diff --git a/server/routers/auth/changePassword.ts b/server/routers/auth/changePassword.ts new file mode 100644 index 00000000..4e201e02 --- /dev/null +++ b/server/routers/auth/changePassword.ts @@ -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; + +export type ChangePasswordResponse = { + codeRequested?: boolean; +}; + +export async function changePassword( + req: Request, + res: Response, + next: NextFunction, +): Promise { + 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", + ), + ); + } +} diff --git a/server/routers/auth/disable2fa.ts b/server/routers/auth/disable2fa.ts index 3b7cfb97..437ba5af 100644 --- a/server/routers/auth/disable2fa.ts +++ b/server/routers/auth/disable2fa.ts @@ -4,13 +4,12 @@ import HttpCode from "@server/types/HttpCode"; import { fromError } from "zod-validation-error"; import { unauthorized } from "@server/auth"; import { z } from "zod"; -import { verify } from "@node-rs/argon2"; import { db } from "@server/db"; import { User, users } from "@server/db/schema"; import { eq } from "drizzle-orm"; import { response } from "@server/utils"; -import { decodeHex } from "oslo/encoding"; -import { TOTPController } from "oslo/otp"; +import { verifyPassword } from "./password"; +import { verifyTotpCode } from "./verifyTotpCode"; export const disable2faBody = z.object({ password: z.string(), @@ -43,14 +42,8 @@ export async function disable2fa( const user = req.user as User; try { - const validPassword = await verify(user.passwordHash, password, { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1, - }); + const validPassword = await verifyPassword(password, user.passwordHash); if (!validPassword) { - await new Promise((resolve) => setTimeout(resolve, 250)); // delay to prevent brute force attacks return next(unauthorized()); } @@ -73,22 +66,9 @@ export async function disable2fa( } } - if (!user.twoFactorSecret) { - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Failed to authenticate user", - ), - ); - } - - const validOTP = await new TOTPController().verify( - code, - decodeHex(user.twoFactorSecret), - ); + const validOTP = await verifyTotpCode(code, user.twoFactorSecret!); if (!validOTP) { - await new Promise((resolve) => setTimeout(resolve, 250)); // delay to prevent brute force attacks return next( createHttpError( HttpCode.BAD_REQUEST, diff --git a/server/routers/auth/index.ts b/server/routers/auth/index.ts index 3195c2e1..5e9d6202 100644 --- a/server/routers/auth/index.ts +++ b/server/routers/auth/index.ts @@ -11,3 +11,4 @@ export * from "./verifyResourceAccess"; export * from "./verifyTargetAccess"; export * from "./verifyEmail"; export * from "./requestEmailVerificationCode"; +export * from "./changePassword"; diff --git a/server/routers/auth/login.ts b/server/routers/auth/login.ts index 8698867e..92430438 100644 --- a/server/routers/auth/login.ts +++ b/server/routers/auth/login.ts @@ -14,6 +14,7 @@ import { decodeHex } from "oslo/encoding"; import { TOTPController } from "oslo/otp"; import { z } from "zod"; import { fromError } from "zod-validation-error"; +import { verifyTotpCode } from "./verifyTotpCode"; export const loginBodySchema = z.object({ email: z.string().email(), @@ -104,22 +105,12 @@ export async function login( }); } - if (!existingUser.twoFactorSecret) { - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Failed to authenticate user", - ), - ); - } - - const validOTP = await new TOTPController().verify( + const validOTP = await verifyTotpCode( code, - decodeHex(existingUser.twoFactorSecret), + existingUser.twoFactorSecret!, ); if (!validOTP) { - await new Promise((resolve) => setTimeout(resolve, 250)); // delay to prevent brute force attacks return next( createHttpError( HttpCode.BAD_REQUEST, diff --git a/server/routers/auth/password.ts b/server/routers/auth/password.ts new file mode 100644 index 00000000..b67020f2 --- /dev/null +++ b/server/routers/auth/password.ts @@ -0,0 +1,29 @@ +import { hash, verify } from "@node-rs/argon2"; + +export async function verifyPassword( + password: string, + hash: string, +): Promise { + 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 { + const passwordHash = await hash(password, { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1, + }); + + return passwordHash; +} diff --git a/server/routers/auth/passwordSchema.ts b/server/routers/auth/passwordSchema.ts new file mode 100644 index 00000000..d1d4cc5b --- /dev/null +++ b/server/routers/auth/passwordSchema.ts @@ -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.`, + }); diff --git a/server/routers/auth/signup.ts b/server/routers/auth/signup.ts index 1bf3e4c4..5ca53d78 100644 --- a/server/routers/auth/signup.ts +++ b/server/routers/auth/signup.ts @@ -11,21 +11,11 @@ import createHttpError from "http-errors"; import response from "@server/utils/response"; import { SqliteError } from "better-sqlite3"; import { sendEmailVerificationCode } from "./sendEmailVerificationCode"; -import logger from "@server/logger"; +import { passwordSchema } from "./passwordSchema"; export const signupBodySchema = z.object({ email: z.string().email(), - password: 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.`, - }), + password: passwordSchema, }); export type SignUpBody = z.infer; @@ -53,7 +43,6 @@ export async function signup( const { email, password } = parsedBody.data; const passwordHash = await hash(password, { - // recommended minimum parameters memoryCost: 19456, timeCost: 2, outputLen: 32, diff --git a/server/routers/auth/verifyTotpCode.ts b/server/routers/auth/verifyTotpCode.ts new file mode 100644 index 00000000..354ce85d --- /dev/null +++ b/server/routers/auth/verifyTotpCode.ts @@ -0,0 +1,15 @@ +import { decodeHex } from "oslo/encoding"; +import { TOTPController } from "oslo/otp"; + +export async function verifyTotpCode( + code: string, + secret: string, +): Promise { + 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; +} diff --git a/server/routers/external.ts b/server/routers/external.ts index 7082db05..3b31d8f6 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -29,16 +29,6 @@ unauthenticated.get("/", (_, res) => { // Authenticated Root routes export const authenticated = Router(); 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.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); // Auth routes -unauthenticated.put("/auth/signup", auth.signup); -unauthenticated.post("/auth/login", auth.login); -unauthenticated.post("/auth/logout", auth.logout); -authenticated.post("/auth/verify-totp", auth.verifyTotp); -authenticated.post("/auth/request-totp-secret", auth.requestTotpSecret); -authenticated.post("/auth/disable-2fa", auth.disable2fa); -unauthenticated.post( - "/auth/verify-email", - verifySessionMiddleware, - auth.verifyEmail, +export const authRouter = Router(); +unauthenticated.use("/auth", authRouter); +authRouter.use( + rateLimitMiddleware({ + windowMin: 10, + max: 15, + type: "IP_AND_PATH", + }), ); -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, auth.requestEmailVerificationCode, ); +authRouter.post( + "/change-password", + verifySessionUserMiddleware, + auth.changePassword, +);