cms.c2sgmbh/src/hooks/auditAuthEvents.ts
Martin Porwoll 304e54f9e2 feat: upgrade Payload CMS 3.69.0 → 3.76.1
Upgrade all 11 @payloadcms/* packages to 3.76.1, gaining fixes from
PRs #15404 (user.collection property for multi-tenant access control)
and #15499 (tenant selector uses beforeNav slot).

Fix afterLogin audit hook deadlock: payload.create() inside the hook
caused a transaction deadlock with PgBouncer in transaction mode under
Payload 3.76.1's stricter transaction handling. Changed to fire-and-forget
pattern to prevent login hangs.

Note: Next.js 15.5.9 peer dependency warning exists but build/runtime
work correctly. Consider upgrading Next.js to 16.x or downgrading to
15.4.11 in a follow-up.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 11:07:46 +00:00

212 lines
6.4 KiB
TypeScript

/**
* 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<string, string | string[] | undefined>
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<void> {
await logLoginFailed(payload, email, reason, reqOrClientInfo)
console.log(`[Audit:Auth] Login failed for ${email}: ${reason}`)
}