feat: add registration and authentication
This commit is contained in:
197
backend/src/routes/auth.ts
Normal file
197
backend/src/routes/auth.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user