feat: implement forgot password, added security updates
This commit is contained in:
@@ -55,6 +55,9 @@ const Env = z.object({
|
||||
SESSION_TIMEOUT_MINUTES: z.coerce.number().int().positive().default(30),
|
||||
AUTH_MAX_FAILED_ATTEMPTS: z.coerce.number().int().positive().default(5),
|
||||
AUTH_LOCKOUT_WINDOW_MS: z.coerce.number().int().positive().default(15 * 60_000),
|
||||
PASSWORD_RESET_TTL_MINUTES: z.coerce.number().int().positive().default(30),
|
||||
PASSWORD_RESET_RATE_LIMIT_PER_MINUTE: z.coerce.number().int().positive().default(5),
|
||||
PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE: z.coerce.number().int().positive().default(10),
|
||||
APP_ORIGIN: z.string().min(1).default("http://localhost:5173"),
|
||||
UPDATE_NOTICE_VERSION: z.coerce.number().int().nonnegative().default(0),
|
||||
UPDATE_NOTICE_TITLE: z.string().min(1).default("SkyMoney Updated"),
|
||||
@@ -93,6 +96,9 @@ const rawEnv = {
|
||||
SESSION_TIMEOUT_MINUTES: process.env.SESSION_TIMEOUT_MINUTES,
|
||||
AUTH_MAX_FAILED_ATTEMPTS: process.env.AUTH_MAX_FAILED_ATTEMPTS,
|
||||
AUTH_LOCKOUT_WINDOW_MS: process.env.AUTH_LOCKOUT_WINDOW_MS,
|
||||
PASSWORD_RESET_TTL_MINUTES: process.env.PASSWORD_RESET_TTL_MINUTES,
|
||||
PASSWORD_RESET_RATE_LIMIT_PER_MINUTE: process.env.PASSWORD_RESET_RATE_LIMIT_PER_MINUTE,
|
||||
PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE: process.env.PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE,
|
||||
APP_ORIGIN: process.env.APP_ORIGIN,
|
||||
UPDATE_NOTICE_VERSION: process.env.UPDATE_NOTICE_VERSION,
|
||||
UPDATE_NOTICE_TITLE: process.env.UPDATE_NOTICE_TITLE,
|
||||
|
||||
@@ -4,7 +4,7 @@ import rateLimit from "@fastify/rate-limit";
|
||||
import fastifyCookie from "@fastify/cookie";
|
||||
import fastifyJwt from "@fastify/jwt";
|
||||
import argon2 from "argon2";
|
||||
import { randomUUID, createHash, randomInt } from "node:crypto";
|
||||
import { randomUUID, createHash, randomInt, randomBytes } from "node:crypto";
|
||||
import nodemailer from "nodemailer";
|
||||
import { env } from "./env.js";
|
||||
import { PrismaClient, Prisma } from "@prisma/client";
|
||||
@@ -21,6 +21,8 @@ const openPaths = new Set([
|
||||
"/auth/register",
|
||||
"/auth/verify",
|
||||
"/auth/verify/resend",
|
||||
"/auth/forgot-password/request",
|
||||
"/auth/forgot-password/confirm",
|
||||
]);
|
||||
const mutationRateLimit = {
|
||||
config: {
|
||||
@@ -54,6 +56,37 @@ const codeIssueRateLimit = {
|
||||
},
|
||||
},
|
||||
};
|
||||
const passwordResetRequestRateLimit = (max: number) => ({
|
||||
config: {
|
||||
rateLimit: {
|
||||
max,
|
||||
timeWindow: 60_000,
|
||||
keyGenerator: (req: any) => {
|
||||
const rawEmail =
|
||||
req?.body && typeof req.body === "object" ? (req.body as Record<string, unknown>)?.email : "";
|
||||
const email =
|
||||
typeof rawEmail === "string" && rawEmail.trim().length > 0
|
||||
? rawEmail.trim().toLowerCase()
|
||||
: "unknown";
|
||||
return `${req.ip}|${createHash("sha256").update(email).digest("hex").slice(0, 16)}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const passwordResetConfirmRateLimit = (max: number) => ({
|
||||
config: {
|
||||
rateLimit: {
|
||||
max,
|
||||
timeWindow: 60_000,
|
||||
keyGenerator: (req: any) => {
|
||||
const rawUid =
|
||||
req?.body && typeof req.body === "object" ? (req.body as Record<string, unknown>)?.uid : "";
|
||||
const uid = typeof rawUid === "string" && rawUid.trim().length > 0 ? rawUid.trim() : "unknown";
|
||||
return `${req.ip}|${createHash("sha256").update(uid).digest("hex").slice(0, 16)}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const pathOf = (url: string) => (url.split("?")[0] || "/");
|
||||
const normalizeClientIp = (ip: string) => ip.replace("::ffff:", "").toLowerCase();
|
||||
const isInternalClientIp = (ip: string) => {
|
||||
@@ -145,6 +178,7 @@ const ensureCsrfCookie = (reply: any, existing?: string) => {
|
||||
const EMAIL_TOKEN_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
const DELETE_TOKEN_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const EMAIL_TOKEN_COOLDOWN_MS = 60 * 1000; // 60 seconds
|
||||
const PASSWORD_RESET_MIN_TOKEN_BYTES = 32;
|
||||
const PASSWORD_MIN_LENGTH = 12;
|
||||
const passwordSchema = z
|
||||
.string()
|
||||
@@ -277,10 +311,11 @@ function hashToken(token: string) {
|
||||
|
||||
async function issueEmailToken(
|
||||
userId: string,
|
||||
type: "signup" | "delete",
|
||||
ttlMs: number
|
||||
type: EmailTokenType,
|
||||
ttlMs: number,
|
||||
token?: string
|
||||
) {
|
||||
const code = generateCode(6);
|
||||
const code = token ?? generateCode(6);
|
||||
const tokenHash = hashToken(code);
|
||||
const expiresAt = new Date(Date.now() + ttlMs);
|
||||
await app.prisma.emailToken.create({
|
||||
@@ -294,9 +329,15 @@ async function issueEmailToken(
|
||||
return { code, expiresAt };
|
||||
}
|
||||
|
||||
function generatePasswordResetToken() {
|
||||
return randomBytes(PASSWORD_RESET_MIN_TOKEN_BYTES).toString("base64url");
|
||||
}
|
||||
|
||||
type EmailTokenType = "signup" | "delete" | "password_reset";
|
||||
|
||||
async function assertEmailTokenCooldown(
|
||||
userId: string,
|
||||
type: "signup" | "delete",
|
||||
type: EmailTokenType,
|
||||
cooldownMs: number
|
||||
) {
|
||||
if (cooldownMs <= 0) return;
|
||||
@@ -319,7 +360,7 @@ async function assertEmailTokenCooldown(
|
||||
throw err;
|
||||
}
|
||||
|
||||
async function clearEmailTokens(userId: string, type?: "signup" | "delete") {
|
||||
async function clearEmailTokens(userId: string, type?: EmailTokenType) {
|
||||
await app.prisma.emailToken.deleteMany({
|
||||
where: type ? { userId, type } : { userId },
|
||||
});
|
||||
@@ -730,9 +771,49 @@ app.decorate("ensureUser", async (userId: string) => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { sub } = await req.jwtVerify<{ sub: string }>();
|
||||
const { sub, iat } = await req.jwtVerify<{ sub: string; iat?: number }>();
|
||||
const authUser = await app.prisma.user.findUnique({
|
||||
where: { id: sub },
|
||||
select: { id: true, passwordChangedAt: true },
|
||||
});
|
||||
if (!authUser) {
|
||||
logSecurityEvent(req, "auth.unauthenticated_request", "failure", {
|
||||
path,
|
||||
method: req.method,
|
||||
reason: "unknown_user",
|
||||
});
|
||||
return reply
|
||||
.code(401)
|
||||
.send({
|
||||
ok: false,
|
||||
code: "UNAUTHENTICATED",
|
||||
message: "Login required",
|
||||
requestId: String(req.id ?? ""),
|
||||
});
|
||||
}
|
||||
const issuedAtMs =
|
||||
typeof iat === "number" && Number.isFinite(iat) ? Math.floor(iat * 1000) : null;
|
||||
const passwordChangedAtMs = authUser.passwordChangedAt?.getTime() ?? null;
|
||||
if (passwordChangedAtMs !== null && (issuedAtMs === null || issuedAtMs <= passwordChangedAtMs)) {
|
||||
logSecurityEvent(req, "auth.unauthenticated_request", "failure", {
|
||||
path,
|
||||
method: req.method,
|
||||
reason: "stale_session",
|
||||
userId: sub,
|
||||
});
|
||||
return reply
|
||||
.code(401)
|
||||
.send({
|
||||
ok: false,
|
||||
code: "UNAUTHENTICATED",
|
||||
message: "Login required",
|
||||
requestId: String(req.id ?? ""),
|
||||
});
|
||||
}
|
||||
req.userId = sub;
|
||||
await app.ensureUser(req.userId);
|
||||
if (config.SEED_DEFAULT_BUDGET) {
|
||||
await seedDefaultBudget(app.prisma, req.userId);
|
||||
}
|
||||
} catch {
|
||||
logSecurityEvent(req, "auth.unauthenticated_request", "failure", {
|
||||
path,
|
||||
@@ -755,7 +836,14 @@ app.decorate("ensureUser", async (userId: string) => {
|
||||
if (method === "GET" || method === "HEAD" || method === "OPTIONS") {
|
||||
return;
|
||||
}
|
||||
if (path === "/auth/login" || path === "/auth/register" || path === "/auth/verify" || path === "/auth/verify/resend") {
|
||||
if (
|
||||
path === "/auth/login" ||
|
||||
path === "/auth/register" ||
|
||||
path === "/auth/verify" ||
|
||||
path === "/auth/verify/resend" ||
|
||||
path === "/auth/forgot-password/request" ||
|
||||
path === "/auth/forgot-password/confirm"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const cookieToken = (req.cookies as any)?.[CSRF_COOKIE];
|
||||
@@ -780,6 +868,14 @@ const VerifyBody = z.object({
|
||||
email: z.string().email(),
|
||||
code: z.string().min(4),
|
||||
});
|
||||
const ForgotPasswordRequestBody = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
const ForgotPasswordConfirmBody = z.object({
|
||||
uid: z.string().uuid(),
|
||||
token: z.string().min(16).max(512),
|
||||
newPassword: passwordSchema,
|
||||
});
|
||||
|
||||
const AllocationOverrideSchema = z.object({
|
||||
type: z.enum(["fixed", "variable"]),
|
||||
@@ -1058,6 +1154,153 @@ app.post("/auth/verify/resend", codeIssueRateLimit, async (req, reply) => {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/auth/forgot-password/request",
|
||||
passwordResetRequestRateLimit(config.PASSWORD_RESET_RATE_LIMIT_PER_MINUTE),
|
||||
async (req, reply) => {
|
||||
const parsed = ForgotPasswordRequestBody.safeParse(req.body);
|
||||
const genericResponse = {
|
||||
ok: true,
|
||||
message: "If an account exists, reset instructions were sent.",
|
||||
};
|
||||
if (!parsed.success) {
|
||||
return reply.code(200).send(genericResponse);
|
||||
}
|
||||
|
||||
const normalizedEmail = normalizeEmail(parsed.data.email);
|
||||
const user = await app.prisma.user.findUnique({
|
||||
where: { email: normalizedEmail },
|
||||
select: { id: true, email: true, emailVerified: true },
|
||||
});
|
||||
|
||||
logSecurityEvent(req, "auth.password_reset.request", "success", {
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
hasAccount: !!user,
|
||||
emailVerified: !!user?.emailVerified,
|
||||
});
|
||||
|
||||
if (!user || !user.emailVerified) {
|
||||
return reply.code(200).send(genericResponse);
|
||||
}
|
||||
|
||||
try {
|
||||
await assertEmailTokenCooldown(user.id, "password_reset", EMAIL_TOKEN_COOLDOWN_MS);
|
||||
} catch (err: any) {
|
||||
if (err?.code === "EMAIL_TOKEN_COOLDOWN") {
|
||||
logSecurityEvent(req, "auth.password_reset.request", "blocked", {
|
||||
reason: "email_token_cooldown",
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return reply.code(200).send(genericResponse);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const rawToken = generatePasswordResetToken();
|
||||
await clearEmailTokens(user.id, "password_reset");
|
||||
await issueEmailToken(
|
||||
user.id,
|
||||
"password_reset",
|
||||
config.PASSWORD_RESET_TTL_MINUTES * 60_000,
|
||||
rawToken
|
||||
);
|
||||
|
||||
const origin = normalizeOrigin(config.APP_ORIGIN);
|
||||
const resetUrl = `${origin}/reset-password?uid=${encodeURIComponent(user.id)}&token=${encodeURIComponent(rawToken)}`;
|
||||
try {
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Reset your SkyMoney password",
|
||||
text:
|
||||
`Use this link to reset your SkyMoney password: ${resetUrl}\n\n` +
|
||||
"This link expires soon. If you did not request this, you can ignore this email.",
|
||||
html:
|
||||
`<p>Use this link to reset your SkyMoney password:</p><p><a href="${resetUrl}">${resetUrl}</a></p>` +
|
||||
"<p>This link expires soon. If you did not request this, you can ignore this email.</p>",
|
||||
});
|
||||
logSecurityEvent(req, "auth.password_reset.email", "success", {
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
} catch {
|
||||
logSecurityEvent(req, "auth.password_reset.email", "failure", {
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(200).send(genericResponse);
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/auth/forgot-password/confirm",
|
||||
passwordResetConfirmRateLimit(config.PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE),
|
||||
async (req, reply) => {
|
||||
const parsed = ForgotPasswordConfirmBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ ok: false, code: "INVALID_OR_EXPIRED_RESET_LINK", message: "Invalid or expired reset link" });
|
||||
}
|
||||
|
||||
const { uid, token, newPassword } = parsed.data;
|
||||
const tokenHash = hashToken(token.trim());
|
||||
const now = new Date();
|
||||
|
||||
const consumed = await app.prisma.$transaction(async (tx) => {
|
||||
const candidate = await tx.emailToken.findFirst({
|
||||
where: {
|
||||
userId: uid,
|
||||
type: "password_reset",
|
||||
tokenHash,
|
||||
usedAt: null,
|
||||
expiresAt: { gt: now },
|
||||
},
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
if (!candidate) return null;
|
||||
|
||||
const updated = await tx.emailToken.updateMany({
|
||||
where: { id: candidate.id, usedAt: null },
|
||||
data: { usedAt: now },
|
||||
});
|
||||
if (updated.count !== 1) return null;
|
||||
|
||||
const nextPasswordHash = await argon2.hash(newPassword, HASH_OPTIONS);
|
||||
await tx.user.update({
|
||||
where: { id: uid },
|
||||
data: {
|
||||
passwordHash: nextPasswordHash,
|
||||
passwordChangedAt: now,
|
||||
},
|
||||
});
|
||||
await tx.emailToken.deleteMany({
|
||||
where: {
|
||||
userId: uid,
|
||||
type: "password_reset",
|
||||
id: { not: candidate.id },
|
||||
},
|
||||
});
|
||||
return candidate.userId;
|
||||
});
|
||||
|
||||
if (!consumed) {
|
||||
logSecurityEvent(req, "auth.password_reset.confirm", "failure", {
|
||||
reason: "invalid_or_expired_token",
|
||||
userId: uid,
|
||||
});
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ ok: false, code: "INVALID_OR_EXPIRED_RESET_LINK", message: "Invalid or expired reset link" });
|
||||
}
|
||||
|
||||
logSecurityEvent(req, "auth.password_reset.confirm", "success", { userId: consumed });
|
||||
return { ok: true };
|
||||
}
|
||||
);
|
||||
|
||||
app.post("/account/delete-request", codeIssueRateLimit, async (req, reply) => {
|
||||
const Body = z.object({
|
||||
password: z.string().min(1),
|
||||
@@ -1317,7 +1560,7 @@ app.patch("/me/password", async (req, reply) => {
|
||||
// Update password
|
||||
await app.prisma.user.update({
|
||||
where: { id: req.userId },
|
||||
data: { passwordHash: newHash },
|
||||
data: { passwordHash: newHash, passwordChangedAt: new Date() },
|
||||
});
|
||||
|
||||
return { ok: true, message: "Password updated successfully" };
|
||||
|
||||
Reference in New Issue
Block a user