Files
runners-calendar/backend/src/routes/auth.ts
2026-05-21 00:01:35 +03:00

198 lines
5.6 KiB
TypeScript

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;