3e1fc3eab2
Portal-Customer-Schwellwert bleibt 12 (Handy-Eingabe → längere PWs
erhöhen Reuse-Risiko). Mitarbeiter/Admin nutzen Passwort-Manager,
für die kostet die Länge nichts.
passwordGenerator.ts:
- STAFF_MIN_PASSWORD_LENGTH = 25, PORTAL_MIN_PASSWORD_LENGTH = 12
- validatePasswordComplexity({ minLength }) parametrisiert
Mitarbeiter-Pfade auf 25:
- createUser, register, setUserPassword
- confirmPasswordReset: Audience aus Token bestimmen
(getPasswordResetAudience), User → 25, Customer → 12. Kein
Body-Hint, damit kein Downgrade-Trick möglich.
Portal-Pfade unverändert (default 12):
- setPortalPassword, changeInitialPortalPassword
Seed-Admin:
- 28-char Zufallspasswort (statt 16) mit allen 4 Klassen garantiert
- SEED_ADMIN_PASSWORD-ENV nur akzeptiert wenn ≥ 25 Zeichen,
sonst Log-Warnung + Random-Fallback
Frontend:
- UserList: Hinweis "Mind. 25 Zeichen". Update + PW gleichzeitig →
zwei API-Calls (PUT + POST /users/:id/password) statt
Password im Body durchzuschmuggeln (Backend strippt es eh)
- PasswordResetConfirm: Hinweis "Mind. 12 (Mitarbeiter: 25)"
- userApi.setPassword(id, password) neu
Live-verifiziert:
- POST /users/6/password "Hallo123!Test" (12) → 400 "mindestens 25"
- POST /users/6/password "MeinExtremLangesPW2026!Test" → 200,
Login mit neuem PW → success
- POST /customers/3/portal/password "Hallo123!Test" (12) → 200
- POST /users createUser mit 12-char-PW → 400 "mindestens 25"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
496 lines
17 KiB
TypeScript
496 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);
|
||
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<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);
|
||
}
|
||
}
|