198 lines
5.6 KiB
TypeScript
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;
|