import crypto from 'node:crypto'; import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { and, eq, gte, isNull, sql } from 'drizzle-orm'; import { getDb, users, firms, loginAttempts, passwordResets, emailVerifications, sessions as sessionsTable, } from '@lawdesk/db'; import { hashPassword, verifyPassword } from '../auth/password'; import { SESSION_COOKIE, createSession, destroySession } from '../auth/sessions'; import { ensureSuperadminFlag } from '../auth/superadmin'; import { generateCsrfToken } from '../auth/csrf'; import { sendEmail, passwordResetEmail, passwordChangedEmail, welcomeEmail, verifyEmailEmail, } from '../lib/email'; import { env } from '../env'; const signupBody = z.object({ email: z.string().email().max(254).toLowerCase().trim(), password: z.string().min(10).max(200), fullName: z.string().min(1).max(120).trim(), firmName: z.string().min(1).max(160).trim(), }); const loginBody = z.object({ email: z.string().email().max(254).toLowerCase().trim(), password: z.string().min(1).max(200), }); const MAX_FAILS_PER_15_MIN = 5; // Mints an email-verification token (stored hashed, like password resets) and returns the // clickable URL. The link hits the API directly — the vite dev proxy and the prod same-origin // setup both route /api/* to this server. async function createVerifyUrl(userId: string): Promise { const rawToken = crypto.randomBytes(32).toString('base64url'); const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex'); const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours await getDb().insert(emailVerifications).values({ tokenHash, userId, expiresAt }); return `${env.PUBLIC_URL}/api/auth/verify-email?token=${rawToken}`; } async function recentFailedAttempts(email: string, ip: string | null): Promise { const since = new Date(Date.now() - 15 * 60 * 1000); const db = getDb(); const conditions = [ eq(loginAttempts.email, email), eq(loginAttempts.success, false), gte(loginAttempts.attemptedAt, since), ]; // Key the lockout on (email, ip). A single IP that keeps failing against an account gets // throttled, but an attacker firing bad passwords from another IP can no longer lock the // legitimate owner out of their own account (previously this counted by email alone). if (ip) conditions.push(eq(loginAttempts.ip, ip)); const rows = await db .select({ count: sql`count(*)::int` }) .from(loginAttempts) .where(and(...conditions)); return rows[0]?.count ?? 0; } export async function authRoutes(app: FastifyInstance) { app.post( '/api/auth/signup', { config: { rateLimit: { max: 5, timeWindow: '1 hour' } } }, async (req, reply) => { const body = signupBody.parse(req.body); const db = getDb(); const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, body.email)).limit(1); if (existing.length > 0) { return reply.code(409).send({ error: 'email_taken' }); } const passwordHash = await hashPassword(body.password); const [firm] = await db.insert(firms).values({ name: body.firmName }).returning(); if (!firm) return reply.code(500).send({ error: 'firm_create_failed' }); const [user] = await db .insert(users) .values({ email: body.email, passwordHash, fullName: body.fullName, firmId: firm.id, role: 'owner', }) .returning(); if (!user) return reply.code(500).send({ error: 'user_create_failed' }); const isSuperadmin = await ensureSuperadminFlag( user.id, user.email, user.isSuperadmin, user.emailVerifiedAt, ); const { token, expiresAt } = await createSession({ userId: user.id, ip: req.ip, userAgent: req.headers['user-agent'] ?? null, }); app.setSessionCookie(reply, token, expiresAt); app.setCsrfCookie(reply, generateCsrfToken()); // Fire-and-forget welcome email with a verification link (no blocking) createVerifyUrl(user.id) .catch((err) => { app.log.warn({ err }, 'verify token create failed — sending welcome without link'); return null; }) .then((verifyUrl) => { const welcome = welcomeEmail(user.fullName, verifyUrl); return sendEmail({ to: user.email, ...welcome }); }) .catch((err) => app.log.warn({ err }, 'welcome email failed')); return reply.code(201).send({ user: { id: user.id, email: user.email, fullName: user.fullName, firmId: firm.id, role: user.role, isSuperadmin, isSuspended: user.isSuspended, emailVerified: Boolean(user.emailVerifiedAt), }, }); }); app.post( '/api/auth/login', { config: { rateLimit: { max: 10, timeWindow: '15 minutes' } } }, async (req, reply) => { const body = loginBody.parse(req.body); const db = getDb(); const ip = req.ip ?? null; const fails = await recentFailedAttempts(body.email, ip); if (fails >= MAX_FAILS_PER_15_MIN) { return reply.code(429).send({ error: 'too_many_attempts' }); } const [user] = await db.select().from(users).where(eq(users.email, body.email)).limit(1); const ok = user ? await verifyPassword(user.passwordHash, body.password) : false; await db.insert(loginAttempts).values({ email: body.email, ip, success: ok }); if (!ok || !user) { return reply.code(401).send({ error: 'invalid_credentials' }); } if (user.isSuspended) { return reply.code(403).send({ error: 'account_suspended' }); } const isSuperadmin = await ensureSuperadminFlag( user.id, user.email, user.isSuperadmin, user.emailVerifiedAt, ); await db.update(users).set({ lastSeenAt: new Date() }).where(eq(users.id, user.id)); const { token, expiresAt } = await createSession({ userId: user.id, ip, userAgent: req.headers['user-agent'] ?? null, }); app.setSessionCookie(reply, token, expiresAt); app.setCsrfCookie(reply, generateCsrfToken()); return { user: { id: user.id, email: user.email, fullName: user.fullName, firmId: user.firmId, role: user.role, isSuperadmin, isSuspended: user.isSuspended, emailVerified: Boolean(user.emailVerifiedAt), }, }; }); app.post('/api/auth/logout', async (req, reply) => { const token = req.cookies?.[SESSION_COOKIE]; if (token) await destroySession(token); app.clearSessionCookie(reply); app.clearCsrfCookie(reply); return { ok: true }; }); app.get('/api/auth/me', async (req, reply) => { if (!req.user) return reply.code(401).send({ error: 'unauthorized' }); return { user: req.user }; }); // ─────────────────────────── Password reset ─────────────────────────── // Request a reset link. Always returns ok=true so an attacker can't enumerate emails. app.post( '/api/auth/request-password-reset', { config: { rateLimit: { max: 5, timeWindow: '15 minutes' } } }, async (req) => { const parsed = z.object({ email: z.string().email().max(254).toLowerCase().trim() }).safeParse(req.body); if (!parsed.success) return { ok: true }; const db = getDb(); const [user] = await db.select().from(users).where(eq(users.email, parsed.data.email)).limit(1); if (!user || user.isSuspended) return { ok: true }; const rawToken = crypto.randomBytes(32).toString('base64url'); const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex'); const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour await db.insert(passwordResets).values({ tokenHash, userId: user.id, expiresAt }); const resetUrl = `${env.PUBLIC_URL}/reset-password?token=${rawToken}`; const tpl = passwordResetEmail(user.fullName, resetUrl); sendEmail({ to: user.email, ...tpl }).catch((err) => app.log.warn({ err }, 'password reset email failed'), ); return { ok: true }; }, ); // Apply a new password using the token from the email. app.post( '/api/auth/reset-password', { config: { rateLimit: { max: 10, timeWindow: '15 minutes' } } }, async (req, reply) => { const parsed = z .object({ token: z.string().min(20).max(200), password: z.string().min(10).max(200), }) .safeParse(req.body); if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' }); const tokenHash = crypto.createHash('sha256').update(parsed.data.token).digest('hex'); const db = getDb(); const [reset] = await db .select() .from(passwordResets) .where(and(eq(passwordResets.tokenHash, tokenHash), isNull(passwordResets.consumedAt))) .limit(1); if (!reset) return reply.code(400).send({ error: 'invalid_or_used_token' }); if (reset.expiresAt.getTime() < Date.now()) { return reply.code(400).send({ error: 'token_expired' }); } const [user] = await db.select().from(users).where(eq(users.id, reset.userId)).limit(1); if (!user) return reply.code(400).send({ error: 'user_not_found' }); if (user.isSuspended) return reply.code(403).send({ error: 'account_suspended' }); const passwordHash = await hashPassword(parsed.data.password); await db.transaction(async (tx) => { await tx .update(users) .set({ passwordHash, updatedAt: new Date() }) .where(eq(users.id, user.id)); await tx .update(passwordResets) .set({ consumedAt: new Date() }) .where(eq(passwordResets.tokenHash, tokenHash)); // Revoke all existing sessions for this user — they should re-login with the new password await tx.delete(sessionsTable).where(eq(sessionsTable.userId, user.id)); }); // Security notice — lets the real owner react fast if the reset wasn't theirs. const tpl = passwordChangedEmail(user.fullName); sendEmail({ to: user.email, ...tpl }).catch((err) => app.log.warn({ err }, 'password changed email failed'), ); return { ok: true }; }, ); // ─────────────────────────── Email verification ─────────────────────────── // Landing endpoint for the link in welcome/verification emails. Redirects to the web app // either way; ?verified=1|0 lets the UI show a toast. app.get('/api/auth/verify-email', async (req, reply) => { const parsed = z.object({ token: z.string().min(20).max(200) }).safeParse(req.query); if (!parsed.success) return reply.redirect(`${env.PUBLIC_URL}/login?verified=0`); const tokenHash = crypto.createHash('sha256').update(parsed.data.token).digest('hex'); const db = getDb(); const [row] = await db .select() .from(emailVerifications) .where(and(eq(emailVerifications.tokenHash, tokenHash), isNull(emailVerifications.consumedAt))) .limit(1); if (!row || row.expiresAt.getTime() < Date.now()) { return reply.redirect(`${env.PUBLIC_URL}/login?verified=0`); } await db.transaction(async (tx) => { await tx .update(users) .set({ emailVerifiedAt: new Date(), updatedAt: new Date() }) .where(and(eq(users.id, row.userId), isNull(users.emailVerifiedAt))); await tx .update(emailVerifications) .set({ consumedAt: new Date() }) .where(eq(emailVerifications.tokenHash, tokenHash)); }); return reply.redirect(`${env.PUBLIC_URL}/login?verified=1`); }); // Re-send the verification email for the logged-in user. app.post( '/api/auth/resend-verification', { config: { rateLimit: { max: 3, timeWindow: '15 minutes' } } }, async (req, reply) => { if (!req.user) return reply.code(401).send({ error: 'unauthorized' }); const db = getDb(); const [user] = await db.select().from(users).where(eq(users.id, req.user.id)).limit(1); if (!user) return reply.code(404).send({ error: 'user_not_found' }); if (user.emailVerifiedAt) return { ok: true, alreadyVerified: true }; const verifyUrl = await createVerifyUrl(user.id); const tpl = verifyEmailEmail(user.fullName, verifyUrl); sendEmail({ to: user.email, ...tpl }).catch((err) => app.log.warn({ err }, 'verification email failed'), ); return { ok: true }; }, ); }