From 15e0c0a88a3e9733a1fd7d3b00ee1ce1c4cf62a0 Mon Sep 17 00:00:00 2001 From: Ricearoni1245 Date: Sun, 1 Mar 2026 21:47:15 -0600 Subject: [PATCH] feat: implement forgot password, added security updates --- .env | 13 +- .env.example | 6 + SECURITY_FORGOT_PASSWORD.md | 46 +++ .../migration.sql | 2 + api/prisma/schema.prisma | 1 + api/src/env.ts | 6 + api/src/server.ts | 263 +++++++++++++++++- api/tests/forgot-password.security.test.ts | 176 ++++++++++++ ...curity-logging-monitoring-failures.test.ts | 16 ++ api/tests/setup.ts | 1 + api/vitest.security.config.ts | 1 + ...ntification-and-Authentication-Failures.md | 9 + ...ecurity-Logging-and-Monitoring-Failures.md | 6 + web/src/App.tsx | 2 + web/src/api/auth.ts | 36 +++ web/src/main.tsx | 4 + web/src/pages/ForgotPasswordPage.tsx | 67 +++++ web/src/pages/LoginPage.tsx | 5 + web/src/pages/ResetPasswordPage.tsx | 115 ++++++++ 19 files changed, 761 insertions(+), 14 deletions(-) create mode 100644 SECURITY_FORGOT_PASSWORD.md create mode 100644 api/prisma/migrations/20260302000000_add_password_changed_at/migration.sql create mode 100644 api/tests/forgot-password.security.test.ts create mode 100644 web/src/api/auth.ts create mode 100644 web/src/pages/ForgotPasswordPage.tsx create mode 100644 web/src/pages/ResetPasswordPage.tsx diff --git a/.env b/.env index 0e30c2c..0a723ba 100644 --- a/.env +++ b/.env @@ -30,11 +30,16 @@ EMAIL_FROM=SkyMoney Budget EMAIL_BOUNCE_FROM=bounces@skymoneybudget.com EMAIL_REPLY_TO=support@skymoneybudget.com -UPDATE_NOTICE_VERSION=2 -UPDATE_NOTICE_TITLE=SkyMoney Security Update -UPDATE_NOTICE_BODY=We strengthened OWASP security controls, auth protections, and deployment security checks. +UPDATE_NOTICE_VERSION=3 +UPDATE_NOTICE_TITLE=SkyMoney Update +UPDATE_NOTICE_BODY=We shipped account security improvements, including a new password reset flow and stronger session protections. + ALLOW_INSECURE_AUTH_FOR_DEV=false JWT_ISSUER=skymoney-api JWT_AUDIENCE=skymoney-web AUTH_MAX_FAILED_ATTEMPTS=5 -AUTH_LOCKOUT_WINDOW_MS=900000 \ No newline at end of file +AUTH_LOCKOUT_WINDOW_MS=900000 + +PASSWORD_RESET_TTL_MINUTES=30 +PASSWORD_RESET_RATE_LIMIT_PER_MINUTE=5 +PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE=10 diff --git a/.env.example b/.env.example index 3ba1ae1..f1418c5 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,12 @@ COOKIE_SECRET=replace-with-32+-chars COOKIE_DOMAIN=skymoneybudget.com AUTH_MAX_FAILED_ATTEMPTS=5 AUTH_LOCKOUT_WINDOW_MS=900000 +PASSWORD_RESET_TTL_MINUTES=30 +PASSWORD_RESET_RATE_LIMIT_PER_MINUTE=5 +PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE=10 +UPDATE_NOTICE_VERSION=3 +UPDATE_NOTICE_TITLE=SkyMoney Update +UPDATE_NOTICE_BODY=We shipped account security improvements, including a new password reset flow and stronger session protections. # Email (verification + delete confirmation) SMTP_HOST=smtp.example.com diff --git a/SECURITY_FORGOT_PASSWORD.md b/SECURITY_FORGOT_PASSWORD.md new file mode 100644 index 0000000..1c772f3 --- /dev/null +++ b/SECURITY_FORGOT_PASSWORD.md @@ -0,0 +1,46 @@ +# Security Forgot Password Controls + +## Implemented Controls + +- Public entry points: + - `POST /auth/forgot-password/request` + - `POST /auth/forgot-password/confirm` +- Enumeration resistance: + - Request endpoint always returns `200` with a generic success message. + - No account existence signal for unknown/unverified emails. +- Verified-account gate: + - Reset tokens are issued only when `emailVerified=true`. +- Token security: + - Reset links contain `uid` and raw `token` in query params. + - Server stores only `SHA-256(token)`. + - Token type is `password_reset`. + - Token must match `uid`, be unused, and be unexpired. + - Token is consumed once (`usedAt`) and cannot be reused. +- Session invalidation: + - Added `User.passwordChangedAt`. + - JWT auth middleware rejects tokens with `iat <= passwordChangedAt`. + - Reset and authenticated password-change both set `passwordChangedAt`. +- Abuse controls: + - Request endpoint: per-IP + email-fingerprint route keying. + - Confirm endpoint: per-IP + uid-fingerprint route keying. + - Endpoint-specific rate limits via env config. +- Logging hygiene: + - Structured security events for request/email/confirm outcomes. + - No plaintext password or raw token in logs. +- Misconfiguration resilience: + - Email send failures do not leak through API response shape. + - Generic response is preserved if SMTP is unavailable. + +## Environment Settings + +- `PASSWORD_RESET_TTL_MINUTES` (default `30`) +- `PASSWORD_RESET_RATE_LIMIT_PER_MINUTE` (default `5`) +- `PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE` (default `10`) + +## Operational Verification + +1. Verify `/auth/forgot-password/request` always returns the same JSON for unknown, unverified, and verified addresses. +2. Verify only verified users get `EmailToken(type=password_reset)` rows. +3. Verify `confirm` succeeds once and fails on replay. +4. Verify pre-reset session cookies fail on protected routes after successful reset. +5. Verify security logs contain `auth.password_reset.*` events and no raw token values. diff --git a/api/prisma/migrations/20260302000000_add_password_changed_at/migration.sql b/api/prisma/migrations/20260302000000_add_password_changed_at/migration.sql new file mode 100644 index 0000000..893af3d --- /dev/null +++ b/api/prisma/migrations/20260302000000_add_password_changed_at/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "User" +ADD COLUMN IF NOT EXISTS "passwordChangedAt" TIMESTAMP(3); diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 56b9f2b..1edbc12 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -24,6 +24,7 @@ model User { id String @id @default(uuid()) email String @unique passwordHash String? + passwordChangedAt DateTime? displayName String? emailVerified Boolean @default(false) seenUpdateVersion Int @default(0) diff --git a/api/src/env.ts b/api/src/env.ts index 41eae0f..f84a879 100644 --- a/api/src/env.ts +++ b/api/src/env.ts @@ -55,6 +55,9 @@ const Env = z.object({ SESSION_TIMEOUT_MINUTES: z.coerce.number().int().positive().default(30), AUTH_MAX_FAILED_ATTEMPTS: z.coerce.number().int().positive().default(5), AUTH_LOCKOUT_WINDOW_MS: z.coerce.number().int().positive().default(15 * 60_000), + PASSWORD_RESET_TTL_MINUTES: z.coerce.number().int().positive().default(30), + PASSWORD_RESET_RATE_LIMIT_PER_MINUTE: z.coerce.number().int().positive().default(5), + PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE: z.coerce.number().int().positive().default(10), APP_ORIGIN: z.string().min(1).default("http://localhost:5173"), UPDATE_NOTICE_VERSION: z.coerce.number().int().nonnegative().default(0), UPDATE_NOTICE_TITLE: z.string().min(1).default("SkyMoney Updated"), @@ -93,6 +96,9 @@ const rawEnv = { SESSION_TIMEOUT_MINUTES: process.env.SESSION_TIMEOUT_MINUTES, AUTH_MAX_FAILED_ATTEMPTS: process.env.AUTH_MAX_FAILED_ATTEMPTS, AUTH_LOCKOUT_WINDOW_MS: process.env.AUTH_LOCKOUT_WINDOW_MS, + PASSWORD_RESET_TTL_MINUTES: process.env.PASSWORD_RESET_TTL_MINUTES, + PASSWORD_RESET_RATE_LIMIT_PER_MINUTE: process.env.PASSWORD_RESET_RATE_LIMIT_PER_MINUTE, + PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE: process.env.PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE, APP_ORIGIN: process.env.APP_ORIGIN, UPDATE_NOTICE_VERSION: process.env.UPDATE_NOTICE_VERSION, UPDATE_NOTICE_TITLE: process.env.UPDATE_NOTICE_TITLE, diff --git a/api/src/server.ts b/api/src/server.ts index 5b4a38f..5c61684 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -4,7 +4,7 @@ import rateLimit from "@fastify/rate-limit"; import fastifyCookie from "@fastify/cookie"; import fastifyJwt from "@fastify/jwt"; import argon2 from "argon2"; -import { randomUUID, createHash, randomInt } from "node:crypto"; +import { randomUUID, createHash, randomInt, randomBytes } from "node:crypto"; import nodemailer from "nodemailer"; import { env } from "./env.js"; import { PrismaClient, Prisma } from "@prisma/client"; @@ -21,6 +21,8 @@ const openPaths = new Set([ "/auth/register", "/auth/verify", "/auth/verify/resend", + "/auth/forgot-password/request", + "/auth/forgot-password/confirm", ]); const mutationRateLimit = { config: { @@ -54,6 +56,37 @@ const codeIssueRateLimit = { }, }, }; +const passwordResetRequestRateLimit = (max: number) => ({ + config: { + rateLimit: { + max, + timeWindow: 60_000, + keyGenerator: (req: any) => { + const rawEmail = + req?.body && typeof req.body === "object" ? (req.body as Record)?.email : ""; + const email = + typeof rawEmail === "string" && rawEmail.trim().length > 0 + ? rawEmail.trim().toLowerCase() + : "unknown"; + return `${req.ip}|${createHash("sha256").update(email).digest("hex").slice(0, 16)}`; + }, + }, + }, +}); +const passwordResetConfirmRateLimit = (max: number) => ({ + config: { + rateLimit: { + max, + timeWindow: 60_000, + keyGenerator: (req: any) => { + const rawUid = + req?.body && typeof req.body === "object" ? (req.body as Record)?.uid : ""; + const uid = typeof rawUid === "string" && rawUid.trim().length > 0 ? rawUid.trim() : "unknown"; + return `${req.ip}|${createHash("sha256").update(uid).digest("hex").slice(0, 16)}`; + }, + }, + }, +}); const pathOf = (url: string) => (url.split("?")[0] || "/"); const normalizeClientIp = (ip: string) => ip.replace("::ffff:", "").toLowerCase(); const isInternalClientIp = (ip: string) => { @@ -145,6 +178,7 @@ const ensureCsrfCookie = (reply: any, existing?: string) => { 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() @@ -277,10 +311,11 @@ function hashToken(token: string) { async function issueEmailToken( userId: string, - type: "signup" | "delete", - ttlMs: number + type: EmailTokenType, + ttlMs: number, + token?: string ) { - const code = generateCode(6); + const code = token ?? generateCode(6); const tokenHash = hashToken(code); const expiresAt = new Date(Date.now() + ttlMs); await app.prisma.emailToken.create({ @@ -294,9 +329,15 @@ async function issueEmailToken( return { code, expiresAt }; } +function generatePasswordResetToken() { + return randomBytes(PASSWORD_RESET_MIN_TOKEN_BYTES).toString("base64url"); +} + +type EmailTokenType = "signup" | "delete" | "password_reset"; + async function assertEmailTokenCooldown( userId: string, - type: "signup" | "delete", + type: EmailTokenType, cooldownMs: number ) { if (cooldownMs <= 0) return; @@ -319,7 +360,7 @@ async function assertEmailTokenCooldown( throw err; } -async function clearEmailTokens(userId: string, type?: "signup" | "delete") { +async function clearEmailTokens(userId: string, type?: EmailTokenType) { await app.prisma.emailToken.deleteMany({ where: type ? { userId, type } : { userId }, }); @@ -730,9 +771,49 @@ app.decorate("ensureUser", async (userId: string) => { return; } try { - const { sub } = await req.jwtVerify<{ sub: string }>(); + const { sub, iat } = await req.jwtVerify<{ sub: string; iat?: number }>(); + const authUser = await app.prisma.user.findUnique({ + where: { id: sub }, + select: { id: true, passwordChangedAt: true }, + }); + if (!authUser) { + logSecurityEvent(req, "auth.unauthenticated_request", "failure", { + path, + method: req.method, + reason: "unknown_user", + }); + return reply + .code(401) + .send({ + ok: false, + code: "UNAUTHENTICATED", + message: "Login required", + requestId: String(req.id ?? ""), + }); + } + const issuedAtMs = + typeof iat === "number" && Number.isFinite(iat) ? Math.floor(iat * 1000) : null; + const passwordChangedAtMs = authUser.passwordChangedAt?.getTime() ?? null; + if (passwordChangedAtMs !== null && (issuedAtMs === null || issuedAtMs <= passwordChangedAtMs)) { + logSecurityEvent(req, "auth.unauthenticated_request", "failure", { + path, + method: req.method, + reason: "stale_session", + userId: sub, + }); + return reply + .code(401) + .send({ + ok: false, + code: "UNAUTHENTICATED", + message: "Login required", + requestId: String(req.id ?? ""), + }); + } req.userId = sub; - await app.ensureUser(req.userId); + if (config.SEED_DEFAULT_BUDGET) { + await seedDefaultBudget(app.prisma, req.userId); + } } catch { logSecurityEvent(req, "auth.unauthenticated_request", "failure", { path, @@ -755,7 +836,14 @@ app.decorate("ensureUser", async (userId: string) => { if (method === "GET" || method === "HEAD" || method === "OPTIONS") { return; } - if (path === "/auth/login" || path === "/auth/register" || path === "/auth/verify" || path === "/auth/verify/resend") { + if ( + path === "/auth/login" || + path === "/auth/register" || + path === "/auth/verify" || + path === "/auth/verify/resend" || + path === "/auth/forgot-password/request" || + path === "/auth/forgot-password/confirm" + ) { return; } const cookieToken = (req.cookies as any)?.[CSRF_COOKIE]; @@ -780,6 +868,14 @@ 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"]), @@ -1058,6 +1154,153 @@ app.post("/auth/verify/resend", codeIssueRateLimit, async (req, reply) => { return { ok: true }; }); +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), @@ -1317,7 +1560,7 @@ app.patch("/me/password", async (req, reply) => { // Update password await app.prisma.user.update({ where: { id: req.userId }, - data: { passwordHash: newHash }, + data: { passwordHash: newHash, passwordChangedAt: new Date() }, }); return { ok: true, message: "Password updated successfully" }; diff --git a/api/tests/forgot-password.security.test.ts b/api/tests/forgot-password.security.test.ts new file mode 100644 index 0000000..db9f2c0 --- /dev/null +++ b/api/tests/forgot-password.security.test.ts @@ -0,0 +1,176 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import request from "supertest"; +import type { FastifyInstance } from "fastify"; +import { PrismaClient } from "@prisma/client"; +import argon2 from "argon2"; +import { createHash } from "node:crypto"; +import { buildApp } from "../src/server"; + +const prisma = new PrismaClient(); +let app: FastifyInstance; + +const hashToken = (token: string) => createHash("sha256").update(token).digest("hex"); + +beforeAll(async () => { + app = await buildApp({ + AUTH_DISABLED: false, + SEED_DEFAULT_BUDGET: false, + PASSWORD_RESET_RATE_LIMIT_PER_MINUTE: 20, + PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE: 20, + }); + await app.ready(); +}); + +afterAll(async () => { + if (app) await app.close(); + await prisma.$disconnect(); +}); + +describe("Forgot password security", () => { + it("returns generic success and only issues reset token for verified users", async () => { + const verifiedEmail = `fp-verified-${Date.now()}@test.dev`; + const unverifiedEmail = `fp-unverified-${Date.now()}@test.dev`; + const passwordHash = await argon2.hash("SupersAFE123!"); + + const [verifiedUser, unverifiedUser] = await Promise.all([ + prisma.user.create({ + data: { email: verifiedEmail, passwordHash, emailVerified: true }, + }), + prisma.user.create({ + data: { email: unverifiedEmail, passwordHash, emailVerified: false }, + }), + ]); + + const unknownRes = await request(app.server) + .post("/auth/forgot-password/request") + .send({ email: `missing-${Date.now()}@test.dev` }); + const verifiedRes = await request(app.server) + .post("/auth/forgot-password/request") + .send({ email: verifiedEmail }); + const unverifiedRes = await request(app.server) + .post("/auth/forgot-password/request") + .send({ email: unverifiedEmail }); + + expect(unknownRes.status).toBe(200); + expect(verifiedRes.status).toBe(200); + expect(unverifiedRes.status).toBe(200); + + expect(unknownRes.body).toEqual({ + ok: true, + message: "If an account exists, reset instructions were sent.", + }); + expect(verifiedRes.body).toEqual(unknownRes.body); + expect(unverifiedRes.body).toEqual(unknownRes.body); + + const [verifiedTokens, unverifiedTokens] = await Promise.all([ + prisma.emailToken.count({ + where: { userId: verifiedUser.id, type: "password_reset", usedAt: null }, + }), + prisma.emailToken.count({ + where: { userId: unverifiedUser.id, type: "password_reset", usedAt: null }, + }), + ]); + expect(verifiedTokens).toBe(1); + expect(unverifiedTokens).toBe(0); + + await prisma.user.deleteMany({ + where: { email: { in: [verifiedEmail, unverifiedEmail] } }, + }); + }); + + it("consumes reset token once, updates password, and invalidates prior session", async () => { + const email = `fp-confirm-${Date.now()}@test.dev`; + const oldPassword = "SupersAFE123!"; + const newPassword = "EvenStrong3rPass!"; + const rawToken = "reset-token-value-0123456789abcdef"; + + const user = await prisma.user.create({ + data: { + email, + emailVerified: true, + passwordHash: await argon2.hash(oldPassword), + }, + }); + + const login = await request(app.server).post("/auth/login").send({ email, password: oldPassword }); + expect(login.status).toBe(200); + const sessionCookie = (login.headers["set-cookie"] ?? []).find((c: string) => c.startsWith("session=")); + expect(sessionCookie).toBeTruthy(); + + await prisma.emailToken.create({ + data: { + userId: user.id, + type: "password_reset", + tokenHash: hashToken(rawToken), + expiresAt: new Date(Date.now() + 30 * 60_000), + }, + }); + + const confirm = await request(app.server) + .post("/auth/forgot-password/confirm") + .send({ uid: user.id, token: rawToken, newPassword }); + expect(confirm.status).toBe(200); + expect(confirm.body.ok).toBe(true); + + const tokenRecord = await prisma.emailToken.findFirst({ + where: { userId: user.id, type: "password_reset", tokenHash: hashToken(rawToken) }, + }); + expect(tokenRecord?.usedAt).toBeTruthy(); + + const secondUse = await request(app.server) + .post("/auth/forgot-password/confirm") + .send({ uid: user.id, token: rawToken, newPassword: "AnotherStrongPass4!" }); + expect(secondUse.status).toBe(400); + expect(secondUse.body.code).toBe("INVALID_OR_EXPIRED_RESET_LINK"); + + const oldSessionAccess = await request(app.server) + .get("/dashboard") + .set("Cookie", [sessionCookie as string]); + expect(oldSessionAccess.status).toBe(401); + + const oldPasswordLogin = await request(app.server) + .post("/auth/login") + .send({ email, password: oldPassword }); + expect(oldPasswordLogin.status).toBe(401); + + const newPasswordLogin = await request(app.server) + .post("/auth/login") + .send({ email, password: newPassword }); + expect(newPasswordLogin.status).toBe(200); + + await prisma.user.deleteMany({ where: { id: user.id } }); + }); + + it("rejects uid/token mismatches with generic error", async () => { + const emailA = `fp-a-${Date.now()}@test.dev`; + const emailB = `fp-b-${Date.now()}@test.dev`; + const rawToken = "mismatch-token-value-abcdef0123456789"; + + const [userA, userB] = await Promise.all([ + prisma.user.create({ + data: { email: emailA, emailVerified: true, passwordHash: await argon2.hash("SupersAFE123!") }, + }), + prisma.user.create({ + data: { email: emailB, emailVerified: true, passwordHash: await argon2.hash("SupersAFE123!") }, + }), + ]); + + await prisma.emailToken.create({ + data: { + userId: userA.id, + type: "password_reset", + tokenHash: hashToken(rawToken), + expiresAt: new Date(Date.now() + 30 * 60_000), + }, + }); + + const mismatch = await request(app.server) + .post("/auth/forgot-password/confirm") + .send({ uid: userB.id, token: rawToken, newPassword: "MismatchPass9!" }); + + expect(mismatch.status).toBe(400); + expect(mismatch.body.code).toBe("INVALID_OR_EXPIRED_RESET_LINK"); + + await prisma.user.deleteMany({ where: { id: { in: [userA.id, userB.id] } } }); + }); +}); diff --git a/api/tests/security-logging-monitoring-failures.test.ts b/api/tests/security-logging-monitoring-failures.test.ts index f599d89..1c9c958 100644 --- a/api/tests/security-logging-monitoring-failures.test.ts +++ b/api/tests/security-logging-monitoring-failures.test.ts @@ -74,4 +74,20 @@ describe("A09 Security Logging and Monitoring Failures", () => { expect(typeof event?.requestId).toBe("string"); expect(typeof event?.ip).toBe("string"); }); + + it("emits structured security log for forgot-password requests without raw token data", async () => { + capturedEvents.length = 0; + + const res = await request(authApp.server) + .post("/auth/forgot-password/request") + .send({ email: `missing-${Date.now()}@test.dev` }); + + expect(res.status).toBe(200); + const event = capturedEvents.find( + (payload) => payload.securityEvent === "auth.password_reset.request" + ); + expect(event).toBeTruthy(); + expect(event?.outcome).toBe("success"); + expect(event && "token" in event).toBe(false); + }); }); diff --git a/api/tests/setup.ts b/api/tests/setup.ts index 02d9ecd..6c7cb19 100644 --- a/api/tests/setup.ts +++ b/api/tests/setup.ts @@ -69,6 +69,7 @@ beforeAll(async () => { // Ensure a clean slate: wipe all tables to avoid cross-file leakage await prisma.$transaction([ + prisma.emailToken.deleteMany({}), prisma.allocation.deleteMany({}), prisma.transaction.deleteMany({}), prisma.incomeEvent.deleteMany({}), diff --git a/api/vitest.security.config.ts b/api/vitest.security.config.ts index 31e7ab0..20b9179 100644 --- a/api/vitest.security.config.ts +++ b/api/vitest.security.config.ts @@ -14,6 +14,7 @@ const baseSecurityTests = [ const dbSecurityTests = [ "tests/insecure-design.test.ts", "tests/identification-auth-failures.test.ts", + "tests/forgot-password.security.test.ts", ]; export default defineConfig({ diff --git a/tests-results-for-OWASP/A07-Identification-and-Authentication-Failures.md b/tests-results-for-OWASP/A07-Identification-and-Authentication-Failures.md index 511023b..4709888 100644 --- a/tests-results-for-OWASP/A07-Identification-and-Authentication-Failures.md +++ b/tests-results-for-OWASP/A07-Identification-and-Authentication-Failures.md @@ -25,6 +25,12 @@ Last updated: March 1, 2026 - `AUTH_MAX_FAILED_ATTEMPTS` (default: `5`) - `AUTH_LOCKOUT_WINDOW_MS` (default: `900000`, 15 minutes) +4. Added forgot-password hardening: +- Public reset request endpoint always returns a generic success response. +- Reset token issuance is restricted to verified users. +- Reset confirmation enforces strong password policy and one-time expiring token usage. +- Successful reset updates `passwordChangedAt` so existing sessions become invalid. + ## Files changed 1. `api/src/server.ts` @@ -33,6 +39,9 @@ Last updated: March 1, 2026 4. `api/tests/auth.routes.test.ts` 5. `api/tests/identification-auth-failures.test.ts` 6. `api/vitest.security.config.ts` +7. `api/tests/forgot-password.security.test.ts` +8. `api/prisma/schema.prisma` +9. `api/prisma/migrations/20260302000000_add_password_changed_at/migration.sql` ## Verification diff --git a/tests-results-for-OWASP/A09-Security-Logging-and-Monitoring-Failures.md b/tests-results-for-OWASP/A09-Security-Logging-and-Monitoring-Failures.md index 96d74b2..22fbe9b 100644 --- a/tests-results-for-OWASP/A09-Security-Logging-and-Monitoring-Failures.md +++ b/tests-results-for-OWASP/A09-Security-Logging-and-Monitoring-Failures.md @@ -21,6 +21,9 @@ Last updated: March 1, 2026 - `auth.logout` success - `auth.verify` success/failure - `auth.verify_resend` success/failure/blocked +- `auth.password_reset.request` success/blocked +- `auth.password_reset.email` success/failure +- `auth.password_reset.confirm` success/failure - `account.delete_request` success/failure/blocked - `account.confirm_delete` success/failure/blocked @@ -32,6 +35,8 @@ Last updated: March 1, 2026 1. `api/src/server.ts` 2. `api/tests/security-logging-monitoring-failures.test.ts` 3. `api/vitest.security.config.ts` +4. `api/tests/forgot-password.security.test.ts` +5. `SECURITY_FORGOT_PASSWORD.md` ## Verification @@ -52,6 +57,7 @@ Dedicated A09 checks in `security-logging-monitoring-failures.test.ts`: 1. Runtime check emits structured `auth.unauthenticated_request` security event for protected-route access failures. 2. Runtime check emits structured `csrf.validation` security event for CSRF failures. 3. Validates correlation fields (`requestId`, `ip`, `outcome`) are present in emitted security events. +4. Runtime check emits `auth.password_reset.request` events and confirms raw token fields are absent. ## Residual notes diff --git a/web/src/App.tsx b/web/src/App.tsx index 87a10f6..b6d52e1 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -18,6 +18,8 @@ export default function App() { location.pathname.startsWith("/login") || location.pathname.startsWith("/register") || location.pathname.startsWith("/verify") || + location.pathname.startsWith("/forgot-password") || + location.pathname.startsWith("/reset-password") || location.pathname.startsWith("/beta"); const showUpdateModal = !isPublicRoute && !!notice && dismissedVersion !== notice.version; diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts new file mode 100644 index 0000000..97ca7ca --- /dev/null +++ b/web/src/api/auth.ts @@ -0,0 +1,36 @@ +import { http } from "./http"; + +export type ForgotPasswordRequestPayload = { + email: string; +}; + +export type ForgotPasswordRequestResponse = { + ok: true; + message: string; +}; + +export type ForgotPasswordConfirmPayload = { + uid: string; + token: string; + newPassword: string; +}; + +export type ForgotPasswordConfirmResponse = { + ok: true; +}; + +export function requestForgotPassword(payload: ForgotPasswordRequestPayload) { + return http("/auth/forgot-password/request", { + method: "POST", + body: payload, + skipAuthRedirect: true, + }); +} + +export function confirmForgotPassword(payload: ForgotPasswordConfirmPayload) { + return http("/auth/forgot-password/confirm", { + method: "POST", + body: payload, + skipAuthRedirect: true, + }); +} diff --git a/web/src/main.tsx b/web/src/main.tsx index e9a26a2..10661ac 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -55,6 +55,8 @@ const LoginPage = lazy(() => import("./pages/LoginPage")); const RegisterPage = lazy(() => import("./pages/RegisterPage")); const BetaAccessPage = lazy(() => import("./pages/BetaAccessPage")); const VerifyPage = lazy(() => import("./pages/VerifyPage")); +const ForgotPasswordPage = lazy(() => import("./pages/ForgotPasswordPage")); +const ResetPasswordPage = lazy(() => import("./pages/ResetPasswordPage")); const router = createBrowserRouter( createRoutesFromElements( @@ -72,6 +74,8 @@ const router = createBrowserRouter( } /> } /> } /> + } /> + } /> {/* Protected onboarding */} (null); + + const emailError = !email.trim() ? "Email is required." : ""; + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setSubmitted(true); + if (emailError) return; + + setPending(true); + try { + await requestForgotPassword({ email }); + setMessage(GENERIC_SUCCESS); + } catch { + setMessage(GENERIC_SUCCESS); + } finally { + setPending(false); + } + } + + return ( +
+
+

Forgot Password

+

+ Enter your email and we will send reset instructions if the account is eligible. +

+ + {message ?
{message}
: null} + +
+ + + +
+ +

+ Back to Sign in +

+
+
+ ); +} diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index 138b472..4c72ea9 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -125,6 +125,11 @@ export default function LoginPage() { {passwordError} )} +
+ + Forgot password? + +
diff --git a/web/src/pages/ResetPasswordPage.tsx b/web/src/pages/ResetPasswordPage.tsx new file mode 100644 index 0000000..d3f587a --- /dev/null +++ b/web/src/pages/ResetPasswordPage.tsx @@ -0,0 +1,115 @@ +import { type FormEvent, useMemo, useState } from "react"; +import { Link, useLocation, useNavigate } from "react-router-dom"; +import { confirmForgotPassword } from "../api/auth"; + +function useQueryParams() { + const location = useLocation(); + return useMemo(() => new URLSearchParams(location.search), [location.search]); +} + +const GENERIC_RESET_ERROR = "Invalid or expired reset link."; + +export default function ResetPasswordPage() { + const navigate = useNavigate(); + const params = useQueryParams(); + + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [pending, setPending] = useState(false); + const [success, setSuccess] = useState(null); + const [error, setError] = useState(null); + + const uid = params.get("uid") || ""; + const token = params.get("token") || ""; + + const passwordError = + !newPassword + ? "New password is required." + : newPassword.length < 12 + ? "Password must be at least 12 characters." + : ""; + const confirmError = + !confirmPassword + ? "Please confirm your password." + : confirmPassword !== newPassword + ? "Passwords do not match." + : ""; + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(null); + + if (!uid || !token) { + setError(GENERIC_RESET_ERROR); + return; + } + if (passwordError || confirmError) { + setError(passwordError || confirmError); + return; + } + + setPending(true); + try { + await confirmForgotPassword({ + uid, + token, + newPassword, + }); + setSuccess("Password reset successful. You can sign in now."); + setTimeout(() => navigate("/login", { replace: true }), 1000); + } catch (err: any) { + if (err?.code === "INVALID_OR_EXPIRED_RESET_LINK" || err?.status === 400) { + setError(GENERIC_RESET_ERROR); + } else { + setError(err?.message || "Unable to reset password."); + } + } finally { + setPending(false); + } + } + + return ( +
+
+

Reset Password

+

Set a new password for your account.

+ + {!uid || !token ?
{GENERIC_RESET_ERROR}
: null} + {error ?
{error}
: null} + {success ?
{success}
: null} + +
+ + + +
+ +

+ Request a new reset link +

+
+
+ ); +}