feat: email verification + delete confirmation + smtp/cors/prod hardening
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user