fix: eliminate duplicate audit entries and add proper client context

- Extend logLoginFailed to accept ClientInfo directly (not just PayloadRequest)
- Add logPasswordReset function for password reset audit logging
- Remove duplicate manual payload.create calls in login routes
- Implement real fallback in auditAfterForgotPassword with structured JSON log
- Login routes now create single audit entry with full IP/User-Agent context

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2025-12-07 22:07:52 +00:00
parent dfb35566b7
commit 47d912016b
4 changed files with 139 additions and 66 deletions

View file

@ -99,31 +99,11 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
reason = errorMessage
}
// Client-Info für Audit-Log
// Client-Info für Audit-Log extrahieren
const clientInfo = getClientInfo(req)
// Audit-Log für fehlgeschlagenen Login (ohne PayloadRequest)
await logLoginFailed(payload, email, reason)
// Zusätzlich: Detailliertes Log mit IP/User-Agent direkt erstellen
try {
await (payload.create as Function)({
collection: 'audit-logs',
data: {
action: 'login_failed',
severity: 'warning',
entityType: 'users',
userEmail: email,
ipAddress: clientInfo.ipAddress,
userAgent: clientInfo.userAgent,
description: `Fehlgeschlagener Login-Versuch für ${email}: ${reason}`,
metadata: { reason, source: '/api/auth/login' },
},
overrideAccess: true,
})
} catch (auditError) {
console.error('[Auth:Login] Failed to create detailed audit log:', auditError)
}
// Audit-Log für fehlgeschlagenen Login mit vollem Client-Context
await logLoginFailed(payload, email, reason, clientInfo)
console.log(
`[Audit:Auth] Login failed for ${email}: ${reason} (IP: ${clientInfo.ipAddress})`,

View file

@ -96,31 +96,11 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
reason = errorMessage
}
// Client-Info für Audit-Log
// Client-Info für Audit-Log extrahieren
const clientInfo = getClientInfo(req)
// Audit-Log für fehlgeschlagenen Login mit IP und User-Agent
await logLoginFailed(payload, email, reason, undefined)
// Zusätzlich: Manuelles Logging mit Client-Info da wir keinen PayloadRequest haben
try {
await (payload.create as Function)({
collection: 'audit-logs',
data: {
action: 'login_failed',
severity: 'warning',
entityType: 'users',
userEmail: email,
ipAddress: clientInfo.ipAddress,
userAgent: clientInfo.userAgent,
description: `Fehlgeschlagener Login-Versuch für ${email}: ${reason}`,
metadata: { reason },
},
overrideAccess: true,
})
} catch (auditError) {
console.error('[Auth:Login] Failed to create detailed audit log:', auditError)
}
// Audit-Log für fehlgeschlagenen Login mit vollem Client-Context
await logLoginFailed(payload, email, reason, clientInfo)
console.log(
`[Audit:Auth] Login failed for ${email}: ${reason} (IP: ${clientInfo.ipAddress})`,

View file

@ -11,7 +11,13 @@ import type {
Payload,
PayloadRequest,
} from 'payload'
import { logLoginSuccess, logLoginFailed, createAuditLog } from '../lib/audit/audit-service'
import {
logLoginSuccess,
logLoginFailed,
logPasswordReset,
createAuditLog,
type ClientInfo,
} from '../lib/audit/audit-service'
interface AuthUser {
id: number
@ -56,6 +62,23 @@ export const auditAfterLogout: CollectionAfterLogoutHook = async ({ req }) => {
console.log(`[Audit:Auth] Logout for ${user.email}`)
}
/**
* Extrahiert Client-Info aus einem PayloadRequest
*/
function extractClientInfo(req?: PayloadRequest): ClientInfo {
if (!req?.headers?.get) return {}
const forwarded = req.headers.get('x-forwarded-for')
const realIp = req.headers.get('x-real-ip')
const ipAddress =
(typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : undefined) ||
(typeof realIp === 'string' ? realIp : undefined)
const userAgent = req.headers.get('user-agent') || undefined
return { ipAddress, userAgent }
}
/**
* Hook: Loggt Passwort-Reset-Anfragen
*
@ -71,27 +94,46 @@ export const auditAfterForgotPassword: CollectionAfterForgotPasswordHook = async
// Aus Sicherheitsgründen wird nicht offengelegt ob die E-Mail existiert
const email = args?.data?.email as string | undefined
// Payload-Instanz und Request aus args holen (NICHT aus context!)
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
if (payload && email) {
await createAuditLog(
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}`,
},
req, // Request für IP/User-Agent übergeben
)
fallback: true,
reason: 'Payload instance not available in hook context',
}
console.log(`[Audit:Auth] Password reset requested for ${email}`)
} else {
// Fallback-Logging wenn Payload nicht verfügbar
console.warn(`[Audit:Auth] Could not log password reset - payload: ${!!payload}, email: ${email}`)
// 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'})`,
)
}
}
@ -104,13 +146,18 @@ export const auditAfterForgotPassword: CollectionAfterForgotPasswordHook = async
* 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,
req?: PayloadRequest,
reqOrClientInfo?: PayloadRequest | ClientInfo,
): Promise<void> {
await logLoginFailed(payload, email, reason, req)
await logLoginFailed(payload, email, reason, reqOrClientInfo)
console.log(`[Audit:Auth] Login failed for ${email}: ${reason}`)
}

View file

@ -148,15 +148,41 @@ export async function logLoginSuccess(
)
}
/**
* Client-Info Interface für direkte Übergabe (wenn kein PayloadRequest verfügbar)
*/
export interface ClientInfo {
ipAddress?: string
userAgent?: string
}
/**
* Loggt einen fehlgeschlagenen Login
*
* @param payload - Payload-Instanz
* @param email - E-Mail des Login-Versuchs
* @param reason - Grund für das Fehlschlagen
* @param reqOrClientInfo - PayloadRequest ODER direktes ClientInfo-Objekt
*/
export async function logLoginFailed(
payload: Payload,
email: string,
reason: string,
req?: PayloadRequest,
reqOrClientInfo?: PayloadRequest | ClientInfo,
): Promise<void> {
// Unterscheide zwischen PayloadRequest und direktem ClientInfo
let clientInfo: ClientInfo = {}
if (reqOrClientInfo) {
if ('headers' in reqOrClientInfo && typeof reqOrClientInfo.headers?.get === 'function') {
// Es ist ein PayloadRequest - extrahiere Client-Info
clientInfo = getClientInfo(reqOrClientInfo as PayloadRequest)
} else {
// Es ist direktes ClientInfo
clientInfo = reqOrClientInfo as ClientInfo
}
}
await createAuditLog(
payload,
{
@ -164,10 +190,50 @@ export async function logLoginFailed(
severity: 'warning',
entityType: 'users',
userEmail: email,
ipAddress: clientInfo.ipAddress,
userAgent: clientInfo.userAgent,
description: `Fehlgeschlagener Login-Versuch für ${email}: ${reason}`,
metadata: { reason },
},
req,
undefined, // Kein req mehr nötig, Client-Info ist bereits im Input
)
}
/**
* Loggt eine Passwort-Reset-Anfrage
*
* @param payload - Payload-Instanz
* @param email - E-Mail für den Reset
* @param reqOrClientInfo - PayloadRequest ODER direktes ClientInfo-Objekt
*/
export async function logPasswordReset(
payload: Payload,
email: string,
reqOrClientInfo?: PayloadRequest | ClientInfo,
): Promise<void> {
// Unterscheide zwischen PayloadRequest und direktem ClientInfo
let clientInfo: ClientInfo = {}
if (reqOrClientInfo) {
if ('headers' in reqOrClientInfo && typeof reqOrClientInfo.headers?.get === 'function') {
clientInfo = getClientInfo(reqOrClientInfo as PayloadRequest)
} else {
clientInfo = reqOrClientInfo as ClientInfo
}
}
await createAuditLog(
payload,
{
action: 'password_reset',
severity: 'info',
entityType: 'users',
userEmail: email,
ipAddress: clientInfo.ipAddress,
userAgent: clientInfo.userAgent,
description: `Passwort-Reset angefordert für ${email}`,
},
undefined,
)
}