/** * Audit Hooks für Authentifizierungs-Events * * Loggt Login-Erfolge, Login-Fehlschläge und Logouts für Compliance und Security. */ import type { CollectionAfterLoginHook, CollectionAfterLogoutHook, CollectionAfterForgotPasswordHook, Payload, PayloadRequest, } from 'payload' import { logLoginSuccess, logLoginFailed, logPasswordReset, createAuditLog, type ClientInfo, } from '../lib/audit/audit-service' interface AuthUser { id: number email: string isSuperAdmin?: boolean } /** * Hook: Loggt erfolgreiche Logins */ export const auditAfterLogin: CollectionAfterLoginHook = async ({ user, req }) => { const typedUser = user as AuthUser // Fire-and-forget: Audit-Log darf Login nicht blockieren // In Payload 3.76.1+ verursacht ein awaited payload.create() innerhalb // des afterLogin-Hooks einen Deadlock mit PgBouncer (Transaction-Mode) logLoginSuccess(req.payload, typedUser.id, typedUser.email, req).catch((err) => { console.error(`[Audit:Auth] Failed to log login for ${typedUser.email}:`, err) }) console.log(`[Audit:Auth] Login success for ${typedUser.email}`) return user } /** * Hook: Loggt Logouts */ export const auditAfterLogout: CollectionAfterLogoutHook = async ({ req }) => { const user = req.user as AuthUser | undefined if (!user) return await createAuditLog( req.payload, { action: 'logout', entityType: 'users', entityId: user.id, userId: user.id, userEmail: user.email, description: `Benutzer ${user.email} hat sich abgemeldet`, }, req, ) console.log(`[Audit:Auth] Logout for ${user.email}`) } /** * Extrahiert einen Header-Wert aus verschiedenen Header-Formaten * * Unterstützt: * - Express/Node IncomingHttpHeaders (object mit lowercase keys) * - Fetch API Headers (mit .get() Methode) * - Request mit .get() Methode (Express-style) */ function getHeaderValue(req: PayloadRequest, headerName: string): string | undefined { const lowerName = headerName.toLowerCase() // 1. Versuche req.get() (Express Request Methode) const reqWithGet = req as unknown as { get?: (name: string) => string | undefined } if (typeof reqWithGet.get === 'function') { const value = reqWithGet.get(lowerName) if (value) return value } // 2. Versuche headers.get() (Fetch API Headers) const headersWithGet = req.headers as unknown as { get?: (name: string) => string | null } if (req.headers && typeof headersWithGet.get === 'function') { const value = headersWithGet.get(lowerName) if (value) return value } // 3. Direkter Zugriff auf headers object (IncomingHttpHeaders) if (req.headers && typeof req.headers === 'object') { const headers = req.headers as unknown as Record const value = headers[lowerName] if (typeof value === 'string') return value if (Array.isArray(value) && value.length > 0) return value[0] } return undefined } /** * Extrahiert Client-Info aus einem PayloadRequest * * Funktioniert mit: * - PayloadRequest (Express-basiert mit IncomingHttpHeaders) * - Next.js Request (Fetch API basiert) */ function extractClientInfo(req?: PayloadRequest): ClientInfo { if (!req) return {} const forwarded = getHeaderValue(req, 'x-forwarded-for') const realIp = getHeaderValue(req, 'x-real-ip') let ipAddress: string | undefined if (forwarded) { ipAddress = forwarded.split(',')[0]?.trim() } else if (realIp) { ipAddress = realIp } const userAgent = getHeaderValue(req, 'user-agent') return { ipAddress, userAgent } } /** * Hook: Loggt Passwort-Reset-Anfragen * * WICHTIG: Dieser Hook muss in Users.hooks.afterForgotPassword registriert werden * * Der Hook bekommt { args, collection, context } wobei: * - args.req.payload = Payload-Instanz * - args.data.email = E-Mail-Adresse * - args.req = PayloadRequest (für IP/User-Agent) */ export const auditAfterForgotPassword: CollectionAfterForgotPasswordHook = async ({ args }) => { // Bei Forgot Password haben wir nur die E-Mail, nicht die User-ID // Aus Sicherheitsgründen wird nicht offengelegt ob die E-Mail existiert const email = args?.data?.email as string | undefined if (!email) { console.warn('[Audit:Auth] Password reset hook called without email') return } // Payload-Instanz und Request aus args holen const req = args?.req as PayloadRequest | undefined const payload = req?.payload // Client-Info extrahieren (auch für Fallback-Log) const clientInfo = extractClientInfo(req) if (payload) { // Normaler Pfad: Logging über Audit-Service await logPasswordReset(payload, email, req) console.log(`[Audit:Auth] Password reset requested for ${email}`) } else { // Fallback: Strukturiertes Log wenn Payload nicht verfügbar // Dies sollte nie passieren, aber wir loggen zumindest strukturiert const fallbackEntry = { timestamp: new Date().toISOString(), action: 'password_reset', severity: 'info', entityType: 'users', userEmail: email, ipAddress: clientInfo.ipAddress || 'unknown', userAgent: clientInfo.userAgent || 'unknown', description: `Passwort-Reset angefordert für ${email}`, fallback: true, reason: 'Payload instance not available in hook context', } // Strukturiertes JSON-Log für spätere Analyse/Import console.error('[Audit:Auth:Fallback]', JSON.stringify(fallbackEntry)) // Zusätzlich lesbare Warnung console.warn( `[Audit:Auth] FALLBACK: Password reset for ${email} logged to console only ` + `(IP: ${clientInfo.ipAddress || 'unknown'})`, ) } } /** * Hilfsfunktion: Loggt fehlgeschlagene Logins * * Diese Funktion kann von Custom-Auth-Handlern oder Middleware aufgerufen werden, * da Payload keinen nativen afterLoginFailed Hook hat. * * Verwendung: * - In Custom-API-Routes wenn Login fehlschlägt * - In Middleware die Auth-Errors abfängt * * @param payload - Payload-Instanz * @param email - E-Mail des fehlgeschlagenen Login-Versuchs * @param reason - Grund für das Fehlschlagen * @param reqOrClientInfo - PayloadRequest ODER direktes ClientInfo-Objekt */ export async function auditLoginFailed( payload: Payload, email: string, reason: string, reqOrClientInfo?: PayloadRequest | ClientInfo, ): Promise { await logLoginFailed(payload, email, reason, reqOrClientInfo) console.log(`[Audit:Auth] Login failed for ${email}: ${reason}`) }