import type { NextFunction, Request, RequestHandler, Response } from "express"; import { SESSION_COOKIE, type AuthConfig } from "./auth-config.js"; import { readCookie } from "./cookie.js"; import type { SessionStore } from "./sessions.js"; import type { UserStore } from "../../domain/ports/user.store.js"; import type { UserRole } from "../../shared/constants/user-roles.js"; import { WRITE_REQUEST_HEADER_NAME, WRITE_REQUEST_HEADER_VALUE, } from "../../shared/constants/request-security.js"; export interface AuthUser { id: string; username: string; name: string; role: UserRole; mustChangePassword: boolean; } declare global { namespace Express { interface Request { authUser?: AuthUser; } } } export function sessionIdFromRequest(req: { headers: { cookie?: string | undefined }; }): string | undefined { return readCookie(req.headers.cookie, SESSION_COOKIE); } export async function identityFromRequest( req: { headers: { cookie?: string | undefined } }, sessions: SessionStore, users: Pick, slideExpiry = true ): Promise { const session = sessions.get(sessionIdFromRequest(req), slideExpiry); if (!session) return undefined; const user = await users.findById(session.userId); if (!user || !user.active) return undefined; return { id: user.id, username: user.username, name: user.name, role: user.role as UserRole, mustChangePassword: user.mustChangePassword, }; } /** * Session identity, or on a development instance with AUTH_DEV_AUTOLOGIN the * configured active user. The auto-login user skips the forced password * change because nobody signs in interactively. */ export async function resolveRequestIdentity( req: { headers: { cookie?: string | undefined } }, authConfig: Pick, sessions: SessionStore, users: Pick, slideExpiry = true ): Promise { const identity = await identityFromRequest(req, sessions, users, slideExpiry); if (identity || !authConfig.devAutoLogin) return identity; const user = await users.findByUsername(authConfig.devAutoLogin); if (!user || !user.active) return undefined; return { id: user.id, username: user.username, name: user.name, role: user.role as UserRole, mustChangePassword: false, }; } export function cookieOptions(authConfig: AuthConfig, maxAgeMs: number) { return { httpOnly: true, sameSite: "lax" as const, secure: authConfig.cookieSecure, path: "/", maxAge: maxAgeMs, }; } /** Sets the session cookie with the same remaining lifetime the server uses. */ export function setSessionCookie( res: Response, authConfig: AuthConfig, sessions: SessionStore, sessionId: string ): void { const session = sessions.get(sessionId, false); if (!session) return; res.cookie(SESSION_COOKIE, sessionId, cookieOptions(authConfig, sessions.remainingMs(session))); } const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); /** * CSRF defense for cookie-authenticated writes. * * SameSite=Lax alone is not enough here: every other service on the same * host (other ports of the same IP) counts as "same-site" and may receive the * cookie. Fetch Metadata is also not guaranteed on plain HTTP in the LAN. * Every write therefore needs an application header that HTML forms cannot * set; a cross-origin fetch would require a CORS preflight, which the API does * not permit. Sec-Fetch-Site remains an additional browser-side check. */ export function requireSameOriginForWrites(): RequestHandler { return (req: Request, res: Response, next: NextFunction) => { if (SAFE_METHODS.has(req.method)) { next(); return; } if (req.get(WRITE_REQUEST_HEADER_NAME) !== WRITE_REQUEST_HEADER_VALUE) { res.status(403).json({ error: "Schreibzugriff ohne gültigen Anwendungsnachweis abgelehnt." }); return; } const fetchSite = req.headers["sec-fetch-site"]; if (fetchSite === undefined || fetchSite === "same-origin" || fetchSite === "none") { next(); return; } res.status(403).json({ error: "Anfrage von fremder Herkunft abgelehnt." }); }; } /** Protects /api. Only login, status and logout stay reachable without a session. */ export function createAuthGuard( authConfig: AuthConfig, sessions: SessionStore, users: Pick, openPaths: readonly string[] = ["/auth/login", "/auth/status", "/auth/logout"] ): RequestHandler { return (req: Request, res: Response, next: NextFunction) => { if (openPaths.includes(req.path)) { next(); return; } resolveRequestIdentity(req, authConfig, sessions, users) .then((identity) => { if (!identity) { res.status(401).json({ error: "Nicht angemeldet." }); return; } req.authUser = identity; const sessionId = sessionIdFromRequest(req); if (sessionId) setSessionCookie(res, authConfig, sessions, sessionId); next(); }) .catch(next); }; } /** Blocks everything except logout and the password change while a start password is still in use. */ export function requirePasswordChanged(): RequestHandler { const allowed = new Set(["/auth/me", "/auth/change-password", "/auth/logout"]); return (req: Request, res: Response, next: NextFunction) => { if (!req.authUser?.mustChangePassword || allowed.has(req.path)) { next(); return; } res.status(403).json({ error: "Bitte zuerst ein eigenes Passwort setzen.", mustChangePassword: true, }); }; } export function requireRole(role: UserRole): RequestHandler { return (req: Request, res: Response, next: NextFunction) => { if (req.authUser?.role !== role) { res.status(403).json({ error: "Keine Berechtigung für diese Aktion." }); return; } next(); }; }