mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 22:04:10 +00:00
- 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>
274 lines
8.4 KiB
TypeScript
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 },
|
|
)
|
|
}
|
|
}
|