/** * @file Verarbeitet Registrierung, Anmeldung, Abmeldung und die Abfrage der aktuellen Sitzung. */ import express from "express"; import {asyncHandler} from "../middleware/asyncHandler.js"; import bcrypt from "bcrypt"; import {validateBody} from "../middleware/validate.js"; import {newPasswordError} from "../services/passwordPolicy.js"; import {normalizeUsername, usernameError} from "../services/usernamePolicy.js"; const credentialsValidator = (body) => { const username = normalizeUsername(body?.username); const password = String(body?.password || ""); const inviteCode = String(body?.inviteCode || "").trim(); const usernameValidationError = usernameError(username); if (usernameValidationError) return {error: usernameValidationError}; if (!password) return {error: "Password is required"}; if (password.length < 6) return {error: "Password must be at least 6 characters"}; return {value: {username, password, inviteCode}}; }; const DUMMY_PASSWORD_HASH = "$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"; const regenerateSession = (req, fields) => new Promise((resolve, reject) => { req.session.regenerate(error => { if (error) return reject(error); Object.assign(req.session, fields); resolve(); }); }); export function createAuthRouter({repositories, authRateLimiters, config}) { const router = express.Router(); router.post("/register", ...authRateLimiters, validateBody(credentialsValidator), asyncHandler(async (req, res) => { const {username, password, inviteCode} = req.validatedBody; const passwordError = newPasswordError(password); if (passwordError) return res.status(400).json({error: passwordError, code: "PASSWORD_TOO_LONG"}); if (config.registrationInviteCode && inviteCode !== config.registrationInviteCode) { return res.status(403).json({error: "Valid invite code is required"}); } if (repositories.getUserCaseInsensitive(username)) { return res.status(409).json({error: "User already exists", code: "USERNAME_TAKEN"}); } const hashed = await bcrypt.hash(password, 10); try { repositories.insertUser(username, hashed); } catch (error) { // The precheck can race while bcrypt yields; the database is authoritative. if (["SQLITE_CONSTRAINT_PRIMARYKEY", "SQLITE_CONSTRAINT_UNIQUE"].includes(error.code)) { return res.status(409).json({error: "User already exists", code: "USERNAME_TAKEN"}); } throw error; } await regenerateSession(req, {user: username, sessionVersion: 0}); return res.json({message: "Registration successful"}); })); router.post("/login", ...authRateLimiters, validateBody(credentialsValidator), asyncHandler(async (req, res) => { const {username, password} = req.validatedBody; const user = repositories.getUserCaseInsensitive(username); const valid = await bcrypt.compare(password, user?.password || DUMMY_PASSWORD_HASH); if (!user || !valid) { // Keep the message generic and still perform bcrypt work for unknown users. return res.status(user ? 403 : 400).json({error: "Invalid username or password"}); } await regenerateSession(req, {user: user.username, sessionVersion: Number(user.session_version || 0)}); return res.json({message: "Login successful"}); })); router.post("/logout", (req, res, next) => { req.session.destroy(error => error ? next(error) : res.json({message: "Logged out"})); }); router.get("/session", (req, res) => { return res.json({ user: req.session.user || null, inviteOnlyRegistration: Boolean(config.registrationInviteCode) }); }); return router; }