cms.c2sgmbh/src/app/(payload)/api/users/login/route.ts
Martin Porwoll 63b97c14f2 feat(security): enhance CSRF, IP allowlist, and rate limiter with strict production checks
- CSRF: Require CSRF_SECRET in production, throw error on missing secret
- IP Allowlist: TRUST_PROXY must be explicitly set to 'true' for proxy headers
- Rate Limiter: Add proper proxy trust handling for client IP detection
- Login: Add browser form redirect support with safe URL validation
- Add custom admin login page with styled form
- Update CLAUDE.md with TRUST_PROXY documentation
- Update tests for new security behavior

BREAKING: Server will not start in production without CSRF_SECRET or PAYLOAD_SECRET

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 05:06:15 +00:00

274 lines
8.4 KiB
TypeScript

/**
* 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<NextResponse> {
// 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 },
)
}
}