import { createHmac, timingSafeEqual } from 'node:crypto'; import type { FastifyInstance, FastifyReply } from 'fastify'; import { z } from 'zod'; import { prisma } from '../db.js'; import { env } from '../env.js'; import { badRequest, unauthorized } from '../errors.js'; import { hashPassword, validatePasswordStrength, verifyPassword } from '../auth/password.js'; import { CSRF_COOKIE, SESSION_COOKIE, createSession, csrfTokenFor, destroyAllSessionsFor, destroyOtherSessionsFor, destroySession, } from '../auth/session.js'; import { requireAuth } from '../auth/guard.js'; import { generateSecret, otpauthUrl, qrDataUrl, verifyTotp } from '../auth/totp.js'; import { recordAudit } from '../services/audit.js'; import { contextOf, parseWith } from './helpers.js'; const CHALLENGE_TTL_MS = 5 * 60 * 1000; // Zwischenschritt zwischen Passwort und TOTP-Code. Signiert statt gespeichert, // damit kein halb angemeldeter Zustand in der Datenbank liegt. function signChallenge(userId: number): string { const expiresAt = Date.now() + CHALLENGE_TTL_MS; const payload = `${userId}.${expiresAt}`; const signature = createHmac('sha256', env.SESSION_SECRET).update(payload).digest('base64url'); return `${payload}.${signature}`; } function verifyChallenge(token: string): number | null { const parts = token.split('.'); if (parts.length !== 3) return null; const [rawUserId, rawExpiry, signature] = parts as [string, string, string]; const expected = createHmac('sha256', env.SESSION_SECRET) .update(`${rawUserId}.${rawExpiry}`) .digest('base64url'); const a = Buffer.from(expected); const b = Buffer.from(signature); if (a.length !== b.length || !timingSafeEqual(a, b)) return null; const expiresAt = Number(rawExpiry); if (!Number.isFinite(expiresAt) || expiresAt < Date.now()) return null; const userId = Number(rawUserId); return Number.isInteger(userId) ? userId : null; } const loginSchema = z.object({ username: z.string().trim().min(1).max(64), password: z.string().min(1).max(256), }); const totpSchema = z.object({ challenge: z.string().min(1), code: z.string().trim().min(6).max(10), }); export async function authRoutes(app: FastifyInstance): Promise { const loginLimit = { config: { rateLimit: { max: 10, timeWindow: '5 minutes', }, }, }; function issueSession(reply: FastifyReply, token: string, expiresAt: Date): void { reply.setCookie(SESSION_COOKIE, token, { path: '/', httpOnly: true, secure: env.cookieSecure, sameSite: 'lax', expires: expiresAt, }); // Muss fuer das Double-Submit-Verfahren lesbar sein. reply.setCookie(CSRF_COOKIE, csrfTokenFor(token), { path: '/', httpOnly: false, secure: env.cookieSecure, sameSite: 'lax', expires: expiresAt, }); } app.post('/api/auth/login', loginLimit, async (request, reply) => { const body = parseWith(loginSchema, request.body); const user = await prisma.user.findUnique({ where: { username: body.username } }); // Auch ohne Treffer einmal hashen, damit die Antwortzeit nichts verraet. const digest = user?.passwordHash ?? '$argon2id$v=19$m=19456,t=2,p=1$aaaaaaaaaaaaaaaa$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; const passwordOk = await verifyPassword(digest, body.password); if (!user || !passwordOk || !user.isActive) { // Der eingegebene Name wird nur protokolliert, wenn es das Konto wirklich // gibt. Sonst landet ein versehentlich ins Namensfeld getipptes Passwort // im Klartext im Audit-Log. await recordAudit( prisma, { actorId: null, actorName: user ? user.username : null, ip: request.ip }, { action: 'auth.login_failed', targetType: 'user', targetId: user?.id ?? null, details: user ? { reason: user.isActive ? 'wrong_password' : 'account_disabled' } : { reason: 'unknown_account' }, }, ).catch(() => undefined); throw unauthorized('Benutzername oder Passwort ist falsch'); } if (user.totpEnabled && user.totpSecret) { return { totpRequired: true, challenge: signChallenge(user.id) }; } const { token, expiresAt } = await createSession( user.id, request.ip, request.headers['user-agent'], ); issueSession(reply, token, expiresAt); await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } }); await recordAudit( { auditLog: prisma.auditLog }, { actorId: user.id, actorName: user.username, ip: request.ip }, { action: 'auth.login', targetType: 'user', targetId: user.id }, ); return { totpRequired: false, user: { id: user.id, username: user.username, role: user.role, mustChangePassword: user.mustChangePassword, totpEnabled: user.totpEnabled, }, }; }); app.post('/api/auth/login/totp', loginLimit, async (request, reply) => { const body = parseWith(totpSchema, request.body); const userId = verifyChallenge(body.challenge); if (userId === null) { throw unauthorized('Die Anmeldung ist abgelaufen, bitte erneut starten'); } const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user || !user.isActive || !user.totpSecret) throw unauthorized(); if (!(await verifyTotp(user.totpSecret, body.code))) { await recordAudit(prisma, { actorId: user.id, actorName: user.username, ip: request.ip }, { action: 'auth.totp_failed', targetType: 'user', targetId: user.id, }).catch(() => undefined); throw unauthorized('Der Code stimmt nicht'); } const { token, expiresAt } = await createSession( user.id, request.ip, request.headers['user-agent'], ); issueSession(reply, token, expiresAt); await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } }); await recordAudit( { auditLog: prisma.auditLog }, { actorId: user.id, actorName: user.username, ip: request.ip }, { action: 'auth.login', targetType: 'user', targetId: user.id }, ); return { user: { id: user.id, username: user.username, role: user.role, mustChangePassword: user.mustChangePassword, totpEnabled: user.totpEnabled, }, }; }); app.post('/api/auth/logout', async (request, reply) => { if (request.sessionToken) { await destroySession(request.sessionToken); } reply.clearCookie(SESSION_COOKIE, { path: '/' }); reply.clearCookie(CSRF_COOKIE, { path: '/' }); return { ok: true }; }); app.get('/api/auth/me', async (request) => { if (!request.user) return { user: null }; return { user: request.user }; }); // --- Eigener Account --- app.post( '/api/account/password', { preHandler: requireAuth }, async (request) => { const body = parseWith( z.object({ currentPassword: z.string().min(1), newPassword: z.string().min(1), }), request.body, ); const user = await prisma.user.findUnique({ where: { id: request.user!.id } }); if (!user) throw unauthorized(); if (!(await verifyPassword(user.passwordHash, body.currentPassword))) { throw badRequest('WRONG_PASSWORD', 'Das aktuelle Passwort ist falsch'); } const weak = validatePasswordStrength(body.newPassword); if (weak) throw badRequest('WEAK_PASSWORD', weak); await prisma.user.update({ where: { id: user.id }, data: { passwordHash: await hashPassword(body.newPassword), mustChangePassword: false, }, }); // Andere Sitzungen beenden, die eigene bleibt bestehen. if (request.sessionToken) { await destroyOtherSessionsFor(user.id, request.sessionToken); } await recordAudit(prisma, contextOf(request), { action: 'account.password_changed', targetType: 'user', targetId: user.id, }); return { ok: true }; }, ); app.post('/api/account/totp/setup', { preHandler: requireAuth }, async (request) => { const secret = generateSecret(); // Erst nach bestaetigtem Code aktivieren, sonst sperrt sich der Nutzer aus. await prisma.user.update({ where: { id: request.user!.id }, data: { totpSecret: secret, totpEnabled: false }, }); const url = otpauthUrl(request.user!.username, secret); return { secret, otpauth: url, qr: await qrDataUrl(url) }; }); app.post('/api/account/totp/enable', { preHandler: requireAuth }, async (request) => { const body = parseWith(z.object({ code: z.string().trim().min(6).max(10) }), request.body); const user = await prisma.user.findUnique({ where: { id: request.user!.id } }); if (!user?.totpSecret) { throw badRequest('NO_SECRET', 'Bitte zuerst die Einrichtung starten'); } if (!(await verifyTotp(user.totpSecret, body.code))) { throw badRequest('WRONG_CODE', 'Der Code stimmt nicht'); } await prisma.user.update({ where: { id: user.id }, data: { totpEnabled: true } }); await recordAudit(prisma, contextOf(request), { action: 'account.totp_enabled', targetType: 'user', targetId: user.id, }); return { ok: true }; }); app.post('/api/account/totp/disable', { preHandler: requireAuth }, async (request) => { const body = parseWith(z.object({ password: z.string().min(1) }), request.body); const user = await prisma.user.findUnique({ where: { id: request.user!.id } }); if (!user) throw unauthorized(); if (!(await verifyPassword(user.passwordHash, body.password))) { throw badRequest('WRONG_PASSWORD', 'Das Passwort ist falsch'); } await prisma.user.update({ where: { id: user.id }, data: { totpEnabled: false, totpSecret: null }, }); await recordAudit(prisma, contextOf(request), { action: 'account.totp_disabled', targetType: 'user', targetId: user.id, }); return { ok: true }; }); app.post('/api/account/sessions/revoke', { preHandler: requireAuth }, async (request, reply) => { await destroyAllSessionsFor(request.user!.id); reply.clearCookie(SESSION_COOKIE, { path: '/' }); reply.clearCookie(CSRF_COOKIE, { path: '/' }); return { ok: true }; }); }