import crypto from "node:crypto"; import { promisify } from "node:util"; import type { NextFunction, Request, Response } from "express"; import { query } from "./db.js"; const scrypt = promisify(crypto.scrypt); const sessionCookieName = "tickettracker_session"; const sessionMaxAgeMs = Number(process.env.SESSION_MAX_AGE_MS ?? 1000 * 60 * 60 * 24 * 14); export type UserRole = "admin" | "user"; export type AuthUser = { id: string; username: string; display_name: string; role: UserRole; active: boolean; }; declare global { namespace Express { interface Request { user?: AuthUser; authSessionHash?: string; } } } export async function hashPassword(password: string) { const salt = crypto.randomBytes(16).toString("base64url"); const key = (await scrypt(password, salt, 64)) as Buffer; return `scrypt$${salt}$${key.toString("base64url")}`; } export async function verifyPassword(password: string, storedHash: string) { const [algorithm, salt, expected] = storedHash.split("$"); if (algorithm !== "scrypt" || !salt || !expected) { return false; } const expectedBuffer = Buffer.from(expected, "base64url"); const actualBuffer = (await scrypt(password, salt, expectedBuffer.length)) as Buffer; return expectedBuffer.length === actualBuffer.length && crypto.timingSafeEqual(expectedBuffer, actualBuffer); } export function hashToken(token: string) { return crypto.createHash("sha256").update(token).digest("hex"); } function readCookie(req: Request, name: string) { const rawCookie = req.headers.cookie; if (!rawCookie) { return null; } const cookies = rawCookie.split(";").map((part) => part.trim()); const prefix = `${name}=`; const match = cookies.find((cookie) => cookie.startsWith(prefix)); if (!match) { return null; } return decodeURIComponent(match.slice(prefix.length)); } function publicUser(user: AuthUser) { return { id: user.id, username: user.username, display_name: user.display_name, role: user.role, active: user.active }; } export async function createAuthSession(res: Response, userId: string) { const token = crypto.randomBytes(32).toString("base64url"); const tokenHash = hashToken(token); const expiresAt = new Date(Date.now() + sessionMaxAgeMs); await query( ` INSERT INTO auth_sessions (token_hash, user_id, expires_at) VALUES ($1, $2, $3); `, [tokenHash, userId, expiresAt.toISOString()] ); res.cookie(sessionCookieName, token, { httpOnly: true, sameSite: "lax", secure: process.env.COOKIE_SECURE === "true", maxAge: sessionMaxAgeMs, path: "/" }); } export async function clearAuthSession(req: Request, res: Response) { if (req.authSessionHash) { await query("DELETE FROM auth_sessions WHERE token_hash = $1;", [req.authSessionHash]); } res.clearCookie(sessionCookieName, { sameSite: "lax", secure: process.env.COOKIE_SECURE === "true", path: "/" }); } export async function requireAuth(req: Request, res: Response, next: NextFunction) { try { const token = readCookie(req, sessionCookieName); if (!token) { res.status(401).json({ error: "Nicht angemeldet" }); return; } const tokenHash = hashToken(token); const result = await query( ` SELECT u.id, u.username, u.display_name, u.role, u.active FROM auth_sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = $1 AND s.expires_at > now() AND u.active = true; `, [tokenHash] ); if (result.rows.length === 0) { res.status(401).json({ error: "Sitzung ist abgelaufen" }); return; } req.user = publicUser(result.rows[0]); req.authSessionHash = tokenHash; next(); } catch (error) { next(error); } } export function requireAdmin(req: Request, res: Response, next: NextFunction) { if (req.user?.role !== "admin") { res.status(403).json({ error: "Adminrechte erforderlich" }); return; } next(); } export function requireUser(req: Request, res: Response, next: NextFunction) { if (req.user?.role !== "user") { res.status(403).json({ error: "Dieser Bereich ist nur für Benutzerkonten vorgesehen" }); return; } next(); } export function currentUser(req: Request) { if (!req.user) { throw Object.assign(new Error("Nicht angemeldet"), { status: 401 }); } return req.user; }