import { Router, Request, Response } from "express"; import rateLimit from "express-rate-limit"; import { z } from "zod"; import { loginUser, registerUser, requestPasswordReset, resendVerification, resetPassword, rotateCsrf, revokeSession, verifyEmailToken, } from "../authService"; import { clearSessionCookie, setSessionCookie } from "../authMiddleware"; import { isValidPassword, normalizeEmail } from "../security"; import { verifyTurnstileToken } from "../turnstile"; const router = Router(); const genericOk = { ok: true }; const genericAuthError = { error: "invalid_credentials", details: ["Invalid email or password"] }; const registerLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 10, standardHeaders: true, legacyHeaders: false }); const loginLimiter = rateLimit({ windowMs: 60 * 1000, limit: 20, standardHeaders: true, legacyHeaders: false }); const loginEmailLimiter = rateLimit({ windowMs: 60 * 1000, limit: 5, standardHeaders: true, legacyHeaders: false, keyGenerator: (req) => `login:${normalizeEmail(String(req.body?.email ?? ""))}`, }); const emailIpLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 10, standardHeaders: true, legacyHeaders: false }); const emailAddressLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 3, standardHeaders: true, legacyHeaders: false, keyGenerator: (req) => `email:${normalizeEmail(String(req.body?.email ?? ""))}`, }); const registerSchema = z.object({ email: z.string().email(), password: z.string().refine(isValidPassword, "Password must be at least 15 characters"), turnstileToken: z.string().min(1), }); const loginSchema = z.object({ email: z.string().email(), password: z.string().min(1), }); const tokenSchema = z.object({ token: z.string().min(16), }); const emailSchema = z.object({ email: z.string().email(), }); const resetSchema = z.object({ token: z.string().min(16), password: z.string().refine(isValidPassword, "Password must be at least 15 characters"), }); function validationError(res: Response): void { res.status(400).json({ error: "validation_error", details: ["Invalid request body"] }); } router.post("/auth/register", registerLimiter, async (req: Request, res: Response, next) => { const parsed = registerSchema.safeParse(req.body); if (!parsed.success) { validationError(res); return; } try { const captchaOk = await verifyTurnstileToken(parsed.data.turnstileToken, req.ip); if (!captchaOk) { res.status(400).json({ error: "captcha_failed", details: ["Captcha verification failed"] }); return; } await registerUser(parsed.data.email, parsed.data.password); res.status(202).json(genericOk); } catch (error) { next(error); } }); router.post("/auth/login", loginLimiter, loginEmailLimiter, async (req: Request, res: Response, next) => { const parsed = loginSchema.safeParse(req.body); if (!parsed.success) { res.status(401).json(genericAuthError); return; } try { const result = await loginUser(parsed.data.email, parsed.data.password); if (!result.ok) { res.status(401).json(genericAuthError); return; } setSessionCookie(res, result.sessionToken); res.json({ user: result.user, csrfToken: result.csrfToken }); } catch (error) { next(error); } }); router.post("/auth/logout", async (req: Request, res: Response, next) => { try { if (req.auth) { await revokeSession(req.auth.sessionToken); } clearSessionCookie(res); res.status(204).end(); } catch (error) { next(error); } }); router.get("/auth/me", async (req: Request, res: Response, next) => { if (!req.auth) { res.status(401).json({ error: "unauthorized", details: ["Authentication required"] }); return; } try { const csrfToken = await rotateCsrf(req.auth.sessionToken); res.json({ user: req.auth.user, csrfToken }); } catch (error) { next(error); } }); router.post("/auth/verify-email", emailIpLimiter, async (req: Request, res: Response, next) => { const parsed = tokenSchema.safeParse(req.body); if (!parsed.success) { validationError(res); return; } try { const ok = await verifyEmailToken(parsed.data.token); if (!ok) { res.status(410).json({ error: "invalid_token", details: ["Token is invalid or expired"] }); return; } res.json(genericOk); } catch (error) { next(error); } }); router.post("/auth/resend-verification", emailIpLimiter, emailAddressLimiter, async (req: Request, res: Response, next) => { const parsed = emailSchema.safeParse(req.body); if (!parsed.success) { validationError(res); return; } try { await resendVerification(parsed.data.email); res.status(202).json(genericOk); } catch (error) { next(error); } }); router.post("/auth/forgot-password", emailIpLimiter, emailAddressLimiter, async (req: Request, res: Response, next) => { const parsed = emailSchema.safeParse(req.body); if (!parsed.success) { validationError(res); return; } try { await requestPasswordReset(parsed.data.email); res.status(202).json(genericOk); } catch (error) { next(error); } }); router.post("/auth/reset-password", emailIpLimiter, async (req: Request, res: Response, next) => { const parsed = resetSchema.safeParse(req.body); if (!parsed.success) { validationError(res); return; } try { const ok = await resetPassword(parsed.data.token, parsed.data.password); if (!ok) { res.status(410).json({ error: "invalid_token", details: ["Token is invalid or expired"] }); return; } clearSessionCookie(res); res.json(genericOk); } catch (error) { next(error); } }); export default router;