mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 17:24:12 +00:00
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:
parent
a923d3ecb4
commit
4198e5cc8f
1 changed files with 215 additions and 214 deletions
|
|
@ -1,106 +1,170 @@
|
|||
/**
|
||||
* Override für Payload's native Login-Route
|
||||
* Custom Login Route mit Audit-Logging
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Security:
|
||||
* - Rate-Limiting: 5 Versuche pro 15 Minuten pro IP (Anti-Brute-Force)
|
||||
* Diese Route erweitert Payload's native Login um:
|
||||
* - Audit-Logging für fehlgeschlagene Login-Versuche
|
||||
* - Rate-Limiting (Anti-Brute-Force)
|
||||
* - 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 configPromise from '@payload-config'
|
||||
import config from '@payload-config'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { logLoginFailed, logRateLimit } from '@/lib/audit/audit-service'
|
||||
import {
|
||||
authLimiter,
|
||||
rateLimitHeaders,
|
||||
getClientIpFromRequest,
|
||||
isIpBlocked,
|
||||
validateCsrf,
|
||||
} from '@/lib/security'
|
||||
|
||||
// Lazy imports für Security-Module um Initialisierungsfehler zu vermeiden
|
||||
let securityModules: {
|
||||
authLimiter?: { check: (ip: string) => Promise<{ allowed: boolean; remaining: number; resetIn: number }> }
|
||||
rateLimitHeaders?: (result: { remaining: number; resetIn: number }, max: number) => Record<string, string>
|
||||
getClientIpFromRequest?: (req: NextRequest) => string
|
||||
isIpBlocked?: (ip: string) => boolean
|
||||
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 realIp = req.headers.get('x-real-ip')
|
||||
const ipAddress =
|
||||
(forwarded ? forwarded.split(',')[0]?.trim() : undefined) || realIp || 'unknown'
|
||||
return (forwarded ? forwarded.split(',')[0]?.trim() : undefined) || realIp || 'unknown'
|
||||
}
|
||||
|
||||
const userAgent = req.headers.get('user-agent') || 'unknown'
|
||||
|
||||
return { ipAddress, userAgent }
|
||||
/**
|
||||
* Extrahiert Client-Informationen für Audit-Logging
|
||||
*/
|
||||
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> {
|
||||
// IP-Blocklist prüfen
|
||||
const clientIp = getClientIpFromRequest(req)
|
||||
if (isIpBlocked(clientIp)) {
|
||||
const clientIp = getClientIp(req)
|
||||
|
||||
// 1. IP-Blocklist prüfen (optional, fail-safe)
|
||||
try {
|
||||
const security = await getSecurityModules()
|
||||
if (security.isIpBlocked?.(clientIp)) {
|
||||
return NextResponse.json(
|
||||
{ errors: [{ message: 'Access denied' }] },
|
||||
{ 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
|
||||
const csrfResult = validateCsrf(req)
|
||||
// 2. CSRF-Schutz (nur für Browser-Requests, fail-safe)
|
||||
// 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) {
|
||||
console.log('[Login] CSRF validation failed:', csrfResult.reason)
|
||||
return NextResponse.json(
|
||||
{ errors: [{ message: 'CSRF validation failed' }] },
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
|
||||
// Rate-Limiting prüfen (Anti-Brute-Force)
|
||||
const rateLimit = await authLimiter.check(clientIp)
|
||||
if (!rateLimit.allowed) {
|
||||
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),
|
||||
},
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Login] CSRF check failed:', err)
|
||||
// Continue - don't block login if check fails
|
||||
}
|
||||
}
|
||||
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
||||
// 3. Rate-Limiting prüfen (optional, fail-safe)
|
||||
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 password: string | undefined
|
||||
|
||||
try {
|
||||
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')) {
|
||||
const formData = await req.formData()
|
||||
|
||||
// Payload Admin Panel sendet Daten als _payload JSON-Feld
|
||||
const payloadField = formData.get('_payload')
|
||||
if (payloadField) {
|
||||
|
|
@ -109,89 +173,50 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|||
email = payloadData.email
|
||||
password = payloadData.password
|
||||
} catch {
|
||||
// Falls _payload kein gültiges JSON ist, ignorieren
|
||||
// Falls _payload kein gültiges JSON ist
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Direkte FormData-Felder (für curl/Tests)
|
||||
// Fallback: Direkte FormData-Felder
|
||||
if (!email || !password) {
|
||||
email = formData.get('email')?.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')) {
|
||||
const formData = await req.formData()
|
||||
email = formData.get('email')?.toString()
|
||||
password = formData.get('password')?.toString()
|
||||
redirectUrl = formData.get('redirect')?.toString()
|
||||
} else {
|
||||
// Default: JSON
|
||||
const body = await req.json()
|
||||
email = body.email
|
||||
password = body.password
|
||||
}
|
||||
|
||||
// Validierung
|
||||
if (!email || !password) {
|
||||
} catch (err) {
|
||||
console.error('[Login] Failed to parse request body:', err)
|
||||
return NextResponse.json(
|
||||
{
|
||||
errors: [{ message: 'E-Mail und Passwort sind erforderlich' }],
|
||||
},
|
||||
{ errors: [{ message: 'Invalid request body' }] },
|
||||
{ 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 {
|
||||
// Versuche Login über Payload
|
||||
const payload = await getPayload({ config })
|
||||
|
||||
const result = await payload.login({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email,
|
||||
password,
|
||||
},
|
||||
data: { email, password },
|
||||
})
|
||||
|
||||
// Erfolgreicher Login - afterLogin Hook hat bereits geloggt
|
||||
|
||||
// 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
|
||||
// Erfolgreicher Login - Response erstellen
|
||||
const response = NextResponse.json({
|
||||
message: 'Auth Passed',
|
||||
user: result.user,
|
||||
|
|
@ -199,7 +224,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|||
exp: result.exp,
|
||||
})
|
||||
|
||||
// Set the token cookie (wie Payload es macht)
|
||||
// Cookie setzen
|
||||
if (result.token) {
|
||||
response.cookies.set('payload-token', result.token, {
|
||||
httpOnly: true,
|
||||
|
|
@ -212,9 +237,8 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|||
|
||||
return response
|
||||
} catch (loginError) {
|
||||
// Login fehlgeschlagen - Audit-Log erstellen mit vollem Request-Context
|
||||
const errorMessage =
|
||||
loginError instanceof Error ? loginError.message : 'Unbekannter Fehler'
|
||||
// Login fehlgeschlagen
|
||||
const errorMessage = loginError instanceof Error ? loginError.message : 'Unknown error'
|
||||
|
||||
// Bestimme den Grund für das Fehlschlagen
|
||||
let reason = 'Unbekannter Fehler'
|
||||
|
|
@ -230,45 +254,22 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|||
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)
|
||||
|
||||
// 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})`,
|
||||
)
|
||||
|
||||
// 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)
|
||||
await audit.logLoginFailed?.(payload, email, reason, clientInfo)
|
||||
console.log(`[Audit:Auth] Login failed for ${email}: ${reason} (IP: ${clientInfo.ipAddress})`)
|
||||
} catch (auditErr) {
|
||||
console.warn('[Login] Audit logging failed:', auditErr)
|
||||
// Continue - don't let audit failure affect response
|
||||
}
|
||||
|
||||
// Response im Payload-Format (wie der native Endpoint)
|
||||
// Standard Payload-Fehlerantwort
|
||||
return NextResponse.json(
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
message: 'The email or password provided is incorrect.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ 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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue