import { Request, Response } from 'express'; import bcrypt from 'bcryptjs'; import prisma from '../lib/prisma.js'; import * as userService from '../services/user.service.js'; import { logChange } from '../services/audit.service.js'; import { AUDIT_OPS_ROLLE } from '../services/user.service.js'; import { ApiResponse, AuthRequest } from '../types/index.js'; import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js'; import { pickUserCreate, pickUserUpdate, isValidEmail, sanitizePhoneField } from '../utils/sanitize.js'; import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js'; // Users export async function getUsers(req: Request, res: Response): Promise { try { const { search, isActive, roleId, page, limit } = req.query; const result = await userService.getAllUsers({ search: search as string, isActive: isActive !== undefined ? isActive === 'true' : undefined, roleId: roleId ? parseInt(roleId as string) : undefined, page: page ? parseInt(page as string) : undefined, limit: limit ? parseInt(limit as string) : undefined, }); res.json({ success: true, data: result.users, pagination: result.pagination, } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Laden der Benutzer', } as ApiResponse); } } export async function getUser(req: Request, res: Response): Promise { try { const user = await userService.getUserById(parseInt(req.params.id)); 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 des Benutzers', } as ApiResponse); } } export async function createUser(req: Request, res: Response): Promise { try { // Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz) const data = pickUserCreate(req.body) as any; // Email-Format prüfen, sonst landet "x@y\nBcc:..." in der DB // (Pentest 29.4 – SMTP-Header-Injection). if (!isValidEmail(data?.email) || !data?.email) { res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse); return; } if (data?.password) { const c = validatePasswordComplexity(data.password, { minLength: STAFF_MIN_PASSWORD_LENGTH }); if (!c.ok) { res.status(400).json({ success: false, error: 'Passwort erfüllt Mindestanforderungen nicht: ' + c.errors.join(', '), } as ApiResponse); return; } } // 60.3: WhatsApp- und Signal-Nummern gegen CRLF/Header-Injection sichern. try { if ('whatsappNumber' in data) { const cleaned = sanitizePhoneField(data.whatsappNumber, 'WhatsApp-Nummer'); data.whatsappNumber = cleaned ?? null; } if ('signalNumber' in data) { const cleaned = sanitizePhoneField(data.signalNumber, 'Signal-Nummer'); data.signalNumber = cleaned ?? null; } } catch (err) { res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Nummer' } as ApiResponse); return; } const user = await userService.createUser(data); await logChange({ req, action: 'CREATE', resourceType: 'User', resourceId: user.id.toString(), label: `Benutzer ${user.firstName} ${user.lastName} angelegt`, }); res.status(201).json({ success: true, data: user } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Benutzers', } as ApiResponse); } } export async function updateUser(req: AuthRequest, res: Response): Promise { try { const userId = parseInt(req.params.id); // `permissions` und `password` darf der generische Update nicht // entgegennehmen. Vorher landeten sie auf dem Floor (Whitelist-Drop), // der Caller bekam aber 200 zurück und glaubte fälschlich, die Werte // wären übernommen worden. Stattdessen sofort 400, damit Tooling / // Client den Fehler sieht. (Pentest 2026-05-20) // - permissions kommen aus Rollen (PUT roleIds bzw. die DSGVO-/ // Developer-Checkboxen) und können nicht direkt am User hängen. // - password wird über POST /users/:id/password gesetzt // (eigene Komplexitäts-Validierung + Audit-Trail). const body = req.body || {}; const forbidden = ['permissions', 'password', 'passwordHash']; const offending = forbidden.filter((k) => k in body); if (offending.length > 0) { res.status(400).json({ success: false, error: `Felder nicht erlaubt: ${offending.join(', ')}. ` + (offending.includes('permissions') ? 'Permissions werden über roleIds / hasGdprAccess / hasDeveloperAccess gesteuert. ' : '') + (offending.includes('password') || offending.includes('passwordHash') ? `Passwort über POST /users/${userId}/password setzen.` : ''), } as ApiResponse); return; } // Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz) const data = pickUserUpdate(req.body) as Record; // Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4). // null/leer ist OK (Email darf optional sein), nur falsches Format prüfen. if (data?.email !== undefined && !isValidEmail(data.email)) { res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse); return; } // 60.3: WhatsApp- und Signal-Nummern gegen CRLF/Header-Injection sichern. try { if ('whatsappNumber' in data) { const cleaned = sanitizePhoneField(data.whatsappNumber, 'WhatsApp-Nummer'); data.whatsappNumber = cleaned ?? null; } if ('signalNumber' in data) { const cleaned = sanitizePhoneField(data.signalNumber, 'Signal-Nummer'); data.signalNumber = cleaned ?? null; } } catch (err) { res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Nummer' } as ApiResponse); return; } // Vorherigen Stand laden für Audit – inkl. Rollen, damit hasGdprAccess / // hasDeveloperAccess (versteckte Rollen) korrekt verglichen werden. const beforeUser = await prisma.user.findUnique({ where: { id: userId }, include: { roles: { include: { role: true } } }, }); const before = beforeUser ? { ...beforeUser, hasGdprAccess: beforeUser.roles.some((ur) => ur.role.name === 'DSGVO'), hasDeveloperAccess: beforeUser.roles.some((ur) => ur.role.name === 'Developer'), hasAuditOpsAccess: beforeUser.roles.some((ur) => ur.role.name === AUDIT_OPS_ROLLE), } : null; // Der Audit-Betrieb-Haken vergibt die EINGREIFENDEN Rechte am Protokoll // (versiegeln, neu berechnen, aufraeumen, Aufbewahrung). Ihn zu setzen ist // kein gewoehnliches Benutzer-Update: Er entscheidet, wer die // Beweisgrundlage ersetzen darf. // // Eine eigene Rechte-Huerde davorzusetzen scheitert am Henne-Ei-Problem - // nach der Aufteilung haelt zunaechst niemand `audit:admin`, und dann // koennte ihn auch niemand vergeben. Stattdessen wird die Vergabe LAUT: // CRITICAL im Protokoll und zusaetzlich in den Alarmkanal, so wie beim // Dienstkonto-Kennzeichen (R184-01). const setztAuditBetrieb = data.hasAuditOpsAccess !== undefined && before !== null && data.hasAuditOpsAccess !== (before as any).hasAuditOpsAccess; const aktiviertAuditBetrieb = data.hasAuditOpsAccess === true; // Das Dienstkonto-Kennzeichen SENKT die Alarmstufe der Anmeldungen dieses // Kontos (Pentest R184). Damit ist es selbst ein Hebel zur Waesche: Wer sein // Konto so markiert, laesst die eigenen auffaelligen Anmeldungen als // Routine erscheinen. Es bekommt deshalb dieselbe Behandlung wie das // Absenken einer Aufbewahrungsfrist – Bestaetigung, laute Protokollierung, // und kein Selbstbedienen. const setztDienstkonto = typeof (data as any).isServiceAccount === 'boolean' && (data as any).isServiceAccount !== before?.isServiceAccount; const aktiviertDienstkonto = setztDienstkonto && (data as any).isServiceAccount === true; // R184-02: Das Kennzeichen ist ein Audit-Governance-Eingriff, kein // gewoehnliches Benutzerfeld – es ist Geschwister von retention-shorten, // seal-backlog, rehash und cleanup, die alle `audit:admin` verlangen. Es // haengt aber an der Route mit `users:update`. Heute deckungsgleich, weil // nur die Administratorrolle beides hat – aber jede kuenftige Rolle mit // „Benutzer bearbeiten“ wuerde still „Login-Alarme herunterstufen“ erben. // Deshalb hier zusaetzlich auf die Domaene pruefen, in der der Schaden // liegt. if (setztDienstkonto && !(req.user?.permissions || []).includes('audit:admin')) { res.status(403).json({ success: false, error: 'Das Dienstkonto-Kennzeichen ändert, wie Anmeldungen im Audit-Log bewertet werden. ' + 'Dafür genügt „Benutzer bearbeiten“ nicht – es erfordert die Berechtigung ' + 'audit:admin, wie das Verkürzen von Aufbewahrungsfristen auch.', } as ApiResponse); return; } if (setztDienstkonto && req.user?.userId === userId) { res.status(403).json({ success: false, error: 'Das Dienstkonto-Kennzeichen lässt sich nicht am eigenen Konto setzen oder entfernen. ' + 'Es stuft Anmeldungen dieses Kontos als Routine ein – wer das für sich selbst täte, ' + 'könnte die eigenen Anmeldungen unauffällig machen. Bitte von einem anderen ' + 'Administrator vornehmen lassen.', } as ApiResponse); return; } // Gate in BEIDE Richtungen (Pentest R184-01). Das Entfernen war ungegatet – // und es ist der gefaehrlichere Weg: Ein un-geflaggtes Konto faellt aus der // Heartbeat-Wache, weil diese am Live-Kennzeichen haengt. Genau das // „stilllegen und auf Stille setzen“, gegen das der Wachhund gebaut wurde. if (setztDienstkonto && req.body?.confirm !== 'SERVICE_ACCOUNT') { res.status(400).json({ success: false, error: aktiviertDienstkonto ? `Damit werden künftige Anmeldungen von ${before?.email ?? 'diesem Konto'} im ` + 'Audit-Log als Routine geführt statt als kritisches Ereignis. Das ist für ' + 'planmäßig arbeitende Dienste gedacht (etwa das Gegenbuch) – für ein Konto, das ' + 'ein Mensch benutzt, wäre es eine Tarnung. ' + 'Zum Bestätigen {"confirm":"SERVICE_ACCOUNT"} mitsenden.' : `Damit fällt ${before?.email ?? 'dieses Konto'} aus der Überwachung heraus: Es wird ` + 'dann nicht mehr gemeldet, wenn es sich nicht mehr anmeldet. Bei einem laufenden ' + 'Dienst wie dem Gegenbuch hieße das, sein Ausfall bliebe unbemerkt. ' + 'Zum Bestätigen {"confirm":"SERVICE_ACCOUNT"} mitsenden.', } as ApiResponse); return; } const user = await userService.updateUser(userId, data as any); if (user) { // Audit: Geänderte Felder ermitteln und loggen if (before) { const changes: Record = {}; const fieldLabels: Record = { email: 'E-Mail', firstName: 'Vorname', lastName: 'Nachname', isActive: 'Aktiv', hasGdprAccess: 'DSGVO-Zugriff', hasDeveloperAccess: 'Entwicklerzugriff', hasAuditOpsAccess: 'Audit-Betrieb (versiegeln, aufräumen, Aufbewahrung)', isServiceAccount: 'Dienstkonto (Anmeldungen als Routine)', }; for (const [key, newVal] of Object.entries(data)) { if (['id', 'createdAt', 'updatedAt'].includes(key)) continue; const oldVal = (before as any)[key]; const norm = (v: unknown) => (v === null || v === undefined || v === '' ? null : v); if (JSON.stringify(norm(oldVal)) !== JSON.stringify(norm(newVal))) { const label = fieldLabels[key] || key; const formatVal = (v: unknown) => { if (v === null || v === undefined || v === '') return '-'; if (typeof v === 'boolean') return v ? 'Ja' : 'Nein'; return String(v); }; changes[label] = { von: formatVal(oldVal), nach: formatVal(newVal) }; } } const changeList = Object.entries(changes).map(([f, c]) => `${f}: ${c.von} → ${c.nach}`).join(', '); await logChange({ req, action: 'UPDATE', resourceType: 'User', resourceId: user.id.toString(), label: setztDienstkonto ? `Dienstkonto-Kennzeichen ${aktiviertDienstkonto ? 'GESETZT' : 'entfernt'} für ` + `${user.email} – Anmeldungen werden künftig ` + `${aktiviertDienstkonto ? 'als Routine' : 'wieder als kritisch'} geführt` : changeList ? `Benutzer ${user.firstName} ${user.lastName} aktualisiert: ${changeList}` : `Benutzer ${user.firstName} ${user.lastName} aktualisiert`, // Die Aenderung dieses Kennzeichens wird wie ihre Wirkung eingestuft: // Sie beeinflusst, wie kuenftige Anmeldungen bewertet werden. sensitivity: setztDienstkonto || setztAuditBetrieb ? 'CRITICAL' : undefined, details: Object.keys(changes).length > 0 ? changes : undefined, }); // Zusaetzlich in den Alarmkanal (Pentest R184-01). Eine CRITICAL-Zeile // im Audit-Log muss jemand LESEN – das ist die R183-02-Klasse. Das // Entfernen des Kennzeichens nimmt das Konto aus der Heartbeat-Wache; // ab dann faellt sein Ausbleiben nicht mehr auf. Diese Aenderung // gehoert deshalb dorthin, wo automatisch reagiert wird. if (setztDienstkonto) { const ctx = contextFromRequest(req); emitSecurityEvent({ type: 'PERMISSION_CHANGED', severity: 'CRITICAL', message: aktiviertDienstkonto ? `Konto ${user.email} als Dienstkonto markiert – seine Anmeldungen gelten ab ` + 'jetzt als Routine statt als kritisches Ereignis.' : `Konto ${user.email} ist KEIN Dienstkonto mehr – es fällt damit aus der ` + 'Überwachung heraus: Ein Ausbleiben seiner Anmeldungen wird nicht mehr gemeldet.', ipAddress: ctx.ipAddress, userId: req.user?.userId, userEmail: req.user?.email, endpoint: ctx.endpoint, details: { betroffenesKonto: user.email, aktiviert: aktiviertDienstkonto }, }); } if (setztAuditBetrieb) { const ctx = contextFromRequest(req); emitSecurityEvent({ type: 'PERMISSION_CHANGED', severity: 'CRITICAL', message: aktiviertAuditBetrieb ? `Konto ${user.email} darf ab jetzt am Audit-Protokoll EINGREIFEN: versiegeln, ` + 'neu berechnen, aufräumen, Aufbewahrung ändern. Damit kann es die Grundlage ' + 'ersetzen, gegen die Manipulation nachgewiesen wird.' : `Konto ${user.email} darf nicht mehr am Audit-Protokoll eingreifen.`, ipAddress: ctx.ipAddress, userId: req.user?.userId, userEmail: req.user?.email, endpoint: ctx.endpoint, details: { betroffenesKonto: user.email, aktiviert: aktiviertAuditBetrieb, selbstvergabe: user.id === req.user?.userId, }, }); } } else { await logChange({ req, action: 'UPDATE', resourceType: 'User', resourceId: user.id.toString(), label: `Benutzer ${user.firstName} ${user.lastName} aktualisiert`, }); } } res.json({ success: true, data: user } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Benutzers', } as ApiResponse); } } // Admin setzt das Passwort eines anderen Users zurück. Separat vom // generischen Update damit der Vorgang explizit auditiert wird und nicht // versehentlich über Mass-Assignment passieren kann. // Pentest Runde 12 (2026-05-18) MITTEL. export async function setUserPassword(req: Request, res: Response): Promise { try { const userId = parseInt(req.params.id); const { password, currentPassword } = req.body || {}; if (!password || typeof password !== 'string') { res.status(400).json({ success: false, error: 'Passwort erforderlich' } as ApiResponse); return; } // Pentest 47.3 (MEDIUM, 2026-06-01): Re-Auth verpflichtend. // Ein gestohlener Admin-JWT reichte bisher, um Staff-Passwörter // umzuschreiben. Jetzt muss der aufrufende Admin sein eigenes // Passwort mitsenden – CSRF/Token-Klau allein reicht nicht mehr. const authReq = req as AuthRequest; const callerId = authReq.user?.userId; if (!callerId) { res.status(401).json({ success: false, error: 'Nicht authentifiziert' } as ApiResponse); return; } if (!currentPassword || typeof currentPassword !== 'string') { res.status(400).json({ success: false, error: 'Bitte das eigene Passwort zur Bestätigung mitsenden.', } as ApiResponse); return; } const caller = await prisma.user.findUnique({ where: { id: callerId }, select: { password: true }, }); // Timing-Schutz: immer einen bcrypt.compare laufen lassen const callerHash = caller?.password ?? '$2a$10$0000000000000000000000000000000000000000000000000000'; const reAuthOk = await bcrypt.compare(currentPassword, callerHash); if (!caller || !reAuthOk) { res.status(403).json({ success: false, error: 'Eigenes Passwort ist falsch.', } as ApiResponse); return; } const c = validatePasswordComplexity(password, { minLength: STAFF_MIN_PASSWORD_LENGTH }); if (!c.ok) { res.status(400).json({ success: false, error: 'Passwort erfüllt Mindestanforderungen nicht: ' + c.errors.join(', '), } as ApiResponse); return; } const user = await userService.updateUser(userId, { password } as any); if (!user) { res.status(404).json({ success: false, error: 'Benutzer nicht gefunden' } as ApiResponse); return; } // Pentest 48.4 (INFO, 2026-06-01): Bestehende JWTs für den Ziel-User // sofort invalidieren. Greift insbesondere bei Self-Reset (Admin setzt // sich selbst zurück) – der gestohlene Token wird damit ungültig, // statt noch bis zum natürlichen Ablauf brauchbar zu bleiben. // Die Middleware-Auth liest tokenInvalidatedAt und vergleicht gegen // den `iat`-Claim des JWT. await prisma.user.update({ where: { id: user.id }, data: { tokenInvalidatedAt: new Date() }, }); await logChange({ req, action: 'UPDATE', resourceType: 'User', resourceId: user.id.toString(), label: `Passwort für Benutzer ${user.firstName} ${user.lastName} (${user.email}) durch Admin gesetzt (Re-Auth bestätigt, Sessions invalidiert)`, }); res.json({ success: true, message: 'Passwort gesetzt' } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Setzen des Passworts', } as ApiResponse); } } export async function deleteUser(req: Request, res: Response): Promise { try { const userId = parseInt(req.params.id); const userBefore = await userService.getUserById(userId); await userService.deleteUser(userId); await logChange({ req, action: 'DELETE', resourceType: 'User', resourceId: userId.toString(), label: `Benutzer ${userBefore?.firstName || ''} ${userBefore?.lastName || ''} gelöscht`, }); res.json({ success: true, message: 'Benutzer gelöscht' } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Löschen des Benutzers', } as ApiResponse); } } // Roles export async function getRoles(req: Request, res: Response): Promise { try { const roles = await userService.getAllRoles(); res.json({ success: true, data: roles } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Laden der Rollen', } as ApiResponse); } } export async function getRole(req: Request, res: Response): Promise { try { const role = await userService.getRoleById(parseInt(req.params.id)); if (!role) { res.status(404).json({ success: false, error: 'Rolle nicht gefunden', } as ApiResponse); return; } res.json({ success: true, data: role } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Laden der Rolle', } as ApiResponse); } } export async function createRole(req: Request, res: Response): Promise { try { const role = await userService.createRole(req.body); await logChange({ req, action: 'CREATE', resourceType: 'Role', resourceId: role.id.toString(), label: `Rolle ${role.name} angelegt`, }); res.status(201).json({ success: true, data: role } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Rolle', } as ApiResponse); } } export async function updateRole(req: Request, res: Response): Promise { try { const role = await userService.updateRole(parseInt(req.params.id), req.body); if (role) { await logChange({ req, action: 'UPDATE', resourceType: 'Role', resourceId: role.id.toString(), label: `Rolle ${role.name} aktualisiert`, }); } res.json({ success: true, data: role } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Rolle', } as ApiResponse); } } export async function deleteRole(req: Request, res: Response): Promise { try { const roleId = parseInt(req.params.id); const role = await userService.getRoleById(roleId); await userService.deleteRole(roleId); await logChange({ req, action: 'DELETE', resourceType: 'Role', resourceId: roleId.toString(), label: `Rolle ${role?.name || roleId} gelöscht`, }); res.json({ success: true, message: 'Rolle gelöscht' } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Löschen der Rolle', } as ApiResponse); } } // Permissions export async function getPermissions(req: Request, res: Response): Promise { try { const permissions = await userService.getAllPermissions(); res.json({ success: true, data: permissions } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Laden der Berechtigungen', } as ApiResponse); } }