feat: email verification + delete confirmation + smtp/cors/prod hardening

This commit is contained in:
2026-02-09 14:46:49 -06:00
parent 27cc7d159b
commit 9856317641
22 changed files with 896 additions and 58 deletions

View File

@@ -4,7 +4,8 @@ import rateLimit from "@fastify/rate-limit";
import fastifyCookie from "@fastify/cookie";
import fastifyJwt from "@fastify/jwt";
import argon2 from "argon2";
import { randomUUID } from "node:crypto";
import { randomUUID, createHash, randomInt } from "node:crypto";
import nodemailer from "nodemailer";
import { env } from "./env.js";
import { PrismaClient, Prisma } from "@prisma/client";
import { z } from "zod";
@@ -14,7 +15,14 @@ import { rolloverFixedPlans } from "./jobs/rollover.js";
export type AppConfig = typeof env;
const openPaths = new Set(["/health", "/health/db", "/auth/login", "/auth/register"]);
const openPaths = new Set([
"/health",
"/health/db",
"/auth/login",
"/auth/register",
"/auth/verify",
"/auth/verify/resend",
]);
const mutationRateLimit = {
config: {
rateLimit: {
@@ -92,6 +100,138 @@ const ensureCsrfCookie = (reply: any, existing?: string) => {
return token;
};
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 normalizeOrigin = (origin: string) => origin.replace(/\/$/, "");
const toPlainEmailAddress = (value?: string | null) => {
if (!value) return undefined;
const trimmed = value.trim();
const match = trimmed.match(/<([^>]+)>/);
return (match?.[1] ?? trimmed).trim();
};
const mailer = config.SMTP_HOST
? nodemailer.createTransport({
host: config.SMTP_HOST,
port: Number(config.SMTP_PORT ?? 587),
secure: false,
requireTLS: true,
tls: {
rejectUnauthorized: config.SMTP_TLS_REJECT_UNAUTHORIZED,
},
auth: config.SMTP_USER
? {
user: config.SMTP_USER,
pass: config.SMTP_PASS,
}
: undefined,
logger: true,
debug: true,
})
: null;
function buildEmailTextBody(content: string) {
return `${content}\n\n---\nSkyMoney Budget\nhttps://skymoneybudget.com\nNeed help? support@skymoneybudget.com`;
}
function buildEmailHtmlBody(contentHtml: string) {
return `${contentHtml}
<hr style="margin:20px 0;border:none;border-top:1px solid #e5e7eb;"/>
<p style="margin:0;color:#6b7280;font-size:12px;line-height:1.5;">
SkyMoney Budget<br/>
<a href="https://skymoneybudget.com">skymoneybudget.com</a><br/>
Need help? <a href="mailto:support@skymoneybudget.com">support@skymoneybudget.com</a>
</p>`;
}
async function sendEmail({
to,
subject,
text,
html,
}: {
to: string;
subject: string;
text: string;
html?: string;
}) {
const finalText = buildEmailTextBody(text);
const finalHtml = buildEmailHtmlBody(html ?? `<p>${text}</p>`);
if (!mailer) {
// Dev fallback: log the email for manual copy
app.log.info({ to, subject }, "[email] mailer disabled; logged email content");
console.log("[email]", { to, subject, text: finalText });
return;
}
const bounceFrom =
toPlainEmailAddress(config.EMAIL_BOUNCE_FROM) ??
toPlainEmailAddress(config.EMAIL_FROM) ??
"bounces@skymoneybudget.com";
try {
const info = await mailer.sendMail({
from: config.EMAIL_FROM,
to,
subject,
text: finalText,
html: finalHtml,
replyTo: config.EMAIL_REPLY_TO || undefined,
envelope: {
from: bounceFrom,
to: [to],
},
headers: {
"Auto-Submitted": "auto-generated",
"X-Auto-Response-Suppress": "All",
},
});
app.log.info({ to, subject, messageId: info.messageId }, "[email] sent");
} catch (err) {
app.log.error({ err, to, subject }, "[email] failed");
throw err;
}
}
function generateCode(length = 6) {
const min = 10 ** (length - 1);
const max = 10 ** length - 1;
return String(randomInt(min, max + 1));
}
function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
async function issueEmailToken(
userId: string,
type: "signup" | "delete",
ttlMs: number
) {
const code = generateCode(6);
const tokenHash = hashToken(code);
const expiresAt = new Date(Date.now() + ttlMs);
await app.prisma.emailToken.create({
data: {
userId,
type,
tokenHash,
expiresAt,
},
});
return { code, expiresAt };
}
async function clearEmailTokens(userId: string, type?: "signup" | "delete") {
await app.prisma.emailToken.deleteMany({
where: type ? { userId, type } : { userId },
});
}
/**
* Calculate the next due date based on frequency for rollover
*/
@@ -365,19 +505,24 @@ async function seedDefaultBudget(prisma: PrismaClient, userId: string) {
});
}
const configuredOrigins = (config.CORS_ORIGINS || config.CORS_ORIGIN || "")
.split(",")
.map((s) => normalizeOrigin(s.trim()))
.filter(Boolean);
await app.register(cors, {
origin: (() => {
if (!config.CORS_ORIGIN) return true;
const allow = config.CORS_ORIGIN.split(",")
.map((s) => s.trim())
.filter(Boolean);
return (origin, cb) => {
if (!origin) return cb(null, true);
cb(null, allow.includes(origin));
};
})(),
credentials: true,
});
origin: (() => {
// Keep local/dev friction-free.
if (config.NODE_ENV !== "production") return true;
if (configuredOrigins.length === 0) return true;
return (origin, cb) => {
if (!origin) return cb(null, true);
const normalized = normalizeOrigin(origin);
cb(null, configuredOrigins.includes(normalized));
};
})(),
credentials: true,
});
await app.register(rateLimit, {
max: config.RATE_LIMIT_MAX,
@@ -398,6 +543,17 @@ async function seedDefaultBudget(prisma: PrismaClient, userId: string) {
},
});
app.addHook("onReady", async () => {
if (!mailer) return;
try {
await mailer.verify();
app.log.info("[email] SMTP verified");
} catch (err) {
app.log.error({ err }, "[email] SMTP verify failed");
if (config.NODE_ENV === "production") throw err;
}
});
{
const prisma = new PrismaClient();
app.decorate("prisma", prisma);
@@ -456,7 +612,7 @@ app.decorate("ensureUser", async (userId: string) => {
if (method === "GET" || method === "HEAD" || method === "OPTIONS") {
return;
}
if (path === "/auth/login" || path === "/auth/register") {
if (path === "/auth/login" || path === "/auth/register" || path === "/auth/verify" || path === "/auth/verify/resend") {
return;
}
const cookieToken = (req.cookies as any)?.[CSRF_COOKIE];
@@ -471,6 +627,11 @@ const AuthBody = z.object({
password: z.string().min(8),
});
const VerifyBody = z.object({
email: z.string().email(),
code: z.string().min(4),
});
const AllocationOverrideSchema = z.object({
type: z.enum(["fixed", "variable"]),
id: z.string().min(1),
@@ -507,23 +668,22 @@ app.post(
email: normalizedEmail,
passwordHash: hash,
displayName: email.split("@")[0] || null,
emailVerified: false,
},
});
if (config.SEED_DEFAULT_BUDGET) {
await seedDefaultBudget(app.prisma, 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 } : {}),
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>`,
});
ensureCsrfCookie(reply);
return { ok: true };
return { ok: true, needsVerification: true };
});
app.post(
@@ -546,6 +706,9 @@ app.post(
if (!user?.passwordHash) return reply.code(401).send({ ok: false, message: "Invalid credentials" });
const valid = await argon2.verify(user.passwordHash, password);
if (!valid) return reply.code(401).send({ ok: false, message: "Invalid credentials" });
if (!user.emailVerified) {
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
@@ -572,6 +735,142 @@ app.post("/auth/logout", async (_req, reply) => {
return { ok: true };
});
app.post("/auth/verify", async (req, reply) => {
const parsed = VerifyBody.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ ok: false, message: "Invalid payload" });
}
const normalizedEmail = parsed.data.email.toLowerCase();
const user = await app.prisma.user.findUnique({ where: { email: normalizedEmail } });
if (!user) {
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) {
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" } });
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);
return { ok: true };
});
app.post("/auth/verify/resend", 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 = parsed.data.email.toLowerCase();
const user = await app.prisma.user.findUnique({ where: { email: normalizedEmail } });
if (!user) return reply.code(200).send({ ok: true });
if (user.emailVerified) return { ok: true, alreadyVerified: true };
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>`,
});
return { ok: true };
});
app.post("/account/delete-request", 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) {
return reply.code(401).send({ ok: false, message: "Invalid credentials" });
}
const valid = await argon2.verify(user.passwordHash, parsed.data.password);
if (!valid) {
return reply.code(401).send({ ok: false, message: "Invalid credentials" });
}
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>`,
});
return { ok: true };
});
app.post("/account/confirm-delete", 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 = parsed.data.email.toLowerCase();
const user = await app.prisma.user.findUnique({ where: { email: normalizedEmail } });
if (!user) {
return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" });
}
if (!user.passwordHash) {
return reply.code(401).send({ ok: false, message: "Invalid credentials" });
}
const passwordOk = await argon2.verify(user.passwordHash, parsed.data.password);
if (!passwordOk) {
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) {
return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" });
}
if (token.expiresAt < new Date()) {
await clearEmailTokens(user.id, "delete");
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 } : {}),
});
return { ok: true };
});
app.post("/auth/refresh", async (req, reply) => {
// Generate a new token to extend the session
const userId = req.userId;
@@ -595,16 +894,55 @@ app.get("/auth/session", async (req, reply) => {
}
const user = await app.prisma.user.findUnique({
where: { id: req.userId },
select: { email: true, displayName: true },
select: {
email: true,
displayName: true,
emailVerified: true,
seenUpdateVersion: true,
},
});
const noticeVersion = config.UPDATE_NOTICE_VERSION;
const shouldShowNotice =
noticeVersion > 0 &&
!!user &&
user.emailVerified &&
user.seenUpdateVersion < noticeVersion;
return {
ok: true,
userId: req.userId,
email: user?.email ?? null,
displayName: user?.displayName ?? null,
emailVerified: user?.emailVerified ?? false,
updateNotice: shouldShowNotice
? {
version: noticeVersion,
title: config.UPDATE_NOTICE_TITLE,
body: config.UPDATE_NOTICE_BODY,
}
: null,
};
});
app.post("/app/update-notice/ack", mutationRateLimit, async (req, reply) => {
const Body = z.object({
version: z.coerce.number().int().nonnegative().optional(),
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ ok: false, message: "Invalid payload" });
}
const targetVersion = parsed.data.version ?? config.UPDATE_NOTICE_VERSION;
await app.prisma.user.updateMany({
where: {
id: req.userId,
seenUpdateVersion: { lt: targetVersion },
},
data: { seenUpdateVersion: targetVersion },
});
return { ok: true };
});
app.patch("/me", async (req, reply) => {
const Body = z.object({
displayName: z.string().trim().min(1).max(120),