mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-18 12:04:11 +00:00
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>
212 lines
6.4 KiB
TypeScript
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}`)
|
|
}
|