fix: correct auth audit logging - use args.req.payload and override native login

- Fix afterForgotPassword hook to read payload from args.req.payload instead of context
- Create /api/users/login route to override native Payload login endpoint
- Add IP/User-Agent context to failed login audit entries
- Update /api/auth/login with consistent client info logging

🤖 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:00:36 +00:00
parent 7b8efcff38
commit dfb35566b7
3 changed files with 220 additions and 17 deletions

View file

@ -15,7 +15,21 @@
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { NextRequest, NextResponse } from 'next/server'
import { auditLoginFailed } from '@/hooks/auditAuthEvents'
import { logLoginFailed } from '@/lib/audit/audit-service'
/**
* Extrahiert Client-Informationen aus dem Request für Audit-Logging
*/
function getClientInfo(req: NextRequest): { ipAddress: string; userAgent: string } {
const forwarded = req.headers.get('x-forwarded-for')
const realIp = req.headers.get('x-real-ip')
const ipAddress =
(forwarded ? forwarded.split(',')[0]?.trim() : undefined) || realIp || 'unknown'
const userAgent = req.headers.get('user-agent') || 'unknown'
return { ipAddress, userAgent }
}
export async function POST(req: NextRequest): Promise<NextResponse> {
try {
@ -67,7 +81,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
return response
} catch (loginError) {
// Login fehlgeschlagen - Audit-Log erstellen
// Login fehlgeschlagen - Audit-Log erstellen mit vollem Context
const errorMessage =
loginError instanceof Error ? loginError.message : 'Unbekannter Fehler'
@ -79,12 +93,41 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
reason = 'Konto gesperrt'
} else if (errorMessage.includes('disabled')) {
reason = 'Konto deaktiviert'
} else if (errorMessage.includes('verify')) {
reason = 'E-Mail nicht verifiziert'
} else {
reason = errorMessage
}
// Audit-Log für fehlgeschlagenen Login
await auditLoginFailed(payload, email, reason)
// Client-Info für Audit-Log
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)
}
console.log(
`[Audit:Auth] Login failed for ${email}: ${reason} (IP: ${clientInfo.ipAddress})`,
)
return NextResponse.json(
{

View file

@ -0,0 +1,150 @@
/**
* Override für Payload's native Login-Route
*
* POST /api/users/login
*
* Diese Route überschreibt den nativen Payload-Login-Endpoint, um fehlgeschlagene
* Login-Versuche im Audit-Log zu erfassen. Dies ist notwendig, weil Payload
* keinen nativen afterLoginFailed Hook hat.
*
* Erfolgreiche Logins werden weiterhin durch den afterLogin-Hook geloggt.
*/
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { NextRequest, NextResponse } from 'next/server'
import { logLoginFailed } from '@/lib/audit/audit-service'
/**
* Extrahiert Client-Informationen aus dem Request für Audit-Logging
*/
function getClientInfo(req: NextRequest): { ipAddress: string; userAgent: string } {
const forwarded = req.headers.get('x-forwarded-for')
const realIp = req.headers.get('x-real-ip')
const ipAddress =
(forwarded ? forwarded.split(',')[0]?.trim() : undefined) || realIp || 'unknown'
const userAgent = req.headers.get('user-agent') || 'unknown'
return { ipAddress, userAgent }
}
export async function POST(req: NextRequest): Promise<NextResponse> {
const payload = await getPayload({ config: configPromise })
try {
const body = await req.json()
const { email, password } = body
// Validierung
if (!email || !password) {
return NextResponse.json(
{
errors: [{ message: 'E-Mail und Passwort sind erforderlich' }],
},
{ status: 400 },
)
}
try {
// Versuche Login über Payload
const result = await payload.login({
collection: 'users',
data: {
email,
password,
},
})
// Erfolgreicher Login - afterLogin Hook hat bereits geloggt
// Response im Payload-Format zurückgeben
const response = NextResponse.json({
message: 'Auth Passed',
user: result.user,
token: result.token,
exp: result.exp,
})
// Set the token cookie (wie Payload es macht)
if (result.token) {
response.cookies.set('payload-token', result.token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: result.exp ? result.exp - Math.floor(Date.now() / 1000) : 7200,
})
}
return response
} catch (loginError) {
// Login fehlgeschlagen - Audit-Log erstellen mit vollem Request-Context
const errorMessage =
loginError instanceof Error ? loginError.message : 'Unbekannter Fehler'
// Bestimme den Grund für das Fehlschlagen
let reason = 'Unbekannter Fehler'
if (errorMessage.includes('not found') || errorMessage.includes('incorrect')) {
reason = 'Ungültige Anmeldedaten'
} else if (errorMessage.includes('locked')) {
reason = 'Konto gesperrt'
} else if (errorMessage.includes('disabled')) {
reason = 'Konto deaktiviert'
} else if (errorMessage.includes('verify')) {
reason = 'E-Mail nicht verifiziert'
} else {
reason = errorMessage
}
// Client-Info für Audit-Log
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)
}
console.log(
`[Audit:Auth] Login failed for ${email}: ${reason} (IP: ${clientInfo.ipAddress})`,
)
// Response im Payload-Format (wie der native Endpoint)
return NextResponse.json(
{
errors: [
{
message: 'The email or password provided is incorrect.',
},
],
},
{ status: 401 },
)
}
} catch (error) {
console.error('[API:Auth] Login error:', error)
return NextResponse.json(
{
errors: [{ message: 'Interner Serverfehler' }],
},
{ status: 500 },
)
}
}

View file

@ -60,28 +60,38 @@ export const auditAfterLogout: CollectionAfterLogoutHook = async ({ req }) => {
* 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,
context,
}) => {
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
// Payload-Instanz aus dem Context holen
const payload = (context as { req?: { payload?: Payload } })?.req?.payload
// Payload-Instanz und Request aus args holen (NICHT aus context!)
const req = args?.req as PayloadRequest | undefined
const payload = req?.payload
if (payload && email) {
await createAuditLog(payload, {
await createAuditLog(
payload,
{
action: 'password_reset',
severity: 'info',
entityType: 'users',
userEmail: email,
description: `Passwort-Reset angefordert für ${email}`,
})
},
req, // Request für IP/User-Agent übergeben
)
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}`)
}
}