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 { ApiResponse, AuthRequest } from '../types/index.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: Request, 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'), } : null; 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', }; 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: changeList ? `Benutzer ${user.firstName} ${user.lastName} aktualisiert: ${changeList}` : `Benutzer ${user.firstName} ${user.lastName} aktualisiert`, details: Object.keys(changes).length > 0 ? changes : undefined, }); } 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); } }