import { Router } from 'express'; import { eq, sql } from 'drizzle-orm'; import { registerSchema, loginSchema, verifyEmailSchema, resendVerificationSchema, forgotPasswordSchema, resetPasswordSchema, profileUpdateSchema, changePasswordSchema, acceptInviteSchema, type PublicUser, } from '@flashcard/shared'; import type { Db } from '../db/client.js'; import { users } from '../db/schema.js'; import { hashPassword, verifyPassword } from '../services/auth/passwords.js'; import { createAuthToken, consumeAuthToken, invalidateTokensForUser } from '../services/auth/tokens.js'; import { createAuthSession, invalidateAuthSession, invalidateAllForUser } from '../services/auth/sessions.js'; import { getMailer } from '../services/auth/email.js'; import { verifyEmailTemplate, passwordResetTemplate, changeEmailTemplate, } from '../services/auth/templates.js'; import { SID_COOKIE, CSRF_COOKIE, sidCookieOptions, csrfCookieOptions, clearCookieOptions } from '../lib/cookies.js'; import { ApiError } from '../lib/errors.js'; import { requireAuth } from '../middleware/auth.js'; import { verifyCsrf } from '../middleware/csrf.js'; import { loginLimiter, registerLimiter, forgotPasswordLimiter, tokenLimiter } from '../middleware/rate-limit.js'; import { randomBytes } from 'node:crypto'; const VERIFY_TTL = 24 * 60 * 60; const RESET_TTL = 60 * 60; const CHANGE_EMAIL_TTL = 24 * 60 * 60; function nowSec() { return Math.floor(Date.now() / 1000); } function appUrl() { return process.env.APP_URL ?? 'http://localhost:5173'; } function toPublicUser(r: typeof users.$inferSelect): PublicUser { return { id: r.id, email: r.email, displayName: r.displayName, role: r.role }; } export function authRouter(db: Db): Router { const r = Router(); r.get('/me', (req, res) => { if (!req.user) { res.status(401).json({ error: { code: 'UNAUTHENTICATED', message: 'Not signed in' } }); return; } res.json(req.user); }); r.post('/register', registerLimiter, async (req, res, next) => { try { const input = registerSchema.parse(req.body); const existing = db.select().from(users).where(eq(users.email, input.email)).get(); if (existing) throw new ApiError(409, 'EMAIL_TAKEN', 'Email already in use'); const totalUsers = db.select({ c: sql`count(*)`.as('c') }).from(users).get()?.c ?? 0; const role: 'user' | 'sysadmin' = Number(totalUsers) === 0 ? 'sysadmin' : 'user'; const passwordHash = await hashPassword(input.password); const [user] = db.insert(users).values({ email: input.email, displayName: input.displayName, passwordHash, role, }).returning().all(); const { plaintext } = await createAuthToken(db, user!.id, 'verify_email', VERIFY_TTL); const tpl = verifyEmailTemplate(appUrl(), plaintext, user!.displayName); await getMailer().send(user!.email, tpl); res.status(201).json({ id: user!.id, email: user!.email, displayName: user!.displayName, role: user!.role }); } catch (e) { next(e); } }); r.post('/verify-email', tokenLimiter, async (req, res, next) => { try { const { token } = verifyEmailSchema.parse(req.body); let consumed = await consumeAuthToken(db, token, 'verify_email'); let purpose: 'verify_email' | 'change_email' = 'verify_email'; if (!consumed) { consumed = await consumeAuthToken(db, token, 'change_email'); purpose = 'change_email'; } if (!consumed) throw new ApiError(400, 'INVALID_TOKEN', 'Invalid or expired token'); if (purpose === 'verify_email') { db.update(users).set({ emailVerifiedAt: nowSec(), updatedAt: nowSec() }) .where(eq(users.id, consumed.userId)).run(); } else { const newEmail = consumed.payload; if (!newEmail) throw new ApiError(400, 'INVALID_TOKEN', 'Invalid token payload'); const taken = db.select().from(users).where(eq(users.email, newEmail)).get(); if (taken && taken.id !== consumed.userId) { throw new ApiError(409, 'EMAIL_TAKEN', 'Email already in use'); } db.update(users).set({ email: newEmail, pendingEmail: null, updatedAt: nowSec() }) .where(eq(users.id, consumed.userId)).run(); } res.status(200).json({ ok: true }); } catch (e) { next(e); } }); r.post('/resend-verification', tokenLimiter, async (req, res, next) => { try { const { email } = resendVerificationSchema.parse(req.body); const u = db.select().from(users).where(eq(users.email, email)).get(); if (u && !u.emailVerifiedAt && u.isActive) { await invalidateTokensForUser(db, u.id, 'verify_email'); const { plaintext } = await createAuthToken(db, u.id, 'verify_email', VERIFY_TTL); const tpl = verifyEmailTemplate(appUrl(), plaintext, u.displayName); await getMailer().send(u.email, tpl); } res.status(200).json({ ok: true }); } catch (e) { next(e); } }); r.post('/login', loginLimiter, async (req, res, next) => { try { const input = loginSchema.parse(req.body); const u = db.select().from(users).where(eq(users.email, input.email)).get(); if (!u || !u.passwordHash || !(await verifyPassword(input.password, u.passwordHash))) { throw new ApiError(401, 'INVALID_CREDENTIALS', 'Invalid email or password'); } if (!u.isActive) throw new ApiError(403, 'ACCOUNT_DISABLED', 'Account is disabled'); if (!u.emailVerifiedAt) throw new ApiError(403, 'EMAIL_NOT_VERIFIED', 'Email not verified'); const s = await createAuthSession(db, u.id, { userAgent: req.headers['user-agent'] ?? null, ip: req.ip ?? null, }); const csrf = randomBytes(24).toString('base64url'); res.cookie(SID_COOKIE, s.id, sidCookieOptions(s.expiresAt)); res.cookie(CSRF_COOKIE, csrf, csrfCookieOptions(s.expiresAt)); res.json(toPublicUser(u)); } catch (e) { next(e); } }); r.post('/logout', requireAuth, verifyCsrf, async (req, res, next) => { try { if (req.sessionId) await invalidateAuthSession(db, req.sessionId); res.clearCookie(SID_COOKIE, clearCookieOptions()); res.clearCookie(CSRF_COOKIE, { ...clearCookieOptions(), httpOnly: false }); res.status(204).end(); } catch (e) { next(e); } }); r.post('/forgot-password', forgotPasswordLimiter, async (req, res, next) => { try { const { email } = forgotPasswordSchema.parse(req.body); const u = db.select().from(users).where(eq(users.email, email)).get(); if (u && u.isActive && u.emailVerifiedAt) { await invalidateTokensForUser(db, u.id, 'password_reset'); const { plaintext } = await createAuthToken(db, u.id, 'password_reset', RESET_TTL); const tpl = passwordResetTemplate(appUrl(), plaintext, u.displayName); await getMailer().send(u.email, tpl); } res.status(200).json({ ok: true }); } catch (e) { next(e); } }); r.post('/reset-password', tokenLimiter, async (req, res, next) => { try { const { token, password } = resetPasswordSchema.parse(req.body); const consumed = await consumeAuthToken(db, token, 'password_reset'); if (!consumed) throw new ApiError(400, 'INVALID_TOKEN', 'Invalid or expired token'); const passwordHash = await hashPassword(password); db.update(users).set({ passwordHash, updatedAt: nowSec() }) .where(eq(users.id, consumed.userId)).run(); await invalidateAllForUser(db, consumed.userId); res.status(200).json({ ok: true }); } catch (e) { next(e); } }); r.post('/accept-invite', tokenLimiter, async (req, res, next) => { try { const input = acceptInviteSchema.parse(req.body); const consumed = await consumeAuthToken(db, input.token, 'invite'); if (!consumed) throw new ApiError(400, 'INVALID_TOKEN', 'Invalid or expired token'); const passwordHash = await hashPassword(input.password); const [updated] = db.update(users).set({ passwordHash, displayName: input.displayName, emailVerifiedAt: nowSec(), updatedAt: nowSec(), }).where(eq(users.id, consumed.userId)).returning().all(); if (!updated) throw new ApiError(400, 'INVALID_TOKEN', 'Invalid invitation'); const s = await createAuthSession(db, updated.id, { userAgent: req.headers['user-agent'] ?? null, ip: req.ip ?? null, }); const csrf = randomBytes(24).toString('base64url'); res.cookie(SID_COOKIE, s.id, sidCookieOptions(s.expiresAt)); res.cookie(CSRF_COOKIE, csrf, csrfCookieOptions(s.expiresAt)); res.status(200).json(toPublicUser(updated)); } catch (e) { next(e); } }); r.patch('/profile', requireAuth, verifyCsrf, async (req, res, next) => { try { const input = profileUpdateSchema.parse(req.body); const user = req.user!; const updates: Record = { updatedAt: nowSec() }; if (input.displayName !== undefined) updates.displayName = input.displayName; let sendEmailChange: string | null = null; if (input.email !== undefined && input.email !== user.email) { const taken = db.select().from(users).where(eq(users.email, input.email)).get(); if (taken) throw new ApiError(409, 'EMAIL_TAKEN', 'Email already in use'); updates.pendingEmail = input.email; sendEmailChange = input.email; } const [updated] = db.update(users).set(updates).where(eq(users.id, user.id)).returning().all(); if (sendEmailChange && updated) { const { plaintext } = await createAuthToken(db, updated.id, 'change_email', CHANGE_EMAIL_TTL, sendEmailChange); const tpl = changeEmailTemplate(appUrl(), plaintext, updated.displayName, sendEmailChange); await getMailer().send(sendEmailChange, tpl); } res.json(toPublicUser(updated!)); } catch (e) { next(e); } }); r.post('/change-password', requireAuth, verifyCsrf, async (req, res, next) => { try { const input = changePasswordSchema.parse(req.body); const user = req.user!; const row = db.select().from(users).where(eq(users.id, user.id)).get(); if (!row?.passwordHash || !(await verifyPassword(input.currentPassword, row.passwordHash))) { throw new ApiError(401, 'INVALID_CREDENTIALS', 'Current password is incorrect'); } const passwordHash = await hashPassword(input.newPassword); db.update(users).set({ passwordHash, updatedAt: nowSec() }).where(eq(users.id, user.id)).run(); if (req.sessionId) await invalidateAllForUser(db, user.id, { except: req.sessionId }); res.status(204).end(); } catch (e) { next(e); } }); return r; }