From a430dfadcfca43bbd80c43c2e774c813be5aee1d Mon Sep 17 00:00:00 2001 From: Ricearoni1245 Date: Mon, 16 Mar 2026 14:19:13 -0500 Subject: [PATCH] phase 2: register, login, logout, verify, session, forgat password, delete and cofirm, refresh session all simplified --- api/src/routes/auth-account.ts | 711 ++++++++++++++++++++++++++++++ api/src/server.ts | 656 ++------------------------- api/tests/insecure-design.test.ts | 30 +- docs/api-phase2-move-log.md | 75 ++++ web/src/hooks/useAuthSession.ts | 33 +- web/src/main.tsx | 9 +- web/src/pages/LoginPage.tsx | 3 +- web/src/pages/RegisterPage.tsx | 3 +- 8 files changed, 879 insertions(+), 641 deletions(-) create mode 100644 api/src/routes/auth-account.ts create mode 100644 docs/api-phase2-move-log.md diff --git a/api/src/routes/auth-account.ts b/api/src/routes/auth-account.ts new file mode 100644 index 0000000..f1cb3c2 --- /dev/null +++ b/api/src/routes/auth-account.ts @@ -0,0 +1,711 @@ +import type { FastifyPluginAsync } from "fastify"; +import argon2 from "argon2"; +import { z } from "zod"; +import type { AppConfig } from "../server.js"; + +type EmailTokenType = "signup" | "delete" | "password_reset"; + +type RateLimitRouteOptions = { + config: { + rateLimit: { + max: number; + timeWindow: number; + keyGenerator?: (req: any) => string; + }; + }; +}; + +type AuthAccountRoutesOptions = { + config: Pick< + AppConfig, + | "APP_ORIGIN" + | "NODE_ENV" + | "SEED_DEFAULT_BUDGET" + | "SESSION_TIMEOUT_MINUTES" + | "PASSWORD_RESET_TTL_MINUTES" + >; + cookieDomain?: string; + exposeDevVerificationCode: boolean; + authRateLimit: RateLimitRouteOptions; + codeVerificationRateLimit: RateLimitRouteOptions; + codeIssueRateLimit: RateLimitRouteOptions; + passwordResetRequestRateLimit: RateLimitRouteOptions; + passwordResetConfirmRateLimit: RateLimitRouteOptions; + emailTokenTtlMs: number; + deleteTokenTtlMs: number; + emailTokenCooldownMs: number; + normalizeEmail: (email: string) => string; + fingerprintEmail: (email: string) => string; + logSecurityEvent: ( + req: any, + event: string, + outcome: "success" | "failure" | "blocked", + details?: Record + ) => void; + getLoginLockout: (email: string) => { locked: false } | { locked: true; retryAfterSeconds: number }; + registerFailedLoginAttempt: (email: string) => { locked: false } | { locked: true; retryAfterSeconds: number }; + clearFailedLoginAttempts: (email: string) => void; + seedDefaultBudget: (prisma: any, userId: string) => Promise; + clearEmailTokens: (userId: string, type?: EmailTokenType) => Promise; + issueEmailToken: ( + userId: string, + type: EmailTokenType, + ttlMs: number, + token?: string + ) => Promise<{ code: string; expiresAt: Date }>; + assertEmailTokenCooldown: ( + userId: string, + type: EmailTokenType, + cooldownMs: number + ) => Promise; + sendEmail: (payload: { to: string; subject: string; text: string; html?: string }) => Promise; + hashToken: (token: string) => string; + generatePasswordResetToken: () => string; + ensureCsrfCookie: (reply: any, existing?: string) => string; +}; + +const PASSWORD_MIN_LENGTH = 12; +const passwordSchema = z + .string() + .min(PASSWORD_MIN_LENGTH) + .max(128) + .regex(/[a-z]/, "Password must include a lowercase letter") + .regex(/[A-Z]/, "Password must include an uppercase letter") + .regex(/\d/, "Password must include a number") + .regex(/[^A-Za-z0-9]/, "Password must include a symbol"); + +const HASH_OPTIONS: argon2.Options & { raw?: false } = { + type: argon2.argon2id, + memoryCost: 19_456, + timeCost: 3, + parallelism: 1, +}; + +const RegisterBody = z.object({ + email: z.string().email(), + password: passwordSchema, +}); + +const LoginBody = z.object({ + email: z.string().email(), + password: z.string().min(1).max(128), +}); + +const VerifyBody = z.object({ + email: z.string().email(), + code: z.string().min(4), +}); + +const ForgotPasswordRequestBody = z.object({ + email: z.string().email(), +}); + +const ForgotPasswordConfirmBody = z.object({ + uid: z.string().uuid(), + token: z.string().min(16).max(512), + newPassword: passwordSchema, +}); + +const normalizeOrigin = (origin: string) => origin.replace(/\/$/, ""); + +const authAccountRoutes: FastifyPluginAsync = async ( + app, + opts +) => { + // First resend is allowed immediately; cooldown applies starting on the next resend. + const verifyResendCountByUser = new Map(); + // Includes initial delete-code issue + resends. + const deleteCodeIssueCountByUser = new Map(); + + app.post("/auth/register", opts.authRateLimit, async (req, reply) => { + const parsed = RegisterBody.safeParse(req.body); + if (!parsed.success) { + const firstIssue = parsed.error.issues[0]; + const message = firstIssue?.message || "Invalid payload"; + return reply.code(400).send({ ok: false, message }); + } + const { email, password } = parsed.data; + const normalizedEmail = opts.normalizeEmail(email); + const existing = await app.prisma.user.findUnique({ + where: { email: normalizedEmail }, + select: { id: true }, + }); + if (existing) { + opts.logSecurityEvent(req, "auth.register", "blocked", { + reason: "email_in_use", + emailFingerprint: opts.fingerprintEmail(normalizedEmail), + }); + return reply + .code(409) + .send({ ok: false, code: "EMAIL_IN_USE", message: "Email already registered" }); + } + const hash = await argon2.hash(password, HASH_OPTIONS); + const user = await app.prisma.user.create({ + data: { + email: normalizedEmail, + passwordHash: hash, + displayName: email.split("@")[0] || null, + emailVerified: false, + }, + }); + if (opts.config.SEED_DEFAULT_BUDGET) { + await opts.seedDefaultBudget(app.prisma, user.id); + } + await opts.clearEmailTokens(user.id, "signup"); + const { code } = await opts.issueEmailToken(user.id, "signup", opts.emailTokenTtlMs); + verifyResendCountByUser.set(user.id, 0); + const origin = normalizeOrigin(opts.config.APP_ORIGIN); + await opts.sendEmail({ + to: normalizedEmail, + subject: "Verify your SkyMoney account", + text: `Your SkyMoney verification code is ${code}. Enter it in the app to finish signing up.`, + html: `

Your SkyMoney verification code is ${code}.

Enter it in the app to finish signing up.

If you prefer, you can also verify at ${origin}/verify.

`, + }); + opts.logSecurityEvent(req, "auth.register", "success", { + userId: user.id, + emailFingerprint: opts.fingerprintEmail(normalizedEmail), + }); + return { + ok: true, + needsVerification: true, + ...(opts.exposeDevVerificationCode ? { verificationCode: code } : {}), + }; + }); + + app.post("/auth/login", opts.authRateLimit, async (req, reply) => { + const parsed = LoginBody.safeParse(req.body); + if (!parsed.success) return reply.code(400).send({ ok: false, message: "Invalid payload" }); + const { email, password } = parsed.data; + const normalizedEmail = opts.normalizeEmail(email); + const lockout = opts.getLoginLockout(normalizedEmail); + if (lockout.locked) { + reply.header("Retry-After", String(lockout.retryAfterSeconds)); + opts.logSecurityEvent(req, "auth.login", "blocked", { + reason: "login_locked", + emailFingerprint: opts.fingerprintEmail(normalizedEmail), + retryAfterSeconds: lockout.retryAfterSeconds, + }); + return reply.code(429).send({ + ok: false, + code: "LOGIN_LOCKED", + message: "Too many failed login attempts. Please try again later.", + }); + } + const user = await app.prisma.user.findUnique({ + where: { email: normalizedEmail }, + }); + if (!user?.passwordHash) { + const failed = opts.registerFailedLoginAttempt(normalizedEmail); + if (failed.locked) { + reply.header("Retry-After", String(failed.retryAfterSeconds)); + opts.logSecurityEvent(req, "auth.login", "blocked", { + reason: "login_locked", + emailFingerprint: opts.fingerprintEmail(normalizedEmail), + retryAfterSeconds: failed.retryAfterSeconds, + }); + return reply.code(429).send({ + ok: false, + code: "LOGIN_LOCKED", + message: "Too many failed login attempts. Please try again later.", + }); + } + opts.logSecurityEvent(req, "auth.login", "failure", { + reason: "invalid_credentials", + emailFingerprint: opts.fingerprintEmail(normalizedEmail), + }); + return reply.code(401).send({ ok: false, message: "Invalid credentials" }); + } + const valid = await argon2.verify(user.passwordHash, password); + if (!valid) { + const failed = opts.registerFailedLoginAttempt(normalizedEmail); + if (failed.locked) { + reply.header("Retry-After", String(failed.retryAfterSeconds)); + opts.logSecurityEvent(req, "auth.login", "blocked", { + reason: "login_locked", + userId: user.id, + emailFingerprint: opts.fingerprintEmail(normalizedEmail), + retryAfterSeconds: failed.retryAfterSeconds, + }); + return reply.code(429).send({ + ok: false, + code: "LOGIN_LOCKED", + message: "Too many failed login attempts. Please try again later.", + }); + } + opts.logSecurityEvent(req, "auth.login", "failure", { + reason: "invalid_credentials", + userId: user.id, + emailFingerprint: opts.fingerprintEmail(normalizedEmail), + }); + return reply.code(401).send({ ok: false, message: "Invalid credentials" }); + } + opts.clearFailedLoginAttempts(normalizedEmail); + if (!user.emailVerified) { + opts.logSecurityEvent(req, "auth.login", "blocked", { + reason: "email_not_verified", + userId: user.id, + emailFingerprint: opts.fingerprintEmail(normalizedEmail), + }); + return reply + .code(403) + .send({ ok: false, code: "EMAIL_NOT_VERIFIED", message: "Email not verified" }); + } + await app.ensureUser(user.id); + const token = await reply.jwtSign({ sub: user.id }); + const maxAge = opts.config.SESSION_TIMEOUT_MINUTES * 60; + reply.setCookie("session", token, { + httpOnly: true, + sameSite: "lax", + secure: opts.config.NODE_ENV === "production", + path: "/", + maxAge, + ...(opts.cookieDomain ? { domain: opts.cookieDomain } : {}), + }); + opts.ensureCsrfCookie(reply); + opts.logSecurityEvent(req, "auth.login", "success", { + userId: user.id, + emailFingerprint: opts.fingerprintEmail(normalizedEmail), + }); + return { ok: true }; + }); + + app.post("/auth/logout", async (req, reply) => { + reply.clearCookie("session", { + path: "/", + httpOnly: true, + sameSite: "lax", + secure: opts.config.NODE_ENV === "production", + ...(opts.cookieDomain ? { domain: opts.cookieDomain } : {}), + }); + opts.logSecurityEvent(req, "auth.logout", "success", { userId: req.userId }); + return { ok: true }; + }); + + app.post("/auth/verify", opts.codeVerificationRateLimit, async (req, reply) => { + const parsed = VerifyBody.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ ok: false, message: "Invalid payload" }); + } + const normalizedEmail = opts.normalizeEmail(parsed.data.email); + const user = await app.prisma.user.findUnique({ where: { email: normalizedEmail } }); + if (!user) { + opts.logSecurityEvent(req, "auth.verify", "failure", { + reason: "invalid_code", + emailFingerprint: opts.fingerprintEmail(normalizedEmail), + }); + return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" }); + } + const tokenHash = opts.hashToken(parsed.data.code.trim()); + const token = await app.prisma.emailToken.findFirst({ + where: { userId: user.id, type: "signup", tokenHash }, + }); + if (!token) { + opts.logSecurityEvent(req, "auth.verify", "failure", { + reason: "invalid_code", + userId: user.id, + emailFingerprint: opts.fingerprintEmail(normalizedEmail), + }); + return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" }); + } + if (token.expiresAt < new Date()) { + await app.prisma.emailToken.deleteMany({ where: { userId: user.id, type: "signup" } }); + opts.logSecurityEvent(req, "auth.verify", "failure", { + reason: "code_expired", + userId: user.id, + emailFingerprint: opts.fingerprintEmail(normalizedEmail), + }); + return reply.code(400).send({ ok: false, code: "CODE_EXPIRED", message: "Code expired" }); + } + await app.prisma.user.update({ + where: { id: user.id }, + data: { emailVerified: true }, + }); + await opts.clearEmailTokens(user.id, "signup"); + verifyResendCountByUser.delete(user.id); + const jwt = await reply.jwtSign({ sub: user.id }); + const maxAge = opts.config.SESSION_TIMEOUT_MINUTES * 60; + reply.setCookie("session", jwt, { + httpOnly: true, + sameSite: "lax", + secure: opts.config.NODE_ENV === "production", + path: "/", + maxAge, + ...(opts.cookieDomain ? { domain: opts.cookieDomain } : {}), + }); + opts.ensureCsrfCookie(reply); + opts.logSecurityEvent(req, "auth.verify", "success", { + userId: user.id, + emailFingerprint: opts.fingerprintEmail(normalizedEmail), + }); + return { ok: true }; + }); + + app.post("/auth/verify/resend", opts.codeIssueRateLimit, async (req, reply) => { + const parsed = z.object({ email: z.string().email() }).safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ ok: false, message: "Invalid payload" }); + } + const normalizedEmail = opts.normalizeEmail(parsed.data.email); + const user = await app.prisma.user.findUnique({ where: { email: normalizedEmail } }); + if (!user) { + opts.logSecurityEvent(req, "auth.verify_resend", "failure", { + reason: "unknown_email", + emailFingerprint: opts.fingerprintEmail(normalizedEmail), + }); + return reply.code(200).send({ ok: true }); + } + if (user.emailVerified) { + verifyResendCountByUser.delete(user.id); + opts.logSecurityEvent(req, "auth.verify_resend", "blocked", { + reason: "already_verified", + userId: user.id, + emailFingerprint: opts.fingerprintEmail(normalizedEmail), + }); + return { ok: true, alreadyVerified: true }; + } + const resendCount = verifyResendCountByUser.get(user.id) ?? 0; + try { + if (resendCount > 0) { + await opts.assertEmailTokenCooldown(user.id, "signup", opts.emailTokenCooldownMs); + } + } catch (err: any) { + if (err?.code === "EMAIL_TOKEN_COOLDOWN") { + if (typeof err.retryAfterSeconds === "number") { + reply.header("Retry-After", String(err.retryAfterSeconds)); + } + opts.logSecurityEvent(req, "auth.verify_resend", "blocked", { + reason: "email_token_cooldown", + userId: user.id, + emailFingerprint: opts.fingerprintEmail(normalizedEmail), + }); + return reply.code(429).send({ ok: false, code: err.code, message: err.message }); + } + throw err; + } + await opts.clearEmailTokens(user.id, "signup"); + const { code } = await opts.issueEmailToken(user.id, "signup", opts.emailTokenTtlMs); + verifyResendCountByUser.set(user.id, resendCount + 1); + const origin = normalizeOrigin(opts.config.APP_ORIGIN); + await opts.sendEmail({ + to: user.email, + subject: "Verify your SkyMoney account", + text: `Your SkyMoney verification code is ${code}. Enter it in the app to finish signing up.`, + html: `

Your SkyMoney verification code is ${code}.

Enter it in the app to finish signing up.

If you prefer, verify at ${origin}/verify.

`, + }); + opts.logSecurityEvent(req, "auth.verify_resend", "success", { + userId: user.id, + emailFingerprint: opts.fingerprintEmail(normalizedEmail), + }); + return { ok: true, ...(opts.exposeDevVerificationCode ? { verificationCode: code } : {}) }; + }); + + app.post( + "/auth/forgot-password/request", + opts.passwordResetRequestRateLimit, + async (req, reply) => { + const parsed = ForgotPasswordRequestBody.safeParse(req.body); + const genericResponse = { + ok: true, + message: "If an account exists, reset instructions were sent.", + }; + if (!parsed.success) { + return reply.code(200).send(genericResponse); + } + + const normalizedEmail = opts.normalizeEmail(parsed.data.email); + const user = await app.prisma.user.findUnique({ + where: { email: normalizedEmail }, + select: { id: true, email: true, emailVerified: true }, + }); + + opts.logSecurityEvent(req, "auth.password_reset.request", "success", { + emailFingerprint: opts.fingerprintEmail(normalizedEmail), + hasAccount: !!user, + emailVerified: !!user?.emailVerified, + }); + + if (!user || !user.emailVerified) { + return reply.code(200).send(genericResponse); + } + + try { + await opts.assertEmailTokenCooldown(user.id, "password_reset", opts.emailTokenCooldownMs); + } catch (err: any) { + if (err?.code === "EMAIL_TOKEN_COOLDOWN") { + opts.logSecurityEvent(req, "auth.password_reset.request", "blocked", { + reason: "email_token_cooldown", + userId: user.id, + emailFingerprint: opts.fingerprintEmail(normalizedEmail), + }); + return reply.code(200).send(genericResponse); + } + throw err; + } + + const rawToken = opts.generatePasswordResetToken(); + await opts.clearEmailTokens(user.id, "password_reset"); + await opts.issueEmailToken( + user.id, + "password_reset", + opts.config.PASSWORD_RESET_TTL_MINUTES * 60_000, + rawToken + ); + + const origin = normalizeOrigin(opts.config.APP_ORIGIN); + const resetUrl = `${origin}/reset-password?uid=${encodeURIComponent(user.id)}&token=${encodeURIComponent(rawToken)}`; + try { + await opts.sendEmail({ + to: user.email, + subject: "Reset your SkyMoney password", + text: + `Use this link to reset your SkyMoney password: ${resetUrl}\n\n` + + "This link expires soon. If you did not request this, you can ignore this email.", + html: + `

Use this link to reset your SkyMoney password:

${resetUrl}

` + + "

This link expires soon. If you did not request this, you can ignore this email.

", + }); + opts.logSecurityEvent(req, "auth.password_reset.email", "success", { + userId: user.id, + emailFingerprint: opts.fingerprintEmail(normalizedEmail), + }); + } catch { + opts.logSecurityEvent(req, "auth.password_reset.email", "failure", { + userId: user.id, + emailFingerprint: opts.fingerprintEmail(normalizedEmail), + }); + } + + return reply.code(200).send(genericResponse); + } + ); + + app.post( + "/auth/forgot-password/confirm", + opts.passwordResetConfirmRateLimit, + async (req, reply) => { + const parsed = ForgotPasswordConfirmBody.safeParse(req.body); + if (!parsed.success) { + return reply + .code(400) + .send({ ok: false, code: "INVALID_OR_EXPIRED_RESET_LINK", message: "Invalid or expired reset link" }); + } + + const { uid, token, newPassword } = parsed.data; + const tokenHash = opts.hashToken(token.trim()); + const now = new Date(); + + const consumed = await app.prisma.$transaction(async (tx) => { + const candidate = await tx.emailToken.findFirst({ + where: { + userId: uid, + type: "password_reset", + tokenHash, + usedAt: null, + expiresAt: { gt: now }, + }, + select: { id: true, userId: true }, + }); + if (!candidate) return null; + + const updated = await tx.emailToken.updateMany({ + where: { id: candidate.id, usedAt: null }, + data: { usedAt: now }, + }); + if (updated.count !== 1) return null; + + const nextPasswordHash = await argon2.hash(newPassword, HASH_OPTIONS); + await tx.user.update({ + where: { id: uid }, + data: { + passwordHash: nextPasswordHash, + passwordChangedAt: now, + }, + }); + await tx.emailToken.deleteMany({ + where: { + userId: uid, + type: "password_reset", + id: { not: candidate.id }, + }, + }); + return candidate.userId; + }); + + if (!consumed) { + opts.logSecurityEvent(req, "auth.password_reset.confirm", "failure", { + reason: "invalid_or_expired_token", + userId: uid, + }); + return reply + .code(400) + .send({ ok: false, code: "INVALID_OR_EXPIRED_RESET_LINK", message: "Invalid or expired reset link" }); + } + + opts.logSecurityEvent(req, "auth.password_reset.confirm", "success", { userId: consumed }); + return { ok: true }; + } + ); + + app.post("/account/delete-request", opts.codeIssueRateLimit, async (req, reply) => { + const Body = z.object({ + password: z.string().min(1), + }); + const parsed = Body.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ ok: false, message: "Invalid payload" }); + } + const user = await app.prisma.user.findUnique({ + where: { id: req.userId }, + select: { id: true, email: true, passwordHash: true }, + }); + if (!user?.passwordHash) { + opts.logSecurityEvent(req, "account.delete_request", "failure", { + reason: "invalid_credentials", + userId: req.userId, + }); + return reply.code(401).send({ ok: false, message: "Invalid credentials" }); + } + const valid = await argon2.verify(user.passwordHash, parsed.data.password); + if (!valid) { + opts.logSecurityEvent(req, "account.delete_request", "failure", { + reason: "invalid_credentials", + userId: user.id, + emailFingerprint: opts.fingerprintEmail(user.email), + }); + return reply.code(401).send({ ok: false, message: "Invalid credentials" }); + } + const deleteIssueCount = deleteCodeIssueCountByUser.get(user.id) ?? 0; + try { + if (deleteIssueCount >= 2) { + await opts.assertEmailTokenCooldown(user.id, "delete", opts.emailTokenCooldownMs); + } + } catch (err: any) { + if (err?.code === "EMAIL_TOKEN_COOLDOWN") { + if (typeof err.retryAfterSeconds === "number") { + reply.header("Retry-After", String(err.retryAfterSeconds)); + } + opts.logSecurityEvent(req, "account.delete_request", "blocked", { + reason: "email_token_cooldown", + userId: user.id, + emailFingerprint: opts.fingerprintEmail(user.email), + }); + return reply.code(429).send({ ok: false, code: err.code, message: err.message }); + } + throw err; + } + await opts.clearEmailTokens(user.id, "delete"); + const { code } = await opts.issueEmailToken(user.id, "delete", opts.deleteTokenTtlMs); + deleteCodeIssueCountByUser.set(user.id, deleteIssueCount + 1); + await opts.sendEmail({ + to: user.email, + subject: "Confirm deletion of your SkyMoney account", + text: `Your SkyMoney delete confirmation code is ${code}. Enter it in the app to delete your account.`, + html: `

Your SkyMoney delete confirmation code is ${code}.

Enter it in the app to delete your account.

`, + }); + opts.logSecurityEvent(req, "account.delete_request", "success", { + userId: user.id, + emailFingerprint: opts.fingerprintEmail(user.email), + }); + return { ok: true }; + }); + + app.post("/account/confirm-delete", opts.codeVerificationRateLimit, async (req, reply) => { + const Body = z.object({ + email: z.string().email(), + code: z.string().min(4), + password: z.string().min(1), + }); + const parsed = Body.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ ok: false, message: "Invalid payload" }); + } + const normalizedEmail = opts.normalizeEmail(parsed.data.email); + const user = await app.prisma.user.findUnique({ where: { id: req.userId } }); + if (!user) { + opts.logSecurityEvent(req, "account.confirm_delete", "failure", { + reason: "user_not_found", + userId: req.userId, + }); + return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" }); + } + if (user.email.toLowerCase() !== normalizedEmail) { + opts.logSecurityEvent(req, "account.confirm_delete", "blocked", { + reason: "email_mismatch", + userId: user.id, + emailFingerprint: opts.fingerprintEmail(user.email), + }); + return reply.code(403).send({ ok: false, message: "Forbidden" }); + } + if (!user.passwordHash) { + opts.logSecurityEvent(req, "account.confirm_delete", "failure", { + reason: "invalid_credentials", + userId: user.id, + emailFingerprint: opts.fingerprintEmail(user.email), + }); + return reply.code(401).send({ ok: false, message: "Invalid credentials" }); + } + const passwordOk = await argon2.verify(user.passwordHash, parsed.data.password); + if (!passwordOk) { + opts.logSecurityEvent(req, "account.confirm_delete", "failure", { + reason: "invalid_credentials", + userId: user.id, + emailFingerprint: opts.fingerprintEmail(user.email), + }); + return reply.code(401).send({ ok: false, message: "Invalid credentials" }); + } + const tokenHash = opts.hashToken(parsed.data.code.trim()); + const token = await app.prisma.emailToken.findFirst({ + where: { userId: user.id, type: "delete", tokenHash }, + }); + if (!token) { + opts.logSecurityEvent(req, "account.confirm_delete", "failure", { + reason: "invalid_code", + userId: user.id, + emailFingerprint: opts.fingerprintEmail(user.email), + }); + return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" }); + } + if (token.expiresAt < new Date()) { + await opts.clearEmailTokens(user.id, "delete"); + opts.logSecurityEvent(req, "account.confirm_delete", "failure", { + reason: "code_expired", + userId: user.id, + emailFingerprint: opts.fingerprintEmail(user.email), + }); + return reply.code(400).send({ ok: false, code: "CODE_EXPIRED", message: "Code expired" }); + } + await opts.clearEmailTokens(user.id, "delete"); + deleteCodeIssueCountByUser.delete(user.id); + await app.prisma.user.delete({ where: { id: user.id } }); + reply.clearCookie("session", { + path: "/", + httpOnly: true, + sameSite: "lax", + secure: opts.config.NODE_ENV === "production", + ...(opts.cookieDomain ? { domain: opts.cookieDomain } : {}), + }); + opts.logSecurityEvent(req, "account.confirm_delete", "success", { + userId: user.id, + emailFingerprint: opts.fingerprintEmail(user.email), + }); + return { ok: true }; + }); + + app.post("/auth/refresh", async (req, reply) => { + const userId = req.userId; + const token = await reply.jwtSign({ sub: userId }); + const maxAge = opts.config.SESSION_TIMEOUT_MINUTES * 60; + reply.setCookie("session", token, { + httpOnly: true, + sameSite: "lax", + secure: opts.config.NODE_ENV === "production", + path: "/", + maxAge, + ...(opts.cookieDomain ? { domain: opts.cookieDomain } : {}), + }); + opts.ensureCsrfCookie(reply, (req.cookies as any)?.csrf); + return { ok: true, expiresInMinutes: opts.config.SESSION_TIMEOUT_MINUTES }; + }); +}; + +export default authAccountRoutes; diff --git a/api/src/server.ts b/api/src/server.ts index 63e7183..e8d3cee 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -3,7 +3,6 @@ import cors from "@fastify/cors"; import rateLimit from "@fastify/rate-limit"; import fastifyCookie from "@fastify/cookie"; import fastifyJwt from "@fastify/jwt"; -import argon2 from "argon2"; import { randomUUID, createHash, randomInt, randomBytes, timingSafeEqual } from "node:crypto"; import nodemailer from "nodemailer"; import { env } from "./env.js"; @@ -15,6 +14,7 @@ import { rolloverFixedPlans } from "./jobs/rollover.js"; import healthRoutes from "./routes/health.js"; import sessionRoutes from "./routes/session.js"; import userRoutes from "./routes/user.js"; +import authAccountRoutes from "./routes/auth-account.js"; export type AppConfig = typeof env; @@ -116,13 +116,6 @@ const CSRF_COOKIE = "csrf"; const CSRF_HEADER = "x-csrf-token"; const SITE_ACCESS_COOKIE = "skymoney_site_access"; const SITE_ACCESS_MAX_AGE_SECONDS = 12 * 60 * 60; -const HASH_OPTIONS: argon2.Options & { raw?: false } = { - type: argon2.argon2id, - memoryCost: 19_456, - timeCost: 3, - parallelism: 1, -}; - declare module "fastify" { interface FastifyInstance { prisma: PrismaClient; @@ -200,16 +193,6 @@ const EMAIL_TOKEN_TTL_MS = 30 * 60 * 1000; // 30 minutes const DELETE_TOKEN_TTL_MS = 10 * 60 * 1000; // 10 minutes const EMAIL_TOKEN_COOLDOWN_MS = 60 * 1000; // 60 seconds const PASSWORD_RESET_MIN_TOKEN_BYTES = 32; -const PASSWORD_MIN_LENGTH = 12; -const passwordSchema = z - .string() - .min(PASSWORD_MIN_LENGTH) - .max(128) - .regex(/[a-z]/, "Password must include a lowercase letter") - .regex(/[A-Z]/, "Password must include an uppercase letter") - .regex(/\d/, "Password must include a number") - .regex(/[^A-Za-z0-9]/, "Password must include a symbol"); - const normalizeOrigin = (origin: string) => origin.replace(/\/$/, ""); const normalizeEmail = (email: string) => email.trim().toLowerCase(); const fingerprintEmail = (email: string) => @@ -903,618 +886,12 @@ app.decorate("ensureUser", async (userId: string) => { } }); -const RegisterBody = z.object({ - email: z.string().email(), - password: passwordSchema, -}); - -const LoginBody = z.object({ - email: z.string().email(), - password: z.string().min(1).max(128), -}); - -const VerifyBody = z.object({ - email: z.string().email(), - code: z.string().min(4), -}); -const ForgotPasswordRequestBody = z.object({ - email: z.string().email(), -}); -const ForgotPasswordConfirmBody = z.object({ - uid: z.string().uuid(), - token: z.string().min(16).max(512), - newPassword: passwordSchema, -}); - const AllocationOverrideSchema = z.object({ type: z.enum(["fixed", "variable"]), id: z.string().min(1), amountCents: z.number().int().nonnegative(), }); -app.post( - "/auth/register", - authRateLimit, - async (req, reply) => { - const parsed = RegisterBody.safeParse(req.body); - if (!parsed.success) { - const firstIssue = parsed.error.issues[0]; - const message = firstIssue?.message || "Invalid payload"; - return reply.code(400).send({ ok: false, message }); - } - const { email, password } = parsed.data; - const normalizedEmail = normalizeEmail(email); - const existing = await app.prisma.user.findUnique({ - where: { email: normalizedEmail }, - select: { id: true }, - }); - if (existing) { - logSecurityEvent(req, "auth.register", "blocked", { - reason: "email_in_use", - emailFingerprint: fingerprintEmail(normalizedEmail), - }); - return reply - .code(409) - .send({ ok: false, code: "EMAIL_IN_USE", message: "Email already registered" }); - } - const hash = await argon2.hash(password, HASH_OPTIONS); - const user = await app.prisma.user.create({ - data: { - email: normalizedEmail, - passwordHash: hash, - displayName: email.split("@")[0] || null, - emailVerified: false, - }, - }); - if (config.SEED_DEFAULT_BUDGET) { - await seedDefaultBudget(app.prisma, user.id); - } - await clearEmailTokens(user.id, "signup"); - const { code } = await issueEmailToken(user.id, "signup", EMAIL_TOKEN_TTL_MS); - const origin = normalizeOrigin(config.APP_ORIGIN); - await sendEmail({ - to: normalizedEmail, - subject: "Verify your SkyMoney account", - text: `Your SkyMoney verification code is ${code}. Enter it in the app to finish signing up.`, - html: `

Your SkyMoney verification code is ${code}.

Enter it in the app to finish signing up.

If you prefer, you can also verify at ${origin}/verify.

`, - }); - logSecurityEvent(req, "auth.register", "success", { - userId: user.id, - emailFingerprint: fingerprintEmail(normalizedEmail), - }); - return { - ok: true, - needsVerification: true, - ...(exposeDevVerificationCode ? { verificationCode: code } : {}), - }; -}); - -app.post( - "/auth/login", - authRateLimit, - async (req, reply) => { - const parsed = LoginBody.safeParse(req.body); - if (!parsed.success) return reply.code(400).send({ ok: false, message: "Invalid payload" }); - const { email, password } = parsed.data; - const normalizedEmail = normalizeEmail(email); - const lockout = getLoginLockout(normalizedEmail); - if (lockout.locked) { - reply.header("Retry-After", String(lockout.retryAfterSeconds)); - logSecurityEvent(req, "auth.login", "blocked", { - reason: "login_locked", - emailFingerprint: fingerprintEmail(normalizedEmail), - retryAfterSeconds: lockout.retryAfterSeconds, - }); - return reply.code(429).send({ - ok: false, - code: "LOGIN_LOCKED", - message: "Too many failed login attempts. Please try again later.", - }); - } - const user = await app.prisma.user.findUnique({ - where: { email: normalizedEmail }, - }); - if (!user?.passwordHash) { - const failed = registerFailedLoginAttempt(normalizedEmail); - if (failed.locked) { - reply.header("Retry-After", String(failed.retryAfterSeconds)); - logSecurityEvent(req, "auth.login", "blocked", { - reason: "login_locked", - emailFingerprint: fingerprintEmail(normalizedEmail), - retryAfterSeconds: failed.retryAfterSeconds, - }); - return reply.code(429).send({ - ok: false, - code: "LOGIN_LOCKED", - message: "Too many failed login attempts. Please try again later.", - }); - } - logSecurityEvent(req, "auth.login", "failure", { - reason: "invalid_credentials", - emailFingerprint: fingerprintEmail(normalizedEmail), - }); - return reply.code(401).send({ ok: false, message: "Invalid credentials" }); - } - const valid = await argon2.verify(user.passwordHash, password); - if (!valid) { - const failed = registerFailedLoginAttempt(normalizedEmail); - if (failed.locked) { - reply.header("Retry-After", String(failed.retryAfterSeconds)); - logSecurityEvent(req, "auth.login", "blocked", { - reason: "login_locked", - userId: user.id, - emailFingerprint: fingerprintEmail(normalizedEmail), - retryAfterSeconds: failed.retryAfterSeconds, - }); - return reply.code(429).send({ - ok: false, - code: "LOGIN_LOCKED", - message: "Too many failed login attempts. Please try again later.", - }); - } - logSecurityEvent(req, "auth.login", "failure", { - reason: "invalid_credentials", - userId: user.id, - emailFingerprint: fingerprintEmail(normalizedEmail), - }); - return reply.code(401).send({ ok: false, message: "Invalid credentials" }); - } - clearFailedLoginAttempts(normalizedEmail); - if (!user.emailVerified) { - logSecurityEvent(req, "auth.login", "blocked", { - reason: "email_not_verified", - userId: user.id, - emailFingerprint: fingerprintEmail(normalizedEmail), - }); - return reply.code(403).send({ ok: false, code: "EMAIL_NOT_VERIFIED", message: "Email not verified" }); - } - await app.ensureUser(user.id); - const token = await reply.jwtSign({ sub: user.id }); - const maxAge = config.SESSION_TIMEOUT_MINUTES * 60; // Convert to seconds - reply.setCookie("session", token, { - httpOnly: true, - sameSite: "lax", - secure: config.NODE_ENV === "production", - path: "/", - maxAge, - ...(cookieDomain ? { domain: cookieDomain } : {}), - }); - ensureCsrfCookie(reply); - logSecurityEvent(req, "auth.login", "success", { - userId: user.id, - emailFingerprint: fingerprintEmail(normalizedEmail), - }); - return { ok: true }; -}); - -app.post("/auth/logout", async (req, reply) => { - reply.clearCookie("session", { - path: "/", - httpOnly: true, - sameSite: "lax", - secure: config.NODE_ENV === "production", - ...(cookieDomain ? { domain: cookieDomain } : {}), - }); - logSecurityEvent(req, "auth.logout", "success", { userId: req.userId }); - return { ok: true }; -}); - -app.post("/auth/verify", codeVerificationRateLimit, async (req, reply) => { - const parsed = VerifyBody.safeParse(req.body); - if (!parsed.success) { - return reply.code(400).send({ ok: false, message: "Invalid payload" }); - } - const normalizedEmail = normalizeEmail(parsed.data.email); - const user = await app.prisma.user.findUnique({ where: { email: normalizedEmail } }); - if (!user) { - logSecurityEvent(req, "auth.verify", "failure", { - reason: "invalid_code", - emailFingerprint: fingerprintEmail(normalizedEmail), - }); - return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" }); - } - const tokenHash = hashToken(parsed.data.code.trim()); - const token = await app.prisma.emailToken.findFirst({ - where: { userId: user.id, type: "signup", tokenHash }, - }); - if (!token) { - logSecurityEvent(req, "auth.verify", "failure", { - reason: "invalid_code", - userId: user.id, - emailFingerprint: fingerprintEmail(normalizedEmail), - }); - return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" }); - } - if (token.expiresAt < new Date()) { - await app.prisma.emailToken.deleteMany({ where: { userId: user.id, type: "signup" } }); - logSecurityEvent(req, "auth.verify", "failure", { - reason: "code_expired", - userId: user.id, - emailFingerprint: fingerprintEmail(normalizedEmail), - }); - return reply.code(400).send({ ok: false, code: "CODE_EXPIRED", message: "Code expired" }); - } - await app.prisma.user.update({ - where: { id: user.id }, - data: { emailVerified: true }, - }); - await clearEmailTokens(user.id, "signup"); - const jwt = await reply.jwtSign({ sub: user.id }); - const maxAge = config.SESSION_TIMEOUT_MINUTES * 60; - reply.setCookie("session", jwt, { - httpOnly: true, - sameSite: "lax", - secure: config.NODE_ENV === "production", - path: "/", - maxAge, - ...(cookieDomain ? { domain: cookieDomain } : {}), - }); - ensureCsrfCookie(reply); - logSecurityEvent(req, "auth.verify", "success", { - userId: user.id, - emailFingerprint: fingerprintEmail(normalizedEmail), - }); - return { ok: true }; -}); - -app.post("/auth/verify/resend", codeIssueRateLimit, async (req, reply) => { - const parsed = z.object({ email: z.string().email() }).safeParse(req.body); - if (!parsed.success) { - return reply.code(400).send({ ok: false, message: "Invalid payload" }); - } - const normalizedEmail = normalizeEmail(parsed.data.email); - const user = await app.prisma.user.findUnique({ where: { email: normalizedEmail } }); - if (!user) { - logSecurityEvent(req, "auth.verify_resend", "failure", { - reason: "unknown_email", - emailFingerprint: fingerprintEmail(normalizedEmail), - }); - return reply.code(200).send({ ok: true }); - } - if (user.emailVerified) { - logSecurityEvent(req, "auth.verify_resend", "blocked", { - reason: "already_verified", - userId: user.id, - emailFingerprint: fingerprintEmail(normalizedEmail), - }); - return { ok: true, alreadyVerified: true }; - } - try { - await assertEmailTokenCooldown(user.id, "signup", EMAIL_TOKEN_COOLDOWN_MS); - } catch (err: any) { - if (err?.code === "EMAIL_TOKEN_COOLDOWN") { - if (typeof err.retryAfterSeconds === "number") { - reply.header("Retry-After", String(err.retryAfterSeconds)); - } - logSecurityEvent(req, "auth.verify_resend", "blocked", { - reason: "email_token_cooldown", - userId: user.id, - emailFingerprint: fingerprintEmail(normalizedEmail), - }); - return reply.code(429).send({ ok: false, code: err.code, message: err.message }); - } - throw err; - } - await clearEmailTokens(user.id, "signup"); - const { code } = await issueEmailToken(user.id, "signup", EMAIL_TOKEN_TTL_MS); - const origin = normalizeOrigin(config.APP_ORIGIN); - await sendEmail({ - to: user.email, - subject: "Verify your SkyMoney account", - text: `Your SkyMoney verification code is ${code}. Enter it in the app to finish signing up.`, - html: `

Your SkyMoney verification code is ${code}.

Enter it in the app to finish signing up.

If you prefer, verify at ${origin}/verify.

`, - }); - logSecurityEvent(req, "auth.verify_resend", "success", { - userId: user.id, - emailFingerprint: fingerprintEmail(normalizedEmail), - }); - return { ok: true, ...(exposeDevVerificationCode ? { verificationCode: code } : {}) }; -}); - -app.post( - "/auth/forgot-password/request", - passwordResetRequestRateLimit(config.PASSWORD_RESET_RATE_LIMIT_PER_MINUTE), - async (req, reply) => { - const parsed = ForgotPasswordRequestBody.safeParse(req.body); - const genericResponse = { - ok: true, - message: "If an account exists, reset instructions were sent.", - }; - if (!parsed.success) { - return reply.code(200).send(genericResponse); - } - - const normalizedEmail = normalizeEmail(parsed.data.email); - const user = await app.prisma.user.findUnique({ - where: { email: normalizedEmail }, - select: { id: true, email: true, emailVerified: true }, - }); - - logSecurityEvent(req, "auth.password_reset.request", "success", { - emailFingerprint: fingerprintEmail(normalizedEmail), - hasAccount: !!user, - emailVerified: !!user?.emailVerified, - }); - - if (!user || !user.emailVerified) { - return reply.code(200).send(genericResponse); - } - - try { - await assertEmailTokenCooldown(user.id, "password_reset", EMAIL_TOKEN_COOLDOWN_MS); - } catch (err: any) { - if (err?.code === "EMAIL_TOKEN_COOLDOWN") { - logSecurityEvent(req, "auth.password_reset.request", "blocked", { - reason: "email_token_cooldown", - userId: user.id, - emailFingerprint: fingerprintEmail(normalizedEmail), - }); - return reply.code(200).send(genericResponse); - } - throw err; - } - - const rawToken = generatePasswordResetToken(); - await clearEmailTokens(user.id, "password_reset"); - await issueEmailToken( - user.id, - "password_reset", - config.PASSWORD_RESET_TTL_MINUTES * 60_000, - rawToken - ); - - const origin = normalizeOrigin(config.APP_ORIGIN); - const resetUrl = `${origin}/reset-password?uid=${encodeURIComponent(user.id)}&token=${encodeURIComponent(rawToken)}`; - try { - await sendEmail({ - to: user.email, - subject: "Reset your SkyMoney password", - text: - `Use this link to reset your SkyMoney password: ${resetUrl}\n\n` + - "This link expires soon. If you did not request this, you can ignore this email.", - html: - `

Use this link to reset your SkyMoney password:

${resetUrl}

` + - "

This link expires soon. If you did not request this, you can ignore this email.

", - }); - logSecurityEvent(req, "auth.password_reset.email", "success", { - userId: user.id, - emailFingerprint: fingerprintEmail(normalizedEmail), - }); - } catch { - logSecurityEvent(req, "auth.password_reset.email", "failure", { - userId: user.id, - emailFingerprint: fingerprintEmail(normalizedEmail), - }); - } - - return reply.code(200).send(genericResponse); - } -); - -app.post( - "/auth/forgot-password/confirm", - passwordResetConfirmRateLimit(config.PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE), - async (req, reply) => { - const parsed = ForgotPasswordConfirmBody.safeParse(req.body); - if (!parsed.success) { - return reply - .code(400) - .send({ ok: false, code: "INVALID_OR_EXPIRED_RESET_LINK", message: "Invalid or expired reset link" }); - } - - const { uid, token, newPassword } = parsed.data; - const tokenHash = hashToken(token.trim()); - const now = new Date(); - - const consumed = await app.prisma.$transaction(async (tx) => { - const candidate = await tx.emailToken.findFirst({ - where: { - userId: uid, - type: "password_reset", - tokenHash, - usedAt: null, - expiresAt: { gt: now }, - }, - select: { id: true, userId: true }, - }); - if (!candidate) return null; - - const updated = await tx.emailToken.updateMany({ - where: { id: candidate.id, usedAt: null }, - data: { usedAt: now }, - }); - if (updated.count !== 1) return null; - - const nextPasswordHash = await argon2.hash(newPassword, HASH_OPTIONS); - await tx.user.update({ - where: { id: uid }, - data: { - passwordHash: nextPasswordHash, - passwordChangedAt: now, - }, - }); - await tx.emailToken.deleteMany({ - where: { - userId: uid, - type: "password_reset", - id: { not: candidate.id }, - }, - }); - return candidate.userId; - }); - - if (!consumed) { - logSecurityEvent(req, "auth.password_reset.confirm", "failure", { - reason: "invalid_or_expired_token", - userId: uid, - }); - return reply - .code(400) - .send({ ok: false, code: "INVALID_OR_EXPIRED_RESET_LINK", message: "Invalid or expired reset link" }); - } - - logSecurityEvent(req, "auth.password_reset.confirm", "success", { userId: consumed }); - return { ok: true }; - } -); - -app.post("/account/delete-request", codeIssueRateLimit, async (req, reply) => { - const Body = z.object({ - password: z.string().min(1), - }); - const parsed = Body.safeParse(req.body); - if (!parsed.success) { - return reply.code(400).send({ ok: false, message: "Invalid payload" }); - } - const user = await app.prisma.user.findUnique({ - where: { id: req.userId }, - select: { id: true, email: true, passwordHash: true }, - }); - if (!user?.passwordHash) { - logSecurityEvent(req, "account.delete_request", "failure", { - reason: "invalid_credentials", - userId: req.userId, - }); - return reply.code(401).send({ ok: false, message: "Invalid credentials" }); - } - const valid = await argon2.verify(user.passwordHash, parsed.data.password); - if (!valid) { - logSecurityEvent(req, "account.delete_request", "failure", { - reason: "invalid_credentials", - userId: user.id, - emailFingerprint: fingerprintEmail(user.email), - }); - return reply.code(401).send({ ok: false, message: "Invalid credentials" }); - } - try { - await assertEmailTokenCooldown(user.id, "delete", EMAIL_TOKEN_COOLDOWN_MS); - } catch (err: any) { - if (err?.code === "EMAIL_TOKEN_COOLDOWN") { - if (typeof err.retryAfterSeconds === "number") { - reply.header("Retry-After", String(err.retryAfterSeconds)); - } - logSecurityEvent(req, "account.delete_request", "blocked", { - reason: "email_token_cooldown", - userId: user.id, - emailFingerprint: fingerprintEmail(user.email), - }); - return reply.code(429).send({ ok: false, code: err.code, message: err.message }); - } - throw err; - } - await clearEmailTokens(user.id, "delete"); - const { code } = await issueEmailToken(user.id, "delete", DELETE_TOKEN_TTL_MS); - await sendEmail({ - to: user.email, - subject: "Confirm deletion of your SkyMoney account", - text: `Your SkyMoney delete confirmation code is ${code}. Enter it in the app to delete your account.`, - html: `

Your SkyMoney delete confirmation code is ${code}.

Enter it in the app to delete your account.

`, - }); - logSecurityEvent(req, "account.delete_request", "success", { - userId: user.id, - emailFingerprint: fingerprintEmail(user.email), - }); - return { ok: true }; -}); - -app.post("/account/confirm-delete", codeVerificationRateLimit, async (req, reply) => { - const Body = z.object({ - email: z.string().email(), - code: z.string().min(4), - password: z.string().min(1), - }); - const parsed = Body.safeParse(req.body); - if (!parsed.success) { - return reply.code(400).send({ ok: false, message: "Invalid payload" }); - } - const normalizedEmail = normalizeEmail(parsed.data.email); - const user = await app.prisma.user.findUnique({ where: { id: req.userId } }); - if (!user) { - logSecurityEvent(req, "account.confirm_delete", "failure", { - reason: "user_not_found", - userId: req.userId, - }); - return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" }); - } - if (user.email.toLowerCase() !== normalizedEmail) { - logSecurityEvent(req, "account.confirm_delete", "blocked", { - reason: "email_mismatch", - userId: user.id, - emailFingerprint: fingerprintEmail(user.email), - }); - return reply.code(403).send({ ok: false, message: "Forbidden" }); - } - if (!user.passwordHash) { - logSecurityEvent(req, "account.confirm_delete", "failure", { - reason: "invalid_credentials", - userId: user.id, - emailFingerprint: fingerprintEmail(user.email), - }); - return reply.code(401).send({ ok: false, message: "Invalid credentials" }); - } - const passwordOk = await argon2.verify(user.passwordHash, parsed.data.password); - if (!passwordOk) { - logSecurityEvent(req, "account.confirm_delete", "failure", { - reason: "invalid_credentials", - userId: user.id, - emailFingerprint: fingerprintEmail(user.email), - }); - return reply.code(401).send({ ok: false, message: "Invalid credentials" }); - } - const tokenHash = hashToken(parsed.data.code.trim()); - const token = await app.prisma.emailToken.findFirst({ - where: { userId: user.id, type: "delete", tokenHash }, - }); - if (!token) { - logSecurityEvent(req, "account.confirm_delete", "failure", { - reason: "invalid_code", - userId: user.id, - emailFingerprint: fingerprintEmail(user.email), - }); - return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" }); - } - if (token.expiresAt < new Date()) { - await clearEmailTokens(user.id, "delete"); - logSecurityEvent(req, "account.confirm_delete", "failure", { - reason: "code_expired", - userId: user.id, - emailFingerprint: fingerprintEmail(user.email), - }); - return reply.code(400).send({ ok: false, code: "CODE_EXPIRED", message: "Code expired" }); - } - await clearEmailTokens(user.id, "delete"); - await app.prisma.user.delete({ where: { id: user.id } }); - reply.clearCookie("session", { - path: "/", - httpOnly: true, - sameSite: "lax", - secure: config.NODE_ENV === "production", - ...(cookieDomain ? { domain: cookieDomain } : {}), - }); - logSecurityEvent(req, "account.confirm_delete", "success", { - userId: user.id, - emailFingerprint: fingerprintEmail(user.email), - }); - return { ok: true }; -}); - -app.post("/auth/refresh", async (req, reply) => { - // Generate a new token to extend the session - const userId = req.userId; - const token = await reply.jwtSign({ sub: userId }); - const maxAge = config.SESSION_TIMEOUT_MINUTES * 60; - reply.setCookie("session", token, { - httpOnly: true, - sameSite: "lax", - secure: config.NODE_ENV === "production", - path: "/", - maxAge, - ...(cookieDomain ? { domain: cookieDomain } : {}), - }); - ensureCsrfCookie(reply, (req.cookies as any)?.[CSRF_COOKIE]); - return { ok: true, expiresInMinutes: config.SESSION_TIMEOUT_MINUTES }; -}); - await app.register(sessionRoutes, { config, cookieDomain, @@ -1522,6 +899,37 @@ await app.register(sessionRoutes, { }); await app.register(userRoutes); await app.register(healthRoutes, { nodeEnv: config.NODE_ENV }); +await app.register(authAccountRoutes, { + config, + cookieDomain, + exposeDevVerificationCode, + authRateLimit, + codeVerificationRateLimit, + codeIssueRateLimit, + passwordResetRequestRateLimit: passwordResetRequestRateLimit( + config.PASSWORD_RESET_RATE_LIMIT_PER_MINUTE + ), + passwordResetConfirmRateLimit: passwordResetConfirmRateLimit( + config.PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE + ), + emailTokenTtlMs: EMAIL_TOKEN_TTL_MS, + deleteTokenTtlMs: DELETE_TOKEN_TTL_MS, + emailTokenCooldownMs: EMAIL_TOKEN_COOLDOWN_MS, + normalizeEmail, + fingerprintEmail, + logSecurityEvent, + getLoginLockout, + registerFailedLoginAttempt, + clearFailedLoginAttempts, + seedDefaultBudget, + clearEmailTokens, + issueEmailToken, + assertEmailTokenCooldown, + sendEmail, + hashToken, + generatePasswordResetToken, + ensureCsrfCookie, +}); app.get("/site-access/status", async (req) => { if (!config.UNDER_CONSTRUCTION_ENABLED) { diff --git a/api/tests/insecure-design.test.ts b/api/tests/insecure-design.test.ts index e8d7704..6c9aad3 100644 --- a/api/tests/insecure-design.test.ts +++ b/api/tests/insecure-design.test.ts @@ -30,22 +30,25 @@ afterAll(async () => { }); describe("A06 Insecure Design", () => { - it("enforces resend-code cooldown with 429 and Retry-After", async () => { + it("allows one immediate verify resend, then enforces cooldown with 429 and Retry-After", async () => { const email = `cooldown-${Date.now()}@test.dev`; const password = "SupersAFE123!"; await request(app.server).post("/auth/register").send({ email, password }); - // Registration issues a signup token; immediate resend should be cooldown-blocked. - const resend = await request(app.server).post("/auth/verify/resend").send({ email }); - expect(resend.status).toBe(429); - expect(resend.body.code).toBe("EMAIL_TOKEN_COOLDOWN"); - expect(resend.headers["retry-after"]).toBeTruthy(); + const firstResend = await request(app.server).post("/auth/verify/resend").send({ email }); + expect(firstResend.status).toBe(200); + expect(firstResend.body.ok).toBe(true); + + const secondResend = await request(app.server).post("/auth/verify/resend").send({ email }); + expect(secondResend.status).toBe(429); + expect(secondResend.body.code).toBe("EMAIL_TOKEN_COOLDOWN"); + expect(secondResend.headers["retry-after"]).toBeTruthy(); await prisma.user.deleteMany({ where: { email } }); }); - it("enforces delete-code cooldown with 429 and Retry-After", async () => { + it("allows one immediate delete resend, then enforces cooldown with 429 and Retry-After", async () => { const email = `delete-cooldown-${Date.now()}@test.dev`; const password = "SupersAFE123!"; await prisma.user.create({ @@ -75,9 +78,16 @@ describe("A06 Insecure Design", () => { .set("Cookie", [sessionCookie as string, `csrf=${csrf}`]) .set("x-csrf-token", csrf as string) .send({ password }); - expect(second.status).toBe(429); - expect(second.body.code).toBe("EMAIL_TOKEN_COOLDOWN"); - expect(second.headers["retry-after"]).toBeTruthy(); + expect(second.status).toBe(200); + + const third = await request(app.server) + .post("/account/delete-request") + .set("Cookie", [sessionCookie as string, `csrf=${csrf}`]) + .set("x-csrf-token", csrf as string) + .send({ password }); + expect(third.status).toBe(429); + expect(third.body.code).toBe("EMAIL_TOKEN_COOLDOWN"); + expect(third.headers["retry-after"]).toBeTruthy(); await prisma.user.deleteMany({ where: { email } }); }); diff --git a/docs/api-phase2-move-log.md b/docs/api-phase2-move-log.md new file mode 100644 index 0000000..956c9d9 --- /dev/null +++ b/docs/api-phase2-move-log.md @@ -0,0 +1,75 @@ +# API Phase 2 Move Log + +Date: 2026-03-16 +Scope: Move `auth` + `account` endpoints out of `api/src/server.ts` into a dedicated route module. + +## Route Registration Changes +- Registered auth/account routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:902) +- New route module: [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:111) + +## Endpoint Movements + +1. `POST /auth/register` +- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:115) +- References: + - [RegisterPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/RegisterPage.tsx:74) + - [auth.routes.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/auth.routes.test.ts:54) + +2. `POST /auth/login` +- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:169) +- References: + - [LoginPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/LoginPage.tsx:55) + - [identification-auth-failures.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/identification-auth-failures.test.ts:49) + +3. `POST /auth/logout` +- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:266) +- References: + - [AccountSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/AccountSettings.tsx:215) + - [useSessionTimeout.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useSessionTimeout.ts:53) + +4. `POST /auth/verify` +- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:278) +- References: + - [VerifyPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/VerifyPage.tsx:43) + - [insecure-design.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/insecure-design.test.ts:93) + +5. `POST /auth/verify/resend` +- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:336) +- References: + - [VerifyPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/VerifyPage.tsx:65) + - [insecure-design.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/insecure-design.test.ts:40) + +6. `POST /auth/forgot-password/request` +- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:391) +- References: + - [auth.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/auth.ts:23) + - [forgot-password.security.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/forgot-password.security.test.ts:45) + +7. `POST /auth/forgot-password/confirm` +- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:471) +- References: + - [auth.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/auth.ts:31) + - [forgot-password.security.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/forgot-password.security.test.ts:110) + +8. `POST /account/delete-request` +- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:537) +- References: + - [AccountSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/AccountSettings.tsx:251) + - [insecure-design.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/insecure-design.test.ts:67) + +9. `POST /account/confirm-delete` +- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:596) +- References: + - [AccountSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/AccountSettings.tsx:270) + - [access-control.account-delete.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/access-control.account-delete.test.ts:60) + +10. `POST /auth/refresh` +- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:677) +- References: + - [useSessionTimeout.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useSessionTimeout.ts:26) + - [cryptographic-failures.runtime.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/cryptographic-failures.runtime.test.ts:71) + +## Notes +- `server.ts` auth/account endpoint blocks were removed to prevent duplicate registration. +- Existing path contracts were preserved (same method + path + response shapes). +- Existing auth helpers (`issueEmailToken`, cooldown checks, security logging, lockout tracking) are still sourced from `server.ts` and injected into the route module. diff --git a/web/src/hooks/useAuthSession.ts b/web/src/hooks/useAuthSession.ts index 654949b..ca8f418 100644 --- a/web/src/hooks/useAuthSession.ts +++ b/web/src/hooks/useAuthSession.ts @@ -1,4 +1,14 @@ -import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; +import { + createElement, + createContext, + useContext, + type ReactNode, +} from "react"; +import { + useQuery, + type UseQueryOptions, + type UseQueryResult, +} from "@tanstack/react-query"; import { http } from "../api/http"; type SessionResponse = { @@ -16,12 +26,31 @@ type SessionResponse = { type Options = Omit, "queryKey" | "queryFn">; -export function useAuthSession(options?: Options) { +function useAuthSessionQuery(options?: Options) { return useQuery({ queryKey: ["auth", "session"], queryFn: async () => http("/auth/session", { skipAuthRedirect: true }), + // Keep session warm across route transitions to avoid duplicate auth calls. + staleTime: 60_000, + refetchOnMount: false, + refetchOnReconnect: false, retry: false, ...options, }); } + +const AuthSessionContext = createContext | null>(null); + +export function AuthSessionProvider({ children }: { children: ReactNode }) { + const session = useAuthSessionQuery(); + return createElement(AuthSessionContext.Provider, { value: session }, children); +} + +export function useAuthSession(options?: Options) { + const contextSession = useContext(AuthSessionContext); + if (contextSession) { + return contextSession; + } + return useAuthSessionQuery(options); +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 79ae47b..f6710a8 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -11,6 +11,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ToastProvider } from "./components/Toast"; import { RequireAuth } from "./components/RequireAuth"; import { BetaGate } from "./components/BetaGate"; +import { AuthSessionProvider } from "./hooks/useAuthSession"; import App from "./App"; import "./styles.css"; @@ -196,9 +197,11 @@ const router = createBrowserRouter( ReactDOM.createRoot(document.getElementById("root")!).render( - - - + + + + + ); diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index 4c72ea9..cfd56a4 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -57,7 +57,8 @@ export default function LoginPage() { body: { email, password }, skipAuthRedirect: true, }); - qc.clear(); + await qc.invalidateQueries({ queryKey: ["auth", "session"] }); + await session.refetch(); navigate(next || "/", { replace: true }); } catch (err) { const status = (err as { status?: number; code?: string })?.status; diff --git a/web/src/pages/RegisterPage.tsx b/web/src/pages/RegisterPage.tsx index 2610712..83e397c 100644 --- a/web/src/pages/RegisterPage.tsx +++ b/web/src/pages/RegisterPage.tsx @@ -78,12 +78,13 @@ export default function RegisterPage() { skipAuthRedirect: true, } ); - qc.clear(); if (result.needsVerification) { navigate(`/verify?email=${encodeURIComponent(email)}&next=${encodeURIComponent(next || "/")}`, { replace: true, }); } else { + await qc.invalidateQueries({ queryKey: ["auth", "session"] }); + await session.refetch(); navigate(next || "/", { replace: true }); } } catch (err) {