feat: implement forgot password, added security updates
Some checks failed
Deploy / deploy (push) Successful in 1m28s
Security Tests / security-non-db (push) Failing after 18s
Security Tests / security-db (push) Failing after 22s

This commit is contained in:
2026-03-01 21:47:15 -06:00
parent c7c72e8199
commit 15e0c0a88a
19 changed files with 761 additions and 14 deletions

View File

@@ -0,0 +1,2 @@
ALTER TABLE "User"
ADD COLUMN IF NOT EXISTS "passwordChangedAt" TIMESTAMP(3);

View File

@@ -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)

View File

@@ -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,

View File

@@ -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<string, unknown>)?.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<string, unknown>)?.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:
`<p>Use this link to reset your SkyMoney password:</p><p><a href="${resetUrl}">${resetUrl}</a></p>` +
"<p>This link expires soon. If you did not request this, you can ignore this email.</p>",
});
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" };

View File

@@ -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] } } });
});
});

View File

@@ -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);
});
});

View File

@@ -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({}),

View File

@@ -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({