diff --git a/server/db/schema.ts b/server/db/schema.ts index 322f7afe..f60e1d68 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -120,6 +120,15 @@ export const emailVerificationCodes = sqliteTable("emailVerificationCodes", { expiresAt: integer("expiresAt").notNull(), }); +export const passwordResetTokens = sqliteTable("passwordResetTokens", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + tokenHash: text("tokenHash").notNull(), + expiresAt: integer("expiresAt").notNull(), +}); + // Define the model types for type inference export type Org = InferSelectModel; export type User = InferSelectModel; @@ -133,3 +142,4 @@ export type EmailVerificationCode = InferSelectModel< typeof emailVerificationCodes >; export type TwoFactorBackupCode = InferSelectModel; +export type PasswordResetToken = InferSelectModel; diff --git a/server/routers/auth/2fa.ts b/server/routers/auth/2fa.ts index 57430b35..2483656b 100644 --- a/server/routers/auth/2fa.ts +++ b/server/routers/auth/2fa.ts @@ -19,10 +19,6 @@ export async function verifyTotpCode( decodeHex(secret), ); - if (!validOTP) { - await new Promise((resolve) => setTimeout(resolve, 250)); // delay to prevent brute force attack - } - return validOTP; } } diff --git a/server/routers/auth/changePassword.ts b/server/routers/auth/changePassword.ts index af2d8317..ae95a0d0 100644 --- a/server/routers/auth/changePassword.ts +++ b/server/routers/auth/changePassword.ts @@ -98,6 +98,8 @@ export async function changePassword( await lucia.invalidateUserSessions(user.id); + // TODO: send email to user confirming password change + return response(res, { data: null, success: true, diff --git a/server/routers/auth/disable2fa.ts b/server/routers/auth/disable2fa.ts index 6bd4e2db..55ae5e80 100644 --- a/server/routers/auth/disable2fa.ts +++ b/server/routers/auth/disable2fa.ts @@ -90,6 +90,8 @@ export async function disable2fa( .delete(twoFactorBackupCodes) .where(eq(twoFactorBackupCodes.userId, user.id)); + // TODO: send email to user confirming two-factor authentication is disabled + return response(res, { data: null, success: true, diff --git a/server/routers/auth/index.ts b/server/routers/auth/index.ts index 5e9d6202..94d0ce62 100644 --- a/server/routers/auth/index.ts +++ b/server/routers/auth/index.ts @@ -12,3 +12,5 @@ export * from "./verifyTargetAccess"; export * from "./verifyEmail"; export * from "./requestEmailVerificationCode"; export * from "./changePassword"; +export * from "./requestPasswordReset"; +export * from "./resetPassword"; diff --git a/server/routers/auth/login.ts b/server/routers/auth/login.ts index 3fc68dc7..65215f7e 100644 --- a/server/routers/auth/login.ts +++ b/server/routers/auth/login.ts @@ -80,7 +80,6 @@ export async function login( }, ); if (!validPassword) { - 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 index b67020f2..dd1a3d1b 100644 --- a/server/routers/auth/password.ts +++ b/server/routers/auth/password.ts @@ -10,10 +10,6 @@ export async function verifyPassword( outputLen: 32, parallelism: 1, }); - if (!validPassword) { - await new Promise((resolve) => setTimeout(resolve, 250)); // delay to prevent brute force attacks - } - return validPassword; } diff --git a/server/routers/auth/requestPasswordReset.ts b/server/routers/auth/requestPasswordReset.ts new file mode 100644 index 00000000..bbc7839d --- /dev/null +++ b/server/routers/auth/requestPasswordReset.ts @@ -0,0 +1,96 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import HttpCode from "@server/types/HttpCode"; +import { response } from "@server/utils"; +import { db } from "@server/db"; +import { passwordResetTokens, users } from "@server/db/schema"; +import { eq } from "drizzle-orm"; +import { sha256 } from "oslo/crypto"; +import { generateIdFromEntropySize, TimeSpan } from "lucia"; +import { encodeHex } from "oslo/encoding"; +import { createDate } from "oslo"; +import logger from "@server/logger"; + +export const requestPasswordResetBody = z.object({ + email: z.string().email(), +}); + +export type RequestPasswordResetBody = z.infer; + +export type RequestPasswordResetResponse = { + sentEmail: boolean; +}; + +export async function requestPasswordReset( + req: Request, + res: Response, + next: NextFunction, +): Promise { + const parsedBody = requestPasswordResetBody.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString(), + ), + ); + } + + const { email } = parsedBody.data; + + try { + const existingUser = await db + .select() + .from(users) + .where(eq(users.email, email)); + + if (!existingUser || !existingUser.length) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "No user with that email exists", + ), + ); + } + + await db + .delete(passwordResetTokens) + .where(eq(passwordResetTokens.userId, existingUser[0].id)); + + const token = generateIdFromEntropySize(25); + const tokenHash = encodeHex( + await sha256(new TextEncoder().encode(token)), + ); + + await db.insert(passwordResetTokens).values({ + userId: existingUser[0].id, + tokenHash, + expiresAt: createDate(new TimeSpan(2, "h")).getTime(), + }); + + // TODO: send email with link to reset password on dashboard + // something like: https://example.com/auth/reset-password?email=${email}&?token=${token} + // for now, just log the token + logger.debug(`Password reset token: ${token}`); + + return response(res, { + data: { + sentEmail: true, + }, + success: true, + error: false, + message: "Password reset email sent", + status: HttpCode.OK, + }); + } catch (e) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to process password reset request" + ), + ); + } +} diff --git a/server/routers/auth/requestTotpSecret.ts b/server/routers/auth/requestTotpSecret.ts index 05a78af2..72e8f5d1 100644 --- a/server/routers/auth/requestTotpSecret.ts +++ b/server/routers/auth/requestTotpSecret.ts @@ -51,7 +51,6 @@ export async function requestTotpSecret( parallelism: 1, }); if (!validPassword) { - await new Promise((resolve) => setTimeout(resolve, 250)); // delay to prevent brute force attacks return next(unauthorized()); } diff --git a/server/routers/auth/resetPassword.ts b/server/routers/auth/resetPassword.ts new file mode 100644 index 00000000..397a54fa --- /dev/null +++ b/server/routers/auth/resetPassword.ts @@ -0,0 +1,142 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import HttpCode from "@server/types/HttpCode"; +import { response } from "@server/utils"; +import { db } from "@server/db"; +import { passwordResetTokens, users } from "@server/db/schema"; +import { eq } from "drizzle-orm"; +import { sha256 } from "oslo/crypto"; +import { hashPassword } from "./password"; +import { verifyTotpCode } from "./2fa"; +import { passwordSchema } from "./passwordSchema"; +import { encodeHex } from "oslo/encoding"; +import { isWithinExpirationDate } from "oslo"; +import lucia from "@server/auth"; + +export const resetPasswordBody = z.object({ + token: z.string(), + newPassword: passwordSchema, + code: z.string().optional(), +}); + +export type ResetPasswordBody = z.infer; + +export type ResetPasswordResponse = { + codeRequested?: boolean; +}; + +export async function resetPassword( + req: Request, + res: Response, + next: NextFunction, +): Promise { + const parsedBody = resetPasswordBody.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString(), + ), + ); + } + + const { token, newPassword, code } = parsedBody.data; + + try { + const tokenHash = encodeHex( + await sha256(new TextEncoder().encode(token)), + ); + + const resetRequest = await db + .select() + .from(passwordResetTokens) + .where(eq(passwordResetTokens.tokenHash, tokenHash)); + + if ( + !resetRequest || + !resetRequest.length || + !isWithinExpirationDate(new Date(resetRequest[0].expiresAt)) + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid or expired password reset token", + ), + ); + } + + const user = await db + .select() + .from(users) + .where(eq(users.id, resetRequest[0].userId)); + + if (!user || !user.length) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "User not found", + ), + ); + } + + if (user[0].twoFactorEnabled) { + if (!code) { + return response(res, { + data: { codeRequested: true }, + success: true, + error: false, + message: "Two-factor authentication required", + status: HttpCode.ACCEPTED, + }); + } + + const validOTP = await verifyTotpCode( + code!, + user[0].twoFactorSecret!, + user[0].id, + ); + + if (!validOTP) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid two-factor authentication code", + ), + ); + } + } + + const passwordHash = await hashPassword(newPassword); + + await lucia.invalidateUserSessions(resetRequest[0].userId); + + await db + .update(users) + .set({ passwordHash }) + .where(eq(users.id, resetRequest[0].userId)); + + await db + .delete(passwordResetTokens) + .where(eq(passwordResetTokens.tokenHash, tokenHash)); + + // TODO: send email to user confirming password reset + + return response(res, { + data: null, + success: true, + error: false, + message: "Password reset successfully", + status: HttpCode.OK, + }); + } catch (e) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to reset password", + ), + ); + } +} diff --git a/server/routers/auth/verifyTotp.ts b/server/routers/auth/verifyTotp.ts index f9f0d15f..4434801d 100644 --- a/server/routers/auth/verifyTotp.ts +++ b/server/routers/auth/verifyTotp.ts @@ -19,7 +19,7 @@ export type VerifyTotpBody = z.infer; export type VerifyTotpResponse = { valid: boolean; - backupCodes: string[]; + backupCodes?: string[]; }; export async function verifyTotp( @@ -63,26 +63,33 @@ export async function verifyTotp( try { const valid = await verifyTotpCode(code, user.twoFactorSecret, user.id); - const backupCodes = await generateBackupCodes(); - for (const code of backupCodes) { - const hash = await hashPassword(code); - - await db.insert(twoFactorBackupCodes).values({ - userId: user.id, - codeHash: hash, - }); - } - + let codes; if (valid) { // if valid, enable two-factor authentication; the totp secret is no longer temporary await db .update(users) .set({ twoFactorEnabled: true }) .where(eq(users.id, user.id)); + + const backupCodes = await generateBackupCodes(); + codes = backupCodes; + for (const code of backupCodes) { + const hash = await hashPassword(code); + + await db.insert(twoFactorBackupCodes).values({ + userId: user.id, + codeHash: hash, + }); + } } + // TODO: send email to user confirming two-factor authentication is enabled + return response(res, { - data: { valid, backupCodes }, + data: { + valid, + ...(valid && codes ? { backupCodes: codes } : {}), + }, success: true, error: false, message: valid diff --git a/server/routers/external.ts b/server/routers/external.ts index b187b4d9..4dccfc4b 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -110,20 +110,16 @@ authRouter.use( authRouter.put("/signup", auth.signup); authRouter.post("/login", auth.login); authRouter.post("/logout", auth.logout); +authRouter.post("/2fa/enable", verifySessionUserMiddleware, auth.verifyTotp); authRouter.post( - "/2fa/verify-code", - verifySessionUserMiddleware, - auth.verifyTotp, -); -authRouter.post( - "/2fa/request-secret", + "/2fa/request", verifySessionUserMiddleware, auth.requestTotpSecret, ); authRouter.post("/2fa/disable", verifySessionUserMiddleware, auth.disable2fa); authRouter.post("/verify-email", verifySessionMiddleware, auth.verifyEmail); authRouter.post( - "/request-email-code", + "/verify-email/request", verifySessionMiddleware, auth.requestEmailVerificationCode, ); @@ -132,3 +128,5 @@ authRouter.post( verifySessionUserMiddleware, auth.changePassword, ); +authRouter.post("/reset-password/request", auth.requestPasswordReset); +authRouter.post("/reset-password/", auth.resetPassword);