phase 2: register, login, logout, verify, session, forgat password, delete and cofirm, refresh session all simplified
All checks were successful
Deploy / deploy (push) Successful in 1m31s
Security Tests / security-non-db (push) Successful in 20s
Security Tests / security-db (push) Successful in 25s

This commit is contained in:
2026-03-16 14:19:13 -05:00
parent 60cdcf1fcf
commit a430dfadcf
8 changed files with 879 additions and 641 deletions

View File

@@ -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<string, unknown>
) => 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<void>;
clearEmailTokens: (userId: string, type?: EmailTokenType) => Promise<void>;
issueEmailToken: (
userId: string,
type: EmailTokenType,
ttlMs: number,
token?: string
) => Promise<{ code: string; expiresAt: Date }>;
assertEmailTokenCooldown: (
userId: string,
type: EmailTokenType,
cooldownMs: number
) => Promise<void>;
sendEmail: (payload: { to: string; subject: string; text: string; html?: string }) => Promise<void>;
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<AuthAccountRoutesOptions> = async (
app,
opts
) => {
// First resend is allowed immediately; cooldown applies starting on the next resend.
const verifyResendCountByUser = new Map<string, number>();
// Includes initial delete-code issue + resends.
const deleteCodeIssueCountByUser = new Map<string, number>();
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: `<p>Your SkyMoney verification code is <strong>${code}</strong>.</p><p>Enter it in the app to finish signing up.</p><p>If you prefer, you can also verify at <a href=\"${origin}/verify\">${origin}/verify</a>.</p>`,
});
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: `<p>Your SkyMoney verification code is <strong>${code}</strong>.</p><p>Enter it in the app to finish signing up.</p><p>If you prefer, verify at <a href=\"${origin}/verify\">${origin}/verify</a>.</p>`,
});
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:
`<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>",
});
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: `<p>Your SkyMoney delete confirmation code is <strong>${code}</strong>.</p><p>Enter it in the app to delete your account.</p>`,
});
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;

View File

@@ -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: `<p>Your SkyMoney verification code is <strong>${code}</strong>.</p><p>Enter it in the app to finish signing up.</p><p>If you prefer, you can also verify at <a href=\"${origin}/verify\">${origin}/verify</a>.</p>`,
});
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: `<p>Your SkyMoney verification code is <strong>${code}</strong>.</p><p>Enter it in the app to finish signing up.</p><p>If you prefer, verify at <a href=\"${origin}/verify\">${origin}/verify</a>.</p>`,
});
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:
`<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),
});
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: `<p>Your SkyMoney delete confirmation code is <strong>${code}</strong>.</p><p>Enter it in the app to delete your account.</p>`,
});
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) {

View File

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