mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 23:14:12 +00:00
295 lines
9.1 KiB
TypeScript
295 lines
9.1 KiB
TypeScript
/**
|
|
* Custom Login Route mit Audit-Logging
|
|
*
|
|
* POST /api/users/login
|
|
*
|
|
* Diese Route erweitert Payload's native Login um:
|
|
* - Audit-Logging für fehlgeschlagene Login-Versuche
|
|
* - Rate-Limiting (Anti-Brute-Force)
|
|
* - IP-Blocklist-Prüfung
|
|
* - CSRF-Schutz für Browser-Requests
|
|
*
|
|
* 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 config from '@payload-config'
|
|
import { NextRequest, NextResponse } from 'next/server'
|
|
|
|
// Lazy imports für Security-Module um Initialisierungsfehler zu vermeiden
|
|
type SecurityModules = {
|
|
authLimiter?: { check: (ip: string) => Promise<{ allowed: boolean; remaining: number; resetIn: number }> }
|
|
rateLimitHeaders?: (result: unknown, max: number) => Record<string, string>
|
|
getClientIpFromRequest?: (req: NextRequest) => string
|
|
isIpBlocked?: (ip: string) => boolean
|
|
validateCsrf?: (req: NextRequest) => { valid: boolean; reason?: string }
|
|
}
|
|
|
|
let securityModules: SecurityModules = {}
|
|
let securityModulesLoaded = false
|
|
|
|
async function getSecurityModules(): Promise<SecurityModules> {
|
|
if (securityModulesLoaded) return securityModules
|
|
|
|
try {
|
|
const security = await import('@/lib/security')
|
|
securityModules = {
|
|
authLimiter: security.authLimiter,
|
|
rateLimitHeaders: security.rateLimitHeaders as SecurityModules['rateLimitHeaders'],
|
|
getClientIpFromRequest: security.getClientIpFromRequest,
|
|
isIpBlocked: security.isIpBlocked,
|
|
validateCsrf: security.validateCsrf,
|
|
}
|
|
} catch (err) {
|
|
console.warn('[Login] Security modules not available:', err)
|
|
securityModules = {}
|
|
}
|
|
|
|
securityModulesLoaded = true
|
|
|
|
return securityModules
|
|
}
|
|
|
|
// Lazy import für Audit-Service
|
|
type 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>
|
|
}
|
|
|
|
let auditService: AuditService = {}
|
|
let auditServiceLoaded = false
|
|
|
|
async function getAuditService(): Promise<AuditService> {
|
|
if (auditServiceLoaded) return auditService
|
|
|
|
try {
|
|
const audit = await import('@/lib/audit/audit-service')
|
|
auditService = {
|
|
logLoginFailed: audit.logLoginFailed as AuditService['logLoginFailed'],
|
|
logRateLimit: audit.logRateLimit as AuditService['logRateLimit'],
|
|
}
|
|
} catch (err) {
|
|
console.warn('[Login] Audit service not available:', err)
|
|
auditService = {}
|
|
}
|
|
|
|
auditServiceLoaded = true
|
|
|
|
return auditService
|
|
}
|
|
|
|
/**
|
|
* Extrahiert Client-IP aus Request
|
|
*/
|
|
function getClientIp(req: NextRequest): string {
|
|
const forwarded = req.headers.get('x-forwarded-for')
|
|
const realIp = req.headers.get('x-real-ip')
|
|
return (forwarded ? forwarded.split(',')[0]?.trim() : undefined) || realIp || 'unknown'
|
|
}
|
|
|
|
/**
|
|
* 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> {
|
|
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
|
|
}
|
|
|
|
// 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 },
|
|
)
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn('[Login] CSRF check failed:', err)
|
|
// Continue - don't block login if check fails
|
|
}
|
|
}
|
|
|
|
// 3. Rate-Limiting prüfen (optional, fail-safe)
|
|
try {
|
|
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') || ''
|
|
|
|
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
|
|
}
|
|
}
|
|
// Fallback: Direkte FormData-Felder
|
|
if (!email || !password) {
|
|
email = formData.get('email')?.toString()
|
|
password = formData.get('password')?.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()
|
|
} else {
|
|
// Default: JSON
|
|
const body = await req.json()
|
|
email = body.email
|
|
password = body.password
|
|
}
|
|
} catch (err) {
|
|
console.error('[Login] Failed to parse request body:', err)
|
|
return NextResponse.json(
|
|
{ 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 {
|
|
const payload = await getPayload({ config })
|
|
|
|
const result = await payload.login({
|
|
collection: 'users',
|
|
data: { email, password },
|
|
})
|
|
|
|
// Erfolgreicher Login - Response erstellen
|
|
const response = NextResponse.json({
|
|
message: 'Auth Passed',
|
|
user: result.user,
|
|
token: result.token,
|
|
exp: result.exp,
|
|
})
|
|
|
|
// Cookie setzen
|
|
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
|
|
const errorMessage = loginError instanceof Error ? loginError.message : 'Unknown error'
|
|
|
|
// 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
|
|
}
|
|
|
|
// Audit-Log für fehlgeschlagenen Login (optional, fail-safe)
|
|
try {
|
|
const payload = await getPayload({ config })
|
|
const audit = await getAuditService()
|
|
const clientInfo = getClientInfo(req)
|
|
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
|
|
}
|
|
|
|
// Standard Payload-Fehlerantwort
|
|
return NextResponse.json(
|
|
{ errors: [{ message: 'The email or password provided is incorrect.' }] },
|
|
{ status: 401 },
|
|
)
|
|
}
|
|
}
|