fix: complete auth event audit logging

Addresses remaining gaps from the audit review:

1. Register afterForgotPassword hook in Users collection
   - Password reset requests are now properly logged
   - Fixed hook signature (uses context instead of req)

2. Create custom /api/auth/login endpoint
   - Wraps native Payload login
   - Logs failed login attempts via auditLoginFailed
   - Returns proper error responses without exposing details

3. Export auditLoginFailed helper function
   - Can be used by other custom auth handlers
   - Calls logLoginFailed from audit-service

Now all critical auth events are tracked:
- Successful logins (afterLogin hook)
- Failed logins (custom /api/auth/login endpoint)
- Logouts (afterLogout hook)
- Password reset requests (afterForgotPassword hook)

🤖 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 21:31:11 +00:00
parent f667792ba7
commit 7b8efcff38
3 changed files with 179 additions and 12 deletions

View file

@ -0,0 +1,134 @@
/**
* Custom Login Endpoint mit Audit-Logging
*
* POST /api/auth/login
*
* Dieser Endpoint wrappet den nativen Payload-Login und loggt fehlgeschlagene
* Login-Versuche im Audit-Log. Erfolgreiche Logins werden durch den
* afterLogin-Hook in der Users-Collection geloggt.
*
* Body:
* - email: string (erforderlich)
* - password: string (erforderlich)
*/
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { NextRequest, NextResponse } from 'next/server'
import { auditLoginFailed } from '@/hooks/auditAuthEvents'
export async function POST(req: NextRequest): Promise<NextResponse> {
try {
const payload = await getPayload({ config: configPromise })
const body = await req.json()
const { email, password } = body
// Validierung
if (!email || !password) {
return NextResponse.json(
{ error: '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
// Setze Cookie für die Session
const response = NextResponse.json({
success: true,
user: {
id: result.user.id,
email: result.user.email,
},
message: 'Login erfolgreich',
})
// Set the token cookie
if (result.token) {
response.cookies.set('payload-token', result.token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
// Token expiration (default 2 hours)
maxAge: result.exp ? result.exp - Math.floor(Date.now() / 1000) : 7200,
})
}
return response
} catch (loginError) {
// Login fehlgeschlagen - Audit-Log erstellen
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 {
reason = errorMessage
}
// Audit-Log für fehlgeschlagenen Login
await auditLoginFailed(payload, email, reason)
return NextResponse.json(
{
success: false,
error: 'Anmeldung fehlgeschlagen',
// Keine detaillierten Infos aus Sicherheitsgründen
},
{ status: 401 },
)
}
} catch (error) {
console.error('[API:Auth] Login error:', error)
return NextResponse.json(
{ error: 'Interner Serverfehler' },
{ status: 500 },
)
}
}
/**
* GET /api/auth/login
*
* Gibt API-Dokumentation zurück.
*/
export async function GET(): Promise<NextResponse> {
return NextResponse.json({
endpoint: '/api/auth/login',
method: 'POST',
description: 'Login endpoint with audit logging for failed attempts',
body: {
email: 'string (required)',
password: 'string (required)',
},
response: {
success: {
success: true,
user: { id: 'number', email: 'string' },
message: 'string',
},
error: {
success: false,
error: 'string',
},
},
note: 'Successful logins are logged via afterLogin hook. Failed attempts are logged here.',
})
}

View file

@ -1,6 +1,10 @@
import type { CollectionConfig } from 'payload' import type { CollectionConfig } from 'payload'
import { auditUserAfterChange, auditUserAfterDelete } from '../hooks/auditUserChanges' import { auditUserAfterChange, auditUserAfterDelete } from '../hooks/auditUserChanges'
import { auditAfterLogin, auditAfterLogout } from '../hooks/auditAuthEvents' import {
auditAfterLogin,
auditAfterLogout,
auditAfterForgotPassword,
} from '../hooks/auditAuthEvents'
export const Users: CollectionConfig = { export const Users: CollectionConfig = {
slug: 'users', slug: 'users',
@ -13,6 +17,7 @@ export const Users: CollectionConfig = {
afterDelete: [auditUserAfterDelete], afterDelete: [auditUserAfterDelete],
afterLogin: [auditAfterLogin], afterLogin: [auditAfterLogin],
afterLogout: [auditAfterLogout], afterLogout: [auditAfterLogout],
afterForgotPassword: [auditAfterForgotPassword],
}, },
fields: [ fields: [
{ {

View file

@ -8,8 +8,10 @@ import type {
CollectionAfterLoginHook, CollectionAfterLoginHook,
CollectionAfterLogoutHook, CollectionAfterLogoutHook,
CollectionAfterForgotPasswordHook, CollectionAfterForgotPasswordHook,
Payload,
PayloadRequest,
} from 'payload' } from 'payload'
import { logLoginSuccess, createAuditLog } from '../lib/audit/audit-service' import { logLoginSuccess, logLoginFailed, createAuditLog } from '../lib/audit/audit-service'
interface AuthUser { interface AuthUser {
id: number id: number
@ -56,23 +58,49 @@ export const auditAfterLogout: CollectionAfterLogoutHook = async ({ req }) => {
/** /**
* Hook: Loggt Passwort-Reset-Anfragen * Hook: Loggt Passwort-Reset-Anfragen
*
* WICHTIG: Dieser Hook muss in Users.hooks.afterForgotPassword registriert werden
*/ */
export const auditAfterForgotPassword: CollectionAfterForgotPasswordHook = async ({ args, context }) => { export const auditAfterForgotPassword: CollectionAfterForgotPasswordHook = async ({
// Hinweis: Bei Forgot Password haben wir nur die E-Mail, nicht die User-ID args,
// aus Sicherheitsgründen wird nicht offengelegt ob die E-Mail existiert context,
}) => {
// 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
// Wir loggen nur wenn der Context die Payload-Instanz enthält // Payload-Instanz aus dem Context holen
if (context && 'payload' in context) { const payload = (context as { req?: { payload?: Payload } })?.req?.payload
const payload = context.payload as typeof import('payload').default
await createAuditLog(payload as unknown as import('payload').Payload, { if (payload && email) {
await createAuditLog(payload, {
action: 'password_reset', action: 'password_reset',
severity: 'info', severity: 'info',
entityType: 'users', entityType: 'users',
userEmail: args.data?.email as string || 'unknown', userEmail: email,
description: `Passwort-Reset angefordert für ${args.data?.email || 'unknown'}`, description: `Passwort-Reset angefordert für ${email}`,
}) })
console.log(`[Audit:Auth] Password reset requested for ${args.data?.email}`) console.log(`[Audit:Auth] Password reset requested for ${email}`)
} }
} }
/**
* 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
*/
export async function auditLoginFailed(
payload: Payload,
email: string,
reason: string,
req?: PayloadRequest,
): Promise<void> {
await logLoginFailed(payload, email, reason, req)
console.log(`[Audit:Auth] Login failed for ${email}: ${reason}`)
}