/** * 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. * * Security: * - Rate-Limiting: 5 Versuche pro 15 Minuten pro IP (Anti-Brute-Force) * - IP-Blocklist-Prüfung * - Audit-Logging für fehlgeschlagene Logins * * 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, logRateLimit } from '@/lib/audit/audit-service' import { authLimiter, rateLimitHeaders, getClientIpFromRequest, isIpBlocked, validateCsrf, } from '@/lib/security' /** * 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 { // IP-Blocklist prüfen const clientIp = getClientIpFromRequest(req) if (isIpBlocked(clientIp)) { return NextResponse.json( { errors: [{ message: 'Access denied' }] }, { status: 403 }, ) } // CSRF-Schutz für Browser-basierte Requests const csrfResult = validateCsrf(req) if (!csrfResult.valid) { 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), }, ) } const payload = await getPayload({ config: configPromise }) try { // Parse body - unterstütze JSON und FormData (Admin Panel sendet FormData) let email: string | undefined let password: string | undefined 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) { try { const payloadData = JSON.parse(payloadField.toString()) email = payloadData.email password = payloadData.password } catch { // Falls _payload kein gültiges JSON ist, ignorieren } } // Fallback: Direkte FormData-Felder (für curl/Tests) 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) { 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 // 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({ 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 extrahieren 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) } // 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 }, ) } }