Fehlgeschlagener /auth/refresh (Replay/Brute-Force auf geraubte Refresh-Tokens) wurde als TOKEN_REFRESH/LOW geloggt und entging der Alarmierung - ein Angreifer konnte von /login auf /refresh ausweichen, um unter der LOGIN_FAILED-Schwelle zu bleiben. Audit-Actions speisen die Alert-Engine nicht (die zaehlt SecurityEvent via emit). Fix daher an zwei Ebenen: - Detection: refresh()-Catch emittiert TOKEN_REJECTED -> greift die bestehende Schwelle (>=3 TOKEN_REJECTED/5min/IP -> CRITICAL). Severity wie Access-Token: abgelaufen/revoked = LOW (kein Sofort-Alert), ungueltige Signatur/Manipulation = HIGH. auth.service reicht dafuer err.code REFRESH_EXPIRED/REFRESH_INVALID durch. "Kein Cookie" emittiert bewusst nicht (normaler Erstbesuch). - Audit-Triage: fehlgeschlagener Refresh -> Sensitivitaet HIGH statt LOW + Label "Token-Refresh abgelehnt". Action bleibt TOKEN_REFRESH (semantisch ein Refresh, kein Login). Verifiziert: tsx-Test abgelaufen->LOW, manipuliert/garbage->HIGH; tsc gruen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
512 lines
17 KiB
TypeScript
512 lines
17 KiB
TypeScript
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<void> {
|
||
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 || '<leer>'}`,
|
||
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<void> {
|
||
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 || '<leer>'}`,
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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);
|
||
// Detection (Pentest R164-01): ein vorgelegter, aber abgelehnter Refresh-Token
|
||
// ist potenzieller Replay/Brute-Force → TOKEN_REJECTED emittieren, damit die
|
||
// bestehende Schwelle (>=3 TOKEN_REJECTED/5min/IP → CRITICAL) greift.
|
||
// Severity wie beim Access-Token (auth-Middleware): abgelaufen/revoked = LOW
|
||
// (benigne, kein Sofort-Alert), ungültige Signatur/Manipulation = HIGH.
|
||
const ctx = contextFromRequest(req);
|
||
const code = (error as { code?: string })?.code;
|
||
const msg = error instanceof Error ? error.message : 'Refresh fehlgeschlagen';
|
||
const benign = code === 'REFRESH_EXPIRED' || /invalidiert/i.test(msg);
|
||
emitSecurityEvent({
|
||
type: 'TOKEN_REJECTED',
|
||
severity: benign ? 'LOW' : 'HIGH',
|
||
message: `Refresh-Token abgelehnt: ${msg}`,
|
||
ipAddress: ctx.ipAddress,
|
||
endpoint: ctx.endpoint,
|
||
});
|
||
res.status(401).json({
|
||
success: false,
|
||
error: msg,
|
||
} as ApiResponse);
|
||
}
|
||
}
|
||
|
||
export async function register(req: Request, res: Response): Promise<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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);
|
||
}
|
||
}
|