added change password endpoint

This commit is contained in:
Milo Schwartz 2024-10-05 15:11:51 -04:00
parent 86fb43d570
commit e7080c4aa8
No known key found for this signature in database
9 changed files with 207 additions and 71 deletions

View file

@ -0,0 +1,113 @@
import { Request, Response, NextFunction } from "express";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { fromError } from "zod-validation-error";
import lucia, { unauthorized } from "@server/auth";
import { z } from "zod";
import { db } from "@server/db";
import { User, users } from "@server/db/schema";
import { eq } from "drizzle-orm";
import { response } from "@server/utils";
import { hashPassword, verifyPassword } from "./password";
import { verifyTotpCode } from "./verifyTotpCode";
import { passwordSchema } from "./passwordSchema";
import logger from "@server/logger";
export const changePasswordBody = z.object({
oldPassword: z.string(),
newPassword: passwordSchema,
code: z.string().optional(),
});
export type ChangePasswordBody = z.infer<typeof changePasswordBody>;
export type ChangePasswordResponse = {
codeRequested?: boolean;
};
export async function changePassword(
req: Request,
res: Response,
next: NextFunction,
): Promise<any> {
const parsedBody = changePasswordBody.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString(),
),
);
}
const { newPassword, oldPassword, code } = parsedBody.data;
const user = req.user as User;
try {
if (newPassword === oldPassword) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"New password cannot be the same as the old password",
),
);
}
const validPassword = await verifyPassword(
oldPassword,
user.passwordHash,
);
if (!validPassword) {
return next(unauthorized());
}
if (user.twoFactorEnabled) {
if (!code) {
return response<{ codeRequested: boolean }>(res, {
data: { codeRequested: true },
success: true,
error: false,
message: "Two-factor authentication required",
status: HttpCode.ACCEPTED,
});
}
const validOTP = await verifyTotpCode(code!, user.twoFactorSecret!);
if (!validOTP) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"The two-factor code you entered is incorrect",
),
);
}
}
const hash = await hashPassword(newPassword);
await db
.update(users)
.set({
passwordHash: hash,
})
.where(eq(users.id, user.id));
await lucia.invalidateUserSessions(user.id);
return response(res, {
data: null,
success: true,
error: false,
message: "Password changed successfully",
status: HttpCode.OK,
});
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to authenticate user",
),
);
}
}

View file

@ -4,13 +4,12 @@ import HttpCode from "@server/types/HttpCode";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { unauthorized } from "@server/auth"; import { unauthorized } from "@server/auth";
import { z } from "zod"; import { z } from "zod";
import { verify } from "@node-rs/argon2";
import { db } from "@server/db"; import { db } from "@server/db";
import { User, users } from "@server/db/schema"; import { User, users } from "@server/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { response } from "@server/utils"; import { response } from "@server/utils";
import { decodeHex } from "oslo/encoding"; import { verifyPassword } from "./password";
import { TOTPController } from "oslo/otp"; import { verifyTotpCode } from "./verifyTotpCode";
export const disable2faBody = z.object({ export const disable2faBody = z.object({
password: z.string(), password: z.string(),
@ -43,14 +42,8 @@ export async function disable2fa(
const user = req.user as User; const user = req.user as User;
try { try {
const validPassword = await verify(user.passwordHash, password, { const validPassword = await verifyPassword(password, user.passwordHash);
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
});
if (!validPassword) { if (!validPassword) {
await new Promise((resolve) => setTimeout(resolve, 250)); // delay to prevent brute force attacks
return next(unauthorized()); return next(unauthorized());
} }
@ -73,22 +66,9 @@ export async function disable2fa(
} }
} }
if (!user.twoFactorSecret) { const validOTP = await verifyTotpCode(code, user.twoFactorSecret!);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to authenticate user",
),
);
}
const validOTP = await new TOTPController().verify(
code,
decodeHex(user.twoFactorSecret),
);
if (!validOTP) { if (!validOTP) {
await new Promise((resolve) => setTimeout(resolve, 250)); // delay to prevent brute force attacks
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,

View file

@ -11,3 +11,4 @@ export * from "./verifyResourceAccess";
export * from "./verifyTargetAccess"; export * from "./verifyTargetAccess";
export * from "./verifyEmail"; export * from "./verifyEmail";
export * from "./requestEmailVerificationCode"; export * from "./requestEmailVerificationCode";
export * from "./changePassword";

View file

@ -14,6 +14,7 @@ import { decodeHex } from "oslo/encoding";
import { TOTPController } from "oslo/otp"; import { TOTPController } from "oslo/otp";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { verifyTotpCode } from "./verifyTotpCode";
export const loginBodySchema = z.object({ export const loginBodySchema = z.object({
email: z.string().email(), email: z.string().email(),
@ -104,22 +105,12 @@ export async function login(
}); });
} }
if (!existingUser.twoFactorSecret) { const validOTP = await verifyTotpCode(
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to authenticate user",
),
);
}
const validOTP = await new TOTPController().verify(
code, code,
decodeHex(existingUser.twoFactorSecret), existingUser.twoFactorSecret!,
); );
if (!validOTP) { if (!validOTP) {
await new Promise((resolve) => setTimeout(resolve, 250)); // delay to prevent brute force attacks
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,

View file

@ -0,0 +1,29 @@
import { hash, verify } from "@node-rs/argon2";
export async function verifyPassword(
password: string,
hash: string,
): Promise<boolean> {
const validPassword = await verify(hash, password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
});
if (!validPassword) {
await new Promise((resolve) => setTimeout(resolve, 250)); // delay to prevent brute force attacks
}
return validPassword;
}
export async function hashPassword(password: string): Promise<string> {
const passwordHash = await hash(password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
});
return passwordHash;
}

View file

@ -0,0 +1,13 @@
import z from "zod";
export const passwordSchema = z
.string()
.min(8, { message: "Password must be at least 8 characters long" })
.max(64, { message: "Password must be at most 64 characters long" })
.regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).*$/, {
message: `Your password must meet the following conditions:
- At least one uppercase English letter.
- At least one lowercase English letter.
- At least one digit.
- At least one special character.`,
});

View file

@ -11,21 +11,11 @@ import createHttpError from "http-errors";
import response from "@server/utils/response"; import response from "@server/utils/response";
import { SqliteError } from "better-sqlite3"; import { SqliteError } from "better-sqlite3";
import { sendEmailVerificationCode } from "./sendEmailVerificationCode"; import { sendEmailVerificationCode } from "./sendEmailVerificationCode";
import logger from "@server/logger"; import { passwordSchema } from "./passwordSchema";
export const signupBodySchema = z.object({ export const signupBodySchema = z.object({
email: z.string().email(), email: z.string().email(),
password: z password: passwordSchema,
.string()
.min(8, { message: "Password must be at least 8 characters long" })
.max(64, { message: "Password must be at most 64 characters long" })
.regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).*$/, {
message: `Your password must meet the following conditions:
- At least one uppercase English letter.
- At least one lowercase English letter.
- At least one digit.
- At least one special character.`,
}),
}); });
export type SignUpBody = z.infer<typeof signupBodySchema>; export type SignUpBody = z.infer<typeof signupBodySchema>;
@ -53,7 +43,6 @@ export async function signup(
const { email, password } = parsedBody.data; const { email, password } = parsedBody.data;
const passwordHash = await hash(password, { const passwordHash = await hash(password, {
// recommended minimum parameters
memoryCost: 19456, memoryCost: 19456,
timeCost: 2, timeCost: 2,
outputLen: 32, outputLen: 32,

View file

@ -0,0 +1,15 @@
import { decodeHex } from "oslo/encoding";
import { TOTPController } from "oslo/otp";
export async function verifyTotpCode(
code: string,
secret: string,
): Promise<boolean> {
const validOTP = await new TOTPController().verify(code, decodeHex(secret));
if (!validOTP) {
await new Promise((resolve) => setTimeout(resolve, 250)); // delay to prevent brute force attack
}
return validOTP;
}

View file

@ -29,16 +29,6 @@ unauthenticated.get("/", (_, res) => {
// Authenticated Root routes // Authenticated Root routes
export const authenticated = Router(); export const authenticated = Router();
authenticated.use(verifySessionUserMiddleware); authenticated.use(verifySessionUserMiddleware);
unauthenticated.use(
rateLimitMiddleware({
windowMin: 60,
max: 5,
type: "IP_AND_PATH",
skipCondition: (req) => {
return !["/auth/request-email-code"].includes(req.path);
},
}),
);
authenticated.put("/org", getUserOrgs, org.createOrg); authenticated.put("/org", getUserOrgs, org.createOrg);
authenticated.get("/orgs", getUserOrgs, org.listOrgs); // TODO we need to check the orgs here authenticated.get("/orgs", getUserOrgs, org.listOrgs); // TODO we need to check the orgs here
@ -107,19 +97,34 @@ authenticated.get("/user/:userId", user.getUser);
authenticated.delete("/user/:userId", user.deleteUser); authenticated.delete("/user/:userId", user.deleteUser);
// Auth routes // Auth routes
unauthenticated.put("/auth/signup", auth.signup); export const authRouter = Router();
unauthenticated.post("/auth/login", auth.login); unauthenticated.use("/auth", authRouter);
unauthenticated.post("/auth/logout", auth.logout); authRouter.use(
authenticated.post("/auth/verify-totp", auth.verifyTotp); rateLimitMiddleware({
authenticated.post("/auth/request-totp-secret", auth.requestTotpSecret); windowMin: 10,
authenticated.post("/auth/disable-2fa", auth.disable2fa); max: 15,
unauthenticated.post( type: "IP_AND_PATH",
"/auth/verify-email", }),
verifySessionMiddleware,
auth.verifyEmail,
); );
unauthenticated.post(
"/auth/request-email-code", authRouter.put("/signup", auth.signup);
authRouter.post("/login", auth.login);
authRouter.post("/logout", auth.logout);
authRouter.post("/verify-totp", verifySessionUserMiddleware, auth.verifyTotp);
authRouter.post(
"/request-totp-secret",
verifySessionUserMiddleware,
auth.requestTotpSecret,
);
authRouter.post("/disable-2fa", verifySessionUserMiddleware, auth.disable2fa);
authRouter.post("/verify-email", verifySessionMiddleware, auth.verifyEmail);
authRouter.post(
"/request-email-code",
verifySessionMiddleware, verifySessionMiddleware,
auth.requestEmailVerificationCode, auth.requestEmailVerificationCode,
); );
authRouter.post(
"/change-password",
verifySessionUserMiddleware,
auth.changePassword,
);