import { Request, Response, CookieOptions } from 'express'; import * as authService from '../services/auth.service.js'; import { AuthRequest, ApiResponse } from '../types/index.js'; import prisma from '../lib/prisma.js'; import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js'; import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH, PORTAL_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js'; // Refresh-Token-Cookie-Konfiguration. Der Cookie: // - httpOnly → kein JavaScript-Zugriff → bei XSS nicht klaubar // - secure → nur über HTTPS (in Prod via HTTPS_ENABLED, in Dev egal) // - sameSite 'strict' → CSRF-Schutz; Cross-Site-Requests senden den Cookie nicht // - path '/api/auth' → wird nur an Auth-Endpoints mitgeschickt const REFRESH_COOKIE_NAME = 'refresh_token'; function getRefreshCookieOptions(): CookieOptions { return { httpOnly: true, secure: process.env.HTTPS_ENABLED === 'true', sameSite: 'strict', path: '/api/auth', maxAge: 7 * 24 * 60 * 60 * 1000, // 7 Tage, gleicht Refresh-JWT-Lifetime }; } function setRefreshCookie(res: Response, token: string): void { res.cookie(REFRESH_COOKIE_NAME, token, getRefreshCookieOptions()); } function clearRefreshCookie(res: Response): void { res.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' }); } // Whitelist von Fehlermeldungen, die wir an Login-Clients durchreichen dürfen. // ALLES andere (Prisma-Internals, DB-Connection-Errors, Schema-Fehler, ...) // wird als generisches "Anmeldung fehlgeschlagen" maskiert – die Original- // Message bleibt im Server-Log, leakt aber nicht im HTTP-Response. Pentest // Runde 3 (2026-05-16): `prisma.customer.findUnique() invocation: The column // X does not exist` war im Body sichtbar → Tabellen-/Spaltennamen geleakt. const SAFE_LOGIN_ERRORS = new Set([ 'Ungültige Anmeldedaten', 'E-Mail und Passwort erforderlich', ]); function safeLoginError(err: unknown): string { if (err instanceof Error && SAFE_LOGIN_ERRORS.has(err.message)) { return err.message; } if (err instanceof Error) { console.error('[Login] Unerwarteter Fehler (maskiert):', err.message); } return 'Anmeldung fehlgeschlagen'; } // Mitarbeiter-Login export async function login(req: Request, res: Response): Promise { const { email, password } = req.body || {}; const ctx = contextFromRequest(req); try { if (!email || !password) { res.status(400).json({ success: false, error: 'E-Mail und Passwort erforderlich', } as ApiResponse); return; } const result = await authService.login(email, password); // Refresh-Token in httpOnly-Cookie, Access-Token im Body (Frontend hält // ihn nur in memory). `token`-Feld bleibt aus Kompatibilität bestehen. setRefreshCookie(res, result.refreshToken); emitSecurityEvent({ type: 'LOGIN_SUCCESS', severity: 'INFO', message: `Mitarbeiter-Login: ${email}`, ipAddress: ctx.ipAddress, userId: result.user.id, userEmail: email, endpoint: ctx.endpoint, }); res.json({ success: true, data: { token: result.accessToken, user: result.user }, } as ApiResponse); } catch (error) { emitSecurityEvent({ type: 'LOGIN_FAILED', severity: 'LOW', message: `Login-Fehlversuch (Mitarbeiter): ${email || ''}`, ipAddress: ctx.ipAddress, userEmail: email, endpoint: ctx.endpoint, }); res.status(401).json({ success: false, error: safeLoginError(error), } as ApiResponse); } } // Kundenportal-Login export async function customerLogin(req: Request, res: Response): Promise { const { email, password } = req.body || {}; const ctx = contextFromRequest(req); try { if (!email || !password) { res.status(400).json({ success: false, error: 'E-Mail und Passwort erforderlich', } as ApiResponse); return; } const result = await authService.customerLogin(email, password); setRefreshCookie(res, result.refreshToken); emitSecurityEvent({ type: 'LOGIN_SUCCESS', severity: 'INFO', message: `Portal-Login: ${email}`, ipAddress: ctx.ipAddress, customerId: result.user.customerId, userEmail: email, endpoint: ctx.endpoint, }); res.json({ success: true, data: { token: result.accessToken, user: result.user }, } as ApiResponse); } catch (error) { emitSecurityEvent({ type: 'LOGIN_FAILED', severity: 'LOW', message: `Login-Fehlversuch (Portal): ${email || ''}`, ipAddress: ctx.ipAddress, userEmail: email, endpoint: ctx.endpoint, }); res.status(401).json({ success: false, error: safeLoginError(error), } as ApiResponse); } } export async function me(req: AuthRequest, res: Response): Promise { try { if (!req.user) { res.status(401).json({ success: false, error: 'Nicht authentifiziert', } as ApiResponse); return; } // Kundenportal-Login if (req.user.isCustomerPortal && req.user.customerId) { const customer = await authService.getCustomerPortalUser(req.user.customerId); if (!customer) { res.status(404).json({ success: false, error: 'Kunde nicht gefunden', } as ApiResponse); return; } res.json({ success: true, data: customer } as ApiResponse); return; } // Mitarbeiter-Login if (!req.user.userId) { res.status(401).json({ success: false, error: 'Ungültige Authentifizierung', } as ApiResponse); return; } const user = await authService.getUserById(req.user.userId); if (!user) { res.status(404).json({ success: false, error: 'Benutzer nicht gefunden', } as ApiResponse); return; } res.json({ success: true, data: user } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Laden der Benutzerdaten', } as ApiResponse); } } /** * Passwort-Reset anfordern (Email + Token per Mail). * Immer 200 OK zurückgeben um Email-Existenz nicht preiszugeben (User-Enumeration-Schutz). */ export async function requestPasswordReset(req: Request, res: Response): Promise { try { const { email, userType } = req.body; // userType: 'admin' | 'portal' if (!email) { res.status(400).json({ success: false, error: 'E-Mail erforderlich' } as ApiResponse); return; } await authService.requestPasswordReset(email, userType === 'portal' ? 'portal' : 'admin'); const ctx = contextFromRequest(req); emitSecurityEvent({ type: 'PASSWORD_RESET_REQUEST', severity: 'MEDIUM', message: `Passwort-Reset angefordert (${userType === 'portal' ? 'Portal' : 'Mitarbeiter'}): ${email}`, ipAddress: ctx.ipAddress, userEmail: email, endpoint: ctx.endpoint, details: { userType: userType === 'portal' ? 'portal' : 'admin' }, }); // IMMER success senden, damit Angreifer nicht herausfinden kann welche Emails existieren res.json({ success: true, message: 'Wenn ein Konto mit dieser E-Mail existiert, wurde ein Link zum Zurücksetzen gesendet.', } as ApiResponse); } catch (error) { console.error('Password reset request error:', error); // Auch bei Fehlern dieselbe Antwort res.json({ success: true, message: 'Wenn ein Konto mit dieser E-Mail existiert, wurde ein Link zum Zurücksetzen gesendet.', } as ApiResponse); } } /** * Passwort-Reset bestätigen (Token + neues Passwort). */ export async function confirmPasswordReset(req: Request, res: Response): Promise { try { const { token, password } = req.body; if (!token || !password) { res.status(400).json({ success: false, error: 'Token und neues Passwort erforderlich', } as ApiResponse); return; } // Audience anhand des Tokens bestimmen, damit Admin-Reset 25 Zeichen // verlangt und Portal-Customer-Reset weiterhin 12 reicht. const audience = await authService.getPasswordResetAudience(token); const minLength = audience === 'admin' ? STAFF_MIN_PASSWORD_LENGTH : PORTAL_MIN_PASSWORD_LENGTH; const complexity = validatePasswordComplexity(password, { minLength }); if (!complexity.ok) { res.status(400).json({ success: false, error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '), } as ApiResponse); return; } await authService.confirmPasswordReset(token, password); const ctx = contextFromRequest(req); emitSecurityEvent({ type: 'PASSWORD_RESET_CONFIRM', severity: 'HIGH', message: 'Passwort-Reset abgeschlossen', ipAddress: ctx.ipAddress, endpoint: ctx.endpoint, }); res.json({ success: true, message: 'Passwort erfolgreich zurückgesetzt. Du kannst dich jetzt einloggen.', } as ApiResponse); } catch (error) { const ctx = contextFromRequest(req); emitSecurityEvent({ type: 'TOKEN_REJECTED', severity: 'MEDIUM', message: 'Passwort-Reset mit ungültigem/abgelaufenem Token versucht', ipAddress: ctx.ipAddress, endpoint: ctx.endpoint, }); res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Passwort-Reset fehlgeschlagen', } as ApiResponse); } } /** * Logout: invalidiert den aktuellen JWT serverseitig durch Setzen von * tokenInvalidatedAt / portalTokenInvalidatedAt auf jetzt. Auth-Middleware * prüft dieses Feld und lehnt Tokens ab, deren `iat` davor liegt. * * Hinweis: Da JWTs stateless sind, gibt es keine echte Token-Revocation * ohne dieses Pattern. Logout invalidiert ALLE aktiven Sessions des Users * (auch andere Geräte) – akzeptabel für ein Sicherheits-Logout. */ export async function logout(req: AuthRequest, res: Response): Promise { try { const user = req.user as any; if (!user) { res.json({ success: true, message: 'Bereits abgemeldet' } as ApiResponse); return; } if (user.isCustomerPortal && user.customerId) { await prisma.customer.update({ where: { id: user.customerId }, data: { portalTokenInvalidatedAt: new Date() }, }); } else if (user.userId) { await prisma.user.update({ where: { id: user.userId }, data: { tokenInvalidatedAt: new Date() }, }); } // Refresh-Cookie löschen, sonst könnte der Browser einen abgemeldeten User // direkt wieder einloggen (server-seitige Invalidation oben fängt das ab, // aber UI würde sich verirren). clearRefreshCookie(res); const ctx = contextFromRequest(req); emitSecurityEvent({ type: 'LOGOUT', severity: 'INFO', message: `Logout: ${user.email || (user.isCustomerPortal ? 'Portal-User' : 'Mitarbeiter')}`, ipAddress: ctx.ipAddress, userId: ctx.userId, customerId: ctx.customerId, userEmail: user.email, endpoint: ctx.endpoint, }); res.json({ success: true, message: 'Abgemeldet' } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Abmelden', } as ApiResponse); } } // Neuen Access-Token aus dem httpOnly-Refresh-Cookie holen. Wird vom Frontend // (axios-Interceptor) bei 401 oder beim App-Start aufgerufen. export async function refresh(req: Request, res: Response): Promise { try { const cookies = (req as any).cookies || {}; const refreshToken = cookies[REFRESH_COOKIE_NAME]; if (!refreshToken) { res.status(401).json({ success: false, error: 'Kein Refresh-Token vorhanden' } as ApiResponse); return; } const result = await authService.refreshAccessToken(refreshToken); // Refresh-Cookie rotieren – verhindert Replay eines geklauten Refresh-Tokens // bis zur vollen Lifetime. setRefreshCookie(res, result.refreshToken); res.json({ success: true, data: { token: result.accessToken, user: result.user }, } as ApiResponse); } catch (error) { // Refresh fehlgeschlagen: Cookie wegputzen, damit der Browser nicht // weiter mit einem invaliden Token weiterhin den Endpoint klopft. clearRefreshCookie(res); res.status(401).json({ success: false, error: error instanceof Error ? error.message : 'Refresh fehlgeschlagen', } as ApiResponse); } } export async function register(req: Request, res: Response): Promise { try { const { email, password, firstName, lastName, roleIds } = req.body; if (!email || !password || !firstName || !lastName) { res.status(400).json({ success: false, error: 'Alle Pflichtfelder müssen ausgefüllt sein', } as ApiResponse); return; } // Mitarbeiter-Anlage: 25-Zeichen-Schwellwert const complexity = validatePasswordComplexity(password, { minLength: STAFF_MIN_PASSWORD_LENGTH }); if (!complexity.ok) { res.status(400).json({ success: false, error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '), } as ApiResponse); return; } const user = await authService.createUser({ email, password, firstName, lastName, roleIds: roleIds || [2], // Default to employee role }); res.status(201).json({ success: true, data: user } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Benutzer konnte nicht erstellt werden', } as ApiResponse); } } // Kurzlebiger Download-Token (60s) für Aufrufe, die den Token in der URL // brauchen (PDF-iframes, window.open für Audit-Export usw.). Aufrufer // authentifiziert sich normal per Bearer-Header. Antwort: ein download- // scoped JWT, das die Auth-Middleware nur via `?token=` akzeptiert. export async function createDownloadToken(req: AuthRequest, res: Response): Promise { try { if (!req.user) { res.status(401).json({ success: false, error: 'Nicht authentifiziert' } as ApiResponse); return; } const payload: any = { email: req.user.email, permissions: req.user.permissions, isCustomerPortal: !!req.user.isCustomerPortal, }; if (req.user.userId) payload.userId = req.user.userId; if (req.user.customerId) payload.customerId = req.user.customerId; if ((req.user as any).representedCustomerIds) { payload.representedCustomerIds = (req.user as any).representedCustomerIds; } const token = authService.signDownloadToken(payload); res.json({ success: true, data: { token } } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Erstellen des Download-Tokens', } as ApiResponse); } } // Vom Endkunden selbst nach Einmalpasswort-Login aufgerufen, um sein eigenes // Passwort zu vergeben. Server invalidiert die laufende Session, Frontend // loggt aus und schickt zurück zum Login. export async function changeInitialPortalPassword(req: AuthRequest, res: Response): Promise { try { if (!req.user?.isCustomerPortal || !req.user?.customerId) { res.status(403).json({ success: false, error: 'Nur für Kundenportal-Login', } as ApiResponse); return; } // Pflicht-Check: NUR im Einmalpasswort-Flow erlaubt. Sonst könnte jeder // eingeloggte Portal-User sein Passwort ohne Kenntnis des alten ändern // (z.B. nach XSS-Token-Diebstahl). Pentest Runde 5 (2026-05-16) – KRITISCH. const customer = await prisma.customer.findUnique({ where: { id: req.user.customerId }, select: { portalPasswordMustChange: true }, }); if (!customer?.portalPasswordMustChange) { res.status(403).json({ success: false, error: 'Nicht erlaubt', } as ApiResponse); return; } const { newPassword } = req.body || {}; if (!newPassword || typeof newPassword !== 'string') { res.status(400).json({ success: false, error: 'Neues Passwort erforderlich', } as ApiResponse); return; } const complexity = validatePasswordComplexity(newPassword); if (!complexity.ok) { res.status(400).json({ success: false, error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '), } as ApiResponse); return; } await authService.changeInitialPortalPassword(req.user.customerId, newPassword); clearRefreshCookie(res); res.json({ success: true, message: 'Passwort geändert' } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: error instanceof Error ? error.message : 'Passwort konnte nicht geändert werden', } as ApiResponse); } }