fix(auth): make login route fail-safe with lazy-loaded security modules

Refactor custom login route to be more resilient:
- Lazy-load security modules to prevent initialization errors
- Wrap each security feature in try-catch for graceful degradation
- Skip CSRF validation for API requests (no Origin header + JSON content)
- Improve error handling and logging throughout

This ensures login works even if individual security modules fail,
while still providing full protection when everything is available.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2026-01-17 16:04:47 +00:00
parent a923d3ecb4
commit 4198e5cc8f

View file

@ -1,106 +1,170 @@
/** /**
* Override für Payload's native Login-Route * Custom Login Route mit Audit-Logging
* *
* POST /api/users/login * POST /api/users/login
* *
* Diese Route überschreibt den nativen Payload-Login-Endpoint, um fehlgeschlagene * Diese Route erweitert Payload's native Login um:
* Login-Versuche im Audit-Log zu erfassen. Dies ist notwendig, weil Payload * - Audit-Logging für fehlgeschlagene Login-Versuche
* keinen nativen afterLoginFailed Hook hat. * - Rate-Limiting (Anti-Brute-Force)
*
* Security:
* - Rate-Limiting: 5 Versuche pro 15 Minuten pro IP (Anti-Brute-Force)
* - IP-Blocklist-Prüfung * - IP-Blocklist-Prüfung
* - Audit-Logging für fehlgeschlagene Logins * - CSRF-Schutz für Browser-Requests
* *
* Erfolgreiche Logins werden weiterhin durch den afterLogin-Hook geloggt. * Wichtig: Jedes Security-Feature ist in try-catch gewrappt,
* damit ein Fehler in einem Feature nicht den gesamten Login blockiert.
*/ */
import { getPayload } from 'payload' import { getPayload } from 'payload'
import configPromise from '@payload-config' import config from '@payload-config'
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { logLoginFailed, logRateLimit } from '@/lib/audit/audit-service'
import { // Lazy imports für Security-Module um Initialisierungsfehler zu vermeiden
authLimiter, let securityModules: {
rateLimitHeaders, authLimiter?: { check: (ip: string) => Promise<{ allowed: boolean; remaining: number; resetIn: number }> }
getClientIpFromRequest, rateLimitHeaders?: (result: { remaining: number; resetIn: number }, max: number) => Record<string, string>
isIpBlocked, getClientIpFromRequest?: (req: NextRequest) => string
validateCsrf, isIpBlocked?: (ip: string) => boolean
} from '@/lib/security' validateCsrf?: (req: NextRequest) => { valid: boolean; reason?: string }
} | null = null
async function getSecurityModules() {
if (securityModules) return securityModules
try {
const security = await import('@/lib/security')
securityModules = {
authLimiter: security.authLimiter,
rateLimitHeaders: security.rateLimitHeaders,
getClientIpFromRequest: security.getClientIpFromRequest,
isIpBlocked: security.isIpBlocked,
validateCsrf: security.validateCsrf,
}
} catch (err) {
console.warn('[Login] Security modules not available:', err)
securityModules = {}
}
return securityModules
}
// Lazy import für Audit-Service
let auditService: {
logLoginFailed?: (payload: unknown, email: string, reason: string, clientInfo: { ipAddress: string; userAgent: string }) => Promise<void>
logRateLimit?: (payload: unknown, endpoint: string, userId?: number, tenantId?: number) => Promise<void>
} | null = null
async function getAuditService() {
if (auditService) return auditService
try {
const audit = await import('@/lib/audit/audit-service')
auditService = {
logLoginFailed: audit.logLoginFailed,
logRateLimit: audit.logRateLimit,
}
} catch (err) {
console.warn('[Login] Audit service not available:', err)
auditService = {}
}
return auditService
}
/** /**
* Extrahiert Client-Informationen aus dem Request für Audit-Logging * Extrahiert Client-IP aus Request
*/ */
function getClientInfo(req: NextRequest): { ipAddress: string; userAgent: string } { function getClientIp(req: NextRequest): string {
const forwarded = req.headers.get('x-forwarded-for') const forwarded = req.headers.get('x-forwarded-for')
const realIp = req.headers.get('x-real-ip') const realIp = req.headers.get('x-real-ip')
const ipAddress = return (forwarded ? forwarded.split(',')[0]?.trim() : undefined) || realIp || 'unknown'
(forwarded ? forwarded.split(',')[0]?.trim() : undefined) || realIp || 'unknown' }
const userAgent = req.headers.get('user-agent') || 'unknown' /**
* Extrahiert Client-Informationen für Audit-Logging
return { ipAddress, userAgent } */
function getClientInfo(req: NextRequest): { ipAddress: string; userAgent: string } {
return {
ipAddress: getClientIp(req),
userAgent: req.headers.get('user-agent') || 'unknown',
}
} }
export async function POST(req: NextRequest): Promise<NextResponse> { export async function POST(req: NextRequest): Promise<NextResponse> {
// IP-Blocklist prüfen const clientIp = getClientIp(req)
const clientIp = getClientIpFromRequest(req)
if (isIpBlocked(clientIp)) { // 1. IP-Blocklist prüfen (optional, fail-safe)
try {
const security = await getSecurityModules()
if (security.isIpBlocked?.(clientIp)) {
return NextResponse.json( return NextResponse.json(
{ errors: [{ message: 'Access denied' }] }, { errors: [{ message: 'Access denied' }] },
{ status: 403 }, { status: 403 },
) )
} }
} catch (err) {
console.warn('[Login] IP blocklist check failed:', err)
// Continue - don't block login if check fails
}
// CSRF-Schutz für Browser-basierte Requests // 2. CSRF-Schutz (nur für Browser-Requests, fail-safe)
const csrfResult = validateCsrf(req) // API-Requests ohne Origin-Header (CLI, Server-to-Server) brauchen kein CSRF
const origin = req.headers.get('origin')
const isApiRequest = !origin && req.headers.get('content-type')?.includes('application/json')
if (!isApiRequest) {
try {
const security = await getSecurityModules()
if (security.validateCsrf) {
const csrfResult = security.validateCsrf(req)
if (!csrfResult.valid) { if (!csrfResult.valid) {
console.log('[Login] CSRF validation failed:', csrfResult.reason)
return NextResponse.json( return NextResponse.json(
{ errors: [{ message: 'CSRF validation failed' }] }, { errors: [{ message: 'CSRF validation failed' }] },
{ status: 403 }, { status: 403 },
) )
} }
}
// Rate-Limiting prüfen (Anti-Brute-Force) } catch (err) {
const rateLimit = await authLimiter.check(clientIp) console.warn('[Login] CSRF check failed:', err)
if (!rateLimit.allowed) { // Continue - don't block login if check fails
const payload = await getPayload({ config: configPromise }) }
await logRateLimit(payload, '/api/users/login', undefined, undefined)
return NextResponse.json(
{
errors: [
{
message: 'Too many login attempts. Please try again later.',
},
],
},
{
status: 429,
headers: rateLimitHeaders(rateLimit, 5),
},
)
} }
const payload = await getPayload({ config: configPromise }) // 3. Rate-Limiting prüfen (optional, fail-safe)
try { try {
// Parse body - unterstütze JSON und FormData (Admin Panel sendet FormData) const security = await getSecurityModules()
if (security.authLimiter) {
const rateLimit = await security.authLimiter.check(clientIp)
if (!rateLimit.allowed) {
// Optionally log rate limit hit
try {
const payload = await getPayload({ config })
const audit = await getAuditService()
await audit.logRateLimit?.(payload, '/api/users/login', undefined, undefined)
} catch {
// Ignore audit logging errors
}
const headers = security.rateLimitHeaders?.(rateLimit, 5) || {}
return NextResponse.json(
{ errors: [{ message: 'Too many login attempts. Please try again later.' }] },
{ status: 429, headers },
)
}
}
} catch (err) {
console.warn('[Login] Rate limiting check failed:', err)
// Continue - don't block login if check fails
}
// 4. Parse Request Body
let email: string | undefined let email: string | undefined
let password: string | undefined let password: string | undefined
try {
const contentType = req.headers.get('content-type') || '' const contentType = req.headers.get('content-type') || ''
// Für Browser-Formulare: Redirect-Ziel extrahieren
let redirectUrl: string | undefined
// Prüfen ob dies ein Browser-Formular ist (nicht XHR/fetch)
const acceptHeader = req.headers.get('accept') || ''
const isBrowserForm =
acceptHeader.includes('text/html') && !req.headers.get('x-requested-with')
if (contentType.includes('multipart/form-data')) { if (contentType.includes('multipart/form-data')) {
const formData = await req.formData() const formData = await req.formData()
// Payload Admin Panel sendet Daten als _payload JSON-Feld // Payload Admin Panel sendet Daten als _payload JSON-Feld
const payloadField = formData.get('_payload') const payloadField = formData.get('_payload')
if (payloadField) { if (payloadField) {
@ -109,89 +173,50 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
email = payloadData.email email = payloadData.email
password = payloadData.password password = payloadData.password
} catch { } catch {
// Falls _payload kein gültiges JSON ist, ignorieren // Falls _payload kein gültiges JSON ist
} }
} }
// Fallback: Direkte FormData-Felder
// Fallback: Direkte FormData-Felder (für curl/Tests)
if (!email || !password) { if (!email || !password) {
email = formData.get('email')?.toString() email = formData.get('email')?.toString()
password = formData.get('password')?.toString() password = formData.get('password')?.toString()
} }
// Redirect-URL für Browser-Formulare
redirectUrl = formData.get('redirect')?.toString()
} else if (contentType.includes('application/x-www-form-urlencoded')) { } else if (contentType.includes('application/x-www-form-urlencoded')) {
const formData = await req.formData() const formData = await req.formData()
email = formData.get('email')?.toString() email = formData.get('email')?.toString()
password = formData.get('password')?.toString() password = formData.get('password')?.toString()
redirectUrl = formData.get('redirect')?.toString()
} else { } else {
// Default: JSON // Default: JSON
const body = await req.json() const body = await req.json()
email = body.email email = body.email
password = body.password password = body.password
} }
} catch (err) {
// Validierung console.error('[Login] Failed to parse request body:', err)
if (!email || !password) {
return NextResponse.json( return NextResponse.json(
{ { errors: [{ message: 'Invalid request body' }] },
errors: [{ message: 'E-Mail und Passwort sind erforderlich' }],
},
{ status: 400 }, { status: 400 },
) )
} }
// 5. Validierung
if (!email || !password) {
return NextResponse.json(
{ errors: [{ message: 'E-Mail und Passwort sind erforderlich' }] },
{ status: 400 },
)
}
// 6. Login durchführen
try { try {
// Versuche Login über Payload const payload = await getPayload({ config })
const result = await payload.login({ const result = await payload.login({
collection: 'users', collection: 'users',
data: { data: { email, password },
email,
password,
},
}) })
// Erfolgreicher Login - afterLogin Hook hat bereits geloggt // Erfolgreicher Login - Response erstellen
// Für Browser-Formulare: Redirect mit gesetztem Cookie
if (isBrowserForm && redirectUrl) {
// Sicherheitscheck: Nur relative URLs oder URLs zur eigenen Domain erlauben
const serverUrl = process.env.PAYLOAD_PUBLIC_SERVER_URL || ''
const isRelativeUrl = redirectUrl.startsWith('/')
const isSameDomain = serverUrl && redirectUrl.startsWith(serverUrl)
if (isRelativeUrl || isSameDomain) {
// Verhindere Redirect zu Login-Seite (Loop-Schutz)
let safeRedirect = redirectUrl
if (redirectUrl.includes('/login')) {
safeRedirect = '/admin'
}
const redirectResponse = NextResponse.redirect(
isRelativeUrl
? new URL(safeRedirect, req.url)
: new URL(safeRedirect),
302,
)
// Set the token cookie
if (result.token) {
redirectResponse.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 redirectResponse
}
}
// Für API-Requests: JSON-Response im Payload-Format
const response = NextResponse.json({ const response = NextResponse.json({
message: 'Auth Passed', message: 'Auth Passed',
user: result.user, user: result.user,
@ -199,7 +224,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
exp: result.exp, exp: result.exp,
}) })
// Set the token cookie (wie Payload es macht) // Cookie setzen
if (result.token) { if (result.token) {
response.cookies.set('payload-token', result.token, { response.cookies.set('payload-token', result.token, {
httpOnly: true, httpOnly: true,
@ -212,9 +237,8 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
return response return response
} catch (loginError) { } catch (loginError) {
// Login fehlgeschlagen - Audit-Log erstellen mit vollem Request-Context // Login fehlgeschlagen
const errorMessage = const errorMessage = loginError instanceof Error ? loginError.message : 'Unknown error'
loginError instanceof Error ? loginError.message : 'Unbekannter Fehler'
// Bestimme den Grund für das Fehlschlagen // Bestimme den Grund für das Fehlschlagen
let reason = 'Unbekannter Fehler' let reason = 'Unbekannter Fehler'
@ -230,45 +254,22 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
reason = errorMessage reason = errorMessage
} }
// Client-Info für Audit-Log extrahieren // Audit-Log für fehlgeschlagenen Login (optional, fail-safe)
try {
const payload = await getPayload({ config })
const audit = await getAuditService()
const clientInfo = getClientInfo(req) const clientInfo = getClientInfo(req)
await audit.logLoginFailed?.(payload, email, reason, clientInfo)
// Audit-Log für fehlgeschlagenen Login mit vollem Client-Context console.log(`[Audit:Auth] Login failed for ${email}: ${reason} (IP: ${clientInfo.ipAddress})`)
await logLoginFailed(payload, email, reason, clientInfo) } catch (auditErr) {
console.warn('[Login] Audit logging failed:', auditErr)
console.log( // Continue - don't let audit failure affect response
`[Audit:Auth] Login failed for ${email}: ${reason} (IP: ${clientInfo.ipAddress})`,
)
// Für Browser-Formulare: Redirect zur Login-Seite mit Fehlermeldung
if (isBrowserForm) {
const loginUrl = new URL('/admin/login', req.url)
loginUrl.searchParams.set('error', 'invalid')
if (redirectUrl) {
loginUrl.searchParams.set('redirect', redirectUrl)
}
return NextResponse.redirect(loginUrl, 302)
} }
// Response im Payload-Format (wie der native Endpoint) // Standard Payload-Fehlerantwort
return NextResponse.json( return NextResponse.json(
{ { errors: [{ message: 'The email or password provided is incorrect.' }] },
errors: [
{
message: 'The email or password provided is incorrect.',
},
],
},
{ status: 401 }, { status: 401 },
) )
} }
} catch (error) {
console.error('[API:Auth] Login error:', error)
return NextResponse.json(
{
errors: [{ message: 'Interner Serverfehler' }],
},
{ status: 500 },
)
}
} }