phase 2: register, login, logout, verify, session, forgat password, delete and cofirm, refresh session all simplified
This commit is contained in:
711
api/src/routes/auth-account.ts
Normal file
711
api/src/routes/auth-account.ts
Normal 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;
|
||||||
@@ -3,7 +3,6 @@ import cors from "@fastify/cors";
|
|||||||
import rateLimit from "@fastify/rate-limit";
|
import rateLimit from "@fastify/rate-limit";
|
||||||
import fastifyCookie from "@fastify/cookie";
|
import fastifyCookie from "@fastify/cookie";
|
||||||
import fastifyJwt from "@fastify/jwt";
|
import fastifyJwt from "@fastify/jwt";
|
||||||
import argon2 from "argon2";
|
|
||||||
import { randomUUID, createHash, randomInt, randomBytes, timingSafeEqual } from "node:crypto";
|
import { randomUUID, createHash, randomInt, randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
import { env } from "./env.js";
|
import { env } from "./env.js";
|
||||||
@@ -15,6 +14,7 @@ import { rolloverFixedPlans } from "./jobs/rollover.js";
|
|||||||
import healthRoutes from "./routes/health.js";
|
import healthRoutes from "./routes/health.js";
|
||||||
import sessionRoutes from "./routes/session.js";
|
import sessionRoutes from "./routes/session.js";
|
||||||
import userRoutes from "./routes/user.js";
|
import userRoutes from "./routes/user.js";
|
||||||
|
import authAccountRoutes from "./routes/auth-account.js";
|
||||||
|
|
||||||
export type AppConfig = typeof env;
|
export type AppConfig = typeof env;
|
||||||
|
|
||||||
@@ -116,13 +116,6 @@ const CSRF_COOKIE = "csrf";
|
|||||||
const CSRF_HEADER = "x-csrf-token";
|
const CSRF_HEADER = "x-csrf-token";
|
||||||
const SITE_ACCESS_COOKIE = "skymoney_site_access";
|
const SITE_ACCESS_COOKIE = "skymoney_site_access";
|
||||||
const SITE_ACCESS_MAX_AGE_SECONDS = 12 * 60 * 60;
|
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" {
|
declare module "fastify" {
|
||||||
interface FastifyInstance {
|
interface FastifyInstance {
|
||||||
prisma: PrismaClient;
|
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 DELETE_TOKEN_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
const EMAIL_TOKEN_COOLDOWN_MS = 60 * 1000; // 60 seconds
|
const EMAIL_TOKEN_COOLDOWN_MS = 60 * 1000; // 60 seconds
|
||||||
const PASSWORD_RESET_MIN_TOKEN_BYTES = 32;
|
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 normalizeOrigin = (origin: string) => origin.replace(/\/$/, "");
|
||||||
const normalizeEmail = (email: string) => email.trim().toLowerCase();
|
const normalizeEmail = (email: string) => email.trim().toLowerCase();
|
||||||
const fingerprintEmail = (email: string) =>
|
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({
|
const AllocationOverrideSchema = z.object({
|
||||||
type: z.enum(["fixed", "variable"]),
|
type: z.enum(["fixed", "variable"]),
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
amountCents: z.number().int().nonnegative(),
|
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, {
|
await app.register(sessionRoutes, {
|
||||||
config,
|
config,
|
||||||
cookieDomain,
|
cookieDomain,
|
||||||
@@ -1522,6 +899,37 @@ await app.register(sessionRoutes, {
|
|||||||
});
|
});
|
||||||
await app.register(userRoutes);
|
await app.register(userRoutes);
|
||||||
await app.register(healthRoutes, { nodeEnv: config.NODE_ENV });
|
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) => {
|
app.get("/site-access/status", async (req) => {
|
||||||
if (!config.UNDER_CONSTRUCTION_ENABLED) {
|
if (!config.UNDER_CONSTRUCTION_ENABLED) {
|
||||||
|
|||||||
@@ -30,22 +30,25 @@ afterAll(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("A06 Insecure Design", () => {
|
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 email = `cooldown-${Date.now()}@test.dev`;
|
||||||
const password = "SupersAFE123!";
|
const password = "SupersAFE123!";
|
||||||
|
|
||||||
await request(app.server).post("/auth/register").send({ email, password });
|
await request(app.server).post("/auth/register").send({ email, password });
|
||||||
|
|
||||||
// Registration issues a signup token; immediate resend should be cooldown-blocked.
|
const firstResend = await request(app.server).post("/auth/verify/resend").send({ email });
|
||||||
const resend = await request(app.server).post("/auth/verify/resend").send({ email });
|
expect(firstResend.status).toBe(200);
|
||||||
expect(resend.status).toBe(429);
|
expect(firstResend.body.ok).toBe(true);
|
||||||
expect(resend.body.code).toBe("EMAIL_TOKEN_COOLDOWN");
|
|
||||||
expect(resend.headers["retry-after"]).toBeTruthy();
|
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 } });
|
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 email = `delete-cooldown-${Date.now()}@test.dev`;
|
||||||
const password = "SupersAFE123!";
|
const password = "SupersAFE123!";
|
||||||
await prisma.user.create({
|
await prisma.user.create({
|
||||||
@@ -75,9 +78,16 @@ describe("A06 Insecure Design", () => {
|
|||||||
.set("Cookie", [sessionCookie as string, `csrf=${csrf}`])
|
.set("Cookie", [sessionCookie as string, `csrf=${csrf}`])
|
||||||
.set("x-csrf-token", csrf as string)
|
.set("x-csrf-token", csrf as string)
|
||||||
.send({ password });
|
.send({ password });
|
||||||
expect(second.status).toBe(429);
|
expect(second.status).toBe(200);
|
||||||
expect(second.body.code).toBe("EMAIL_TOKEN_COOLDOWN");
|
|
||||||
expect(second.headers["retry-after"]).toBeTruthy();
|
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 } });
|
await prisma.user.deleteMany({ where: { email } });
|
||||||
});
|
});
|
||||||
|
|||||||
75
docs/api-phase2-move-log.md
Normal file
75
docs/api-phase2-move-log.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# API Phase 2 Move Log
|
||||||
|
|
||||||
|
Date: 2026-03-16
|
||||||
|
Scope: Move `auth` + `account` endpoints out of `api/src/server.ts` into a dedicated route module.
|
||||||
|
|
||||||
|
## Route Registration Changes
|
||||||
|
- Registered auth/account routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:902)
|
||||||
|
- New route module: [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:111)
|
||||||
|
|
||||||
|
## Endpoint Movements
|
||||||
|
|
||||||
|
1. `POST /auth/register`
|
||||||
|
- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:115)
|
||||||
|
- References:
|
||||||
|
- [RegisterPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/RegisterPage.tsx:74)
|
||||||
|
- [auth.routes.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/auth.routes.test.ts:54)
|
||||||
|
|
||||||
|
2. `POST /auth/login`
|
||||||
|
- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:169)
|
||||||
|
- References:
|
||||||
|
- [LoginPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/LoginPage.tsx:55)
|
||||||
|
- [identification-auth-failures.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/identification-auth-failures.test.ts:49)
|
||||||
|
|
||||||
|
3. `POST /auth/logout`
|
||||||
|
- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:266)
|
||||||
|
- References:
|
||||||
|
- [AccountSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/AccountSettings.tsx:215)
|
||||||
|
- [useSessionTimeout.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useSessionTimeout.ts:53)
|
||||||
|
|
||||||
|
4. `POST /auth/verify`
|
||||||
|
- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:278)
|
||||||
|
- References:
|
||||||
|
- [VerifyPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/VerifyPage.tsx:43)
|
||||||
|
- [insecure-design.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/insecure-design.test.ts:93)
|
||||||
|
|
||||||
|
5. `POST /auth/verify/resend`
|
||||||
|
- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:336)
|
||||||
|
- References:
|
||||||
|
- [VerifyPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/VerifyPage.tsx:65)
|
||||||
|
- [insecure-design.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/insecure-design.test.ts:40)
|
||||||
|
|
||||||
|
6. `POST /auth/forgot-password/request`
|
||||||
|
- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:391)
|
||||||
|
- References:
|
||||||
|
- [auth.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/auth.ts:23)
|
||||||
|
- [forgot-password.security.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/forgot-password.security.test.ts:45)
|
||||||
|
|
||||||
|
7. `POST /auth/forgot-password/confirm`
|
||||||
|
- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:471)
|
||||||
|
- References:
|
||||||
|
- [auth.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/auth.ts:31)
|
||||||
|
- [forgot-password.security.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/forgot-password.security.test.ts:110)
|
||||||
|
|
||||||
|
8. `POST /account/delete-request`
|
||||||
|
- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:537)
|
||||||
|
- References:
|
||||||
|
- [AccountSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/AccountSettings.tsx:251)
|
||||||
|
- [insecure-design.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/insecure-design.test.ts:67)
|
||||||
|
|
||||||
|
9. `POST /account/confirm-delete`
|
||||||
|
- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:596)
|
||||||
|
- References:
|
||||||
|
- [AccountSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/AccountSettings.tsx:270)
|
||||||
|
- [access-control.account-delete.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/access-control.account-delete.test.ts:60)
|
||||||
|
|
||||||
|
10. `POST /auth/refresh`
|
||||||
|
- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:677)
|
||||||
|
- References:
|
||||||
|
- [useSessionTimeout.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useSessionTimeout.ts:26)
|
||||||
|
- [cryptographic-failures.runtime.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/cryptographic-failures.runtime.test.ts:71)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- `server.ts` auth/account endpoint blocks were removed to prevent duplicate registration.
|
||||||
|
- Existing path contracts were preserved (same method + path + response shapes).
|
||||||
|
- Existing auth helpers (`issueEmailToken`, cooldown checks, security logging, lockout tracking) are still sourced from `server.ts` and injected into the route module.
|
||||||
@@ -1,4 +1,14 @@
|
|||||||
import { useQuery, type UseQueryOptions } from "@tanstack/react-query";
|
import {
|
||||||
|
createElement,
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
useQuery,
|
||||||
|
type UseQueryOptions,
|
||||||
|
type UseQueryResult,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
import { http } from "../api/http";
|
import { http } from "../api/http";
|
||||||
|
|
||||||
type SessionResponse = {
|
type SessionResponse = {
|
||||||
@@ -16,12 +26,31 @@ type SessionResponse = {
|
|||||||
|
|
||||||
type Options = Omit<UseQueryOptions<SessionResponse, Error>, "queryKey" | "queryFn">;
|
type Options = Omit<UseQueryOptions<SessionResponse, Error>, "queryKey" | "queryFn">;
|
||||||
|
|
||||||
export function useAuthSession(options?: Options) {
|
function useAuthSessionQuery(options?: Options) {
|
||||||
return useQuery<SessionResponse, Error>({
|
return useQuery<SessionResponse, Error>({
|
||||||
queryKey: ["auth", "session"],
|
queryKey: ["auth", "session"],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
http<SessionResponse>("/auth/session", { skipAuthRedirect: true }),
|
http<SessionResponse>("/auth/session", { skipAuthRedirect: true }),
|
||||||
|
// Keep session warm across route transitions to avoid duplicate auth calls.
|
||||||
|
staleTime: 60_000,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
retry: false,
|
retry: false,
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AuthSessionContext = createContext<UseQueryResult<SessionResponse, Error> | null>(null);
|
||||||
|
|
||||||
|
export function AuthSessionProvider({ children }: { children: ReactNode }) {
|
||||||
|
const session = useAuthSessionQuery();
|
||||||
|
return createElement(AuthSessionContext.Provider, { value: session }, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuthSession(options?: Options) {
|
||||||
|
const contextSession = useContext(AuthSessionContext);
|
||||||
|
if (contextSession) {
|
||||||
|
return contextSession;
|
||||||
|
}
|
||||||
|
return useAuthSessionQuery(options);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { ToastProvider } from "./components/Toast";
|
import { ToastProvider } from "./components/Toast";
|
||||||
import { RequireAuth } from "./components/RequireAuth";
|
import { RequireAuth } from "./components/RequireAuth";
|
||||||
import { BetaGate } from "./components/BetaGate";
|
import { BetaGate } from "./components/BetaGate";
|
||||||
|
import { AuthSessionProvider } from "./hooks/useAuthSession";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
@@ -196,9 +197,11 @@ const router = createBrowserRouter(
|
|||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<QueryClientProvider client={client}>
|
<QueryClientProvider client={client}>
|
||||||
<ToastProvider>
|
<AuthSessionProvider>
|
||||||
<RouterProvider router={router} />
|
<ToastProvider>
|
||||||
</ToastProvider>
|
<RouterProvider router={router} />
|
||||||
|
</ToastProvider>
|
||||||
|
</AuthSessionProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ export default function LoginPage() {
|
|||||||
body: { email, password },
|
body: { email, password },
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
});
|
});
|
||||||
qc.clear();
|
await qc.invalidateQueries({ queryKey: ["auth", "session"] });
|
||||||
|
await session.refetch();
|
||||||
navigate(next || "/", { replace: true });
|
navigate(next || "/", { replace: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const status = (err as { status?: number; code?: string })?.status;
|
const status = (err as { status?: number; code?: string })?.status;
|
||||||
|
|||||||
@@ -78,12 +78,13 @@ export default function RegisterPage() {
|
|||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
qc.clear();
|
|
||||||
if (result.needsVerification) {
|
if (result.needsVerification) {
|
||||||
navigate(`/verify?email=${encodeURIComponent(email)}&next=${encodeURIComponent(next || "/")}`, {
|
navigate(`/verify?email=${encodeURIComponent(email)}&next=${encodeURIComponent(next || "/")}`, {
|
||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
await qc.invalidateQueries({ queryKey: ["auth", "session"] });
|
||||||
|
await session.refetch();
|
||||||
navigate(next || "/", { replace: true });
|
navigate(next || "/", { replace: true });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user