From 0cf3dd6a7b4351f1561a4f4b0f1f6fa0902b24b6 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Fri, 1 May 2026 09:25:47 +0200 Subject: [PATCH] Security-Hardening Runde 10: Security-Monitoring + Alerting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defense-in-Depth für alles, was in den ersten 9 Runden nicht durch Code verhindert wurde: zumindest gesehen + alarmiert werden. 📊 SecurityEvent-Tabelle (Prisma) - Type/Severity/IP/User/Endpoint + Indexen für Filter+Threshold-Detection - Trennt sich vom AuditLog: AuditLog ist forensisch + hash-gekettet, SecurityEvent ist optimiert für Realtime-Alerting + Aggregation. 🪝 Hooks an kritischen Stellen - Login (Success/Failed) – auth.controller - Logout, Password-Reset (Request + Confirm) – auth.controller - Rate-Limit-Hit – middleware/rateLimit - IDOR-403 – utils/accessControl (canAccessCustomer / canAccessContract) - SSRF-Block – emailProvider.controller (test-connection + test-mail-access) - JWT-Reject (alg=none, expired, manipuliert) – middleware/auth 🚨 Threshold-Detection + Alerting (securityAlert.service.ts) - Cron jede Minute: prüft Brute-Force-Patterns je IP - 10× LOGIN_FAILED in 60 min → CRITICAL Brute-Force-Verdacht - 5× ACCESS_DENIED in 5 min → CRITICAL IDOR-Probing-Verdacht - 3× SSRF_BLOCKED in 60 min → CRITICAL SSRF-Probing - 3× TOKEN_REJECTED HIGH in 5 min → CRITICAL JWT-Manipulation - CRITICAL-Events: Sofort-Alert per E-Mail (debounced) - Cron stündlich: Digest mit HIGH+MEDIUM-Events (wenn aktiviert) - Sofort-Alert + Digest laufen über System-E-Mail-Provider (gleicher Pfad wie Geburtstagsgrüße, Passwort-Reset) 🖥 Frontend: Settings → "Sicherheits-Monitoring" - Alert-E-Mail-Adresse + Digest-Toggle - Test-Alert-Button + Digest-jetzt-Button - Stats-Cards pro Severity (CRITICAL/HIGH/MEDIUM/LOW/INFO) - Filter (Type/Severity/Search/IP) + Pagination - Auto-Refresh alle 30 s - Verlinkt aus Settings-Übersicht (settings:read Permission) 🧪 Live-verifiziert - Login-Fehlversuch → LOGIN_FAILED Event - Portal probt 4× fremde Customer-IDs → 4× ACCESS_DENIED - SSRF-Probe (169.254.169.254) → SSRF_BLOCKED Event - 12× LOGIN_FAILED simuliert → Cron erzeugt CRITICAL nach ≤60s - CRITICAL-Sofort-Alert binnen 30s zugestellt - Test-Alert-Button: E-Mail zugestellt - Hourly-Digest mit 5 Events: E-Mail mit Tabelle zugestellt Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/prisma/schema.prisma | 50 +++ backend/src/controllers/auth.controller.ts | 82 ++++- .../controllers/emailProvider.controller.ts | 23 ++ .../src/controllers/monitoring.controller.ts | 164 ++++++++++ backend/src/index.ts | 4 + backend/src/middleware/auth.ts | 12 +- backend/src/middleware/rateLimit.ts | 28 ++ backend/src/routes/monitoring.routes.ts | 15 + backend/src/services/securityAlert.service.ts | 287 +++++++++++++++++ .../src/services/securityMonitor.service.ts | 81 +++++ backend/src/utils/accessControl.ts | 24 ++ docs/SECURITY-HARDENING.md | 42 ++- docs/todo.md | 5 +- frontend/src/App.tsx | 2 + frontend/src/pages/Settings.tsx | 23 +- frontend/src/pages/settings/Monitoring.tsx | 289 ++++++++++++++++++ frontend/src/services/api.ts | 65 ++++ 17 files changed, 1188 insertions(+), 8 deletions(-) create mode 100644 backend/src/controllers/monitoring.controller.ts create mode 100644 backend/src/routes/monitoring.routes.ts create mode 100644 backend/src/services/securityAlert.service.ts create mode 100644 backend/src/services/securityMonitor.service.ts create mode 100644 frontend/src/pages/settings/Monitoring.tsx diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 543b5634..f2476567 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -1113,3 +1113,53 @@ model AuditRetentionPolicy { @@unique([resourceType, sensitivity]) } + +// ==================== SECURITY MONITORING ==================== +// Sicherheitsrelevante Events für Realtime-Alerting + Forensik. +// Im Gegensatz zum AuditLog (forensisch, hash-gekettet) ist das hier +// optimiert für schnelles Filtern + Alerting (nicht-tamper-evident, dafür +// effizient querybar). Threshold-Detection läuft per Cron. + +enum SecurityEventType { + LOGIN_FAILED // falsches Passwort / unbekannter User + LOGIN_SUCCESS // erfolgreicher Login (informativ) + RATE_LIMIT_HIT // express-rate-limit hat zugeschlagen + ACCESS_DENIED // 403 von canAccess* (versuchter IDOR) + SSRF_BLOCKED // ssrfGuard hat geblockte Adresse abgefangen + PASSWORD_RESET_REQUEST // Reset-Mail angefordert + PASSWORD_RESET_CONFIRM // Reset abgeschlossen + LOGOUT // expliziter Logout + TOKEN_REJECTED // ungültiger / abgelaufener / manipulierter JWT + PERMISSION_CHANGED // Admin hat Rolle/Permission geändert + SUSPICIOUS // generischer Catch-All +} + +enum SecuritySeverity { + INFO // Login-Success, Logout + LOW // Einzelner failed Login, einzelner 403 + MEDIUM // Rate-Limit-Hit, mehrere 403er + HIGH // SSRF-Block, JWT-Manipulation + CRITICAL // Threshold überschritten (>10 failed login/h, >5 403/min) +} + +model SecurityEvent { + id Int @id @default(autoincrement()) + type SecurityEventType + severity SecuritySeverity + message String @db.Text + ipAddress String? + userId Int? // Mitarbeiter (falls eingeloggt) + customerId Int? // Portal-Kunde (falls eingeloggt) + userEmail String? // beste Schätzung – auch bei nicht eingeloggt + endpoint String? // betroffener Endpoint + details Json? // strukturierte Zusatzinfo + alerted Boolean @default(false) // schon per Email versendet? + alertedAt DateTime? + + createdAt DateTime @default(now()) + + @@index([type, createdAt]) + @@index([severity, createdAt]) + @@index([ipAddress, createdAt]) + @@index([alerted, severity]) +} diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts index eae195e6..78d48acd 100644 --- a/backend/src/controllers/auth.controller.ts +++ b/backend/src/controllers/auth.controller.ts @@ -2,12 +2,13 @@ import { Request, Response } 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'; // Mitarbeiter-Login export async function login(req: Request, res: Response): Promise { + const { email, password } = req.body || {}; + const ctx = contextFromRequest(req); try { - const { email, password } = req.body; - if (!email || !password) { res.status(400).json({ success: false, @@ -17,8 +18,25 @@ export async function login(req: Request, res: Response): Promise { } const result = await authService.login(email, password); + 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: result } 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: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen', @@ -28,9 +46,9 @@ export async function login(req: Request, res: Response): Promise { // Kundenportal-Login export async function customerLogin(req: Request, res: Response): Promise { + const { email, password } = req.body || {}; + const ctx = contextFromRequest(req); try { - const { email, password } = req.body; - if (!email || !password) { res.status(400).json({ success: false, @@ -40,8 +58,25 @@ export async function customerLogin(req: Request, res: Response): Promise } const result = await authService.customerLogin(email, password); + 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: result } 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: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen', @@ -115,6 +150,17 @@ export async function requestPasswordReset(req: Request, res: Response): Promise 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, @@ -155,11 +201,28 @@ export async function confirmPasswordReset(req: Request, res: Response): Promise 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', @@ -194,6 +257,17 @@ export async function logout(req: AuthRequest, res: Response): Promise { data: { tokenInvalidatedAt: new Date() }, }); } + 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({ diff --git a/backend/src/controllers/emailProvider.controller.ts b/backend/src/controllers/emailProvider.controller.ts index 28301b91..925f6448 100644 --- a/backend/src/controllers/emailProvider.controller.ts +++ b/backend/src/controllers/emailProvider.controller.ts @@ -8,6 +8,7 @@ import { testImapConnection, ImapCredentials } from '../services/imapService.js' import { testSmtpConnection, SmtpCredentials } from '../services/smtpService.js'; import { decrypt } from '../utils/encryption.js'; import { assertAllowedHost, safeResolveHost } from '../utils/ssrfGuard.js'; +import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js'; import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); @@ -128,6 +129,17 @@ export async function testConnection(req: Request, res: Response): Promise await safeResolveHost(url.hostname, 'apiUrl-Host'); } catch (err) { if (err instanceof Error && (err.message.includes('geblockte') || err.message.includes('DNS'))) { + const ctx = contextFromRequest(req); + emitSecurityEvent({ + type: 'SSRF_BLOCKED', + severity: 'HIGH', + message: err.message, + ipAddress: ctx.ipAddress, + userId: ctx.userId, + userEmail: ctx.userEmail, + endpoint: ctx.endpoint, + details: { apiUrl: testData.apiUrl }, + }); res.status(400).json({ success: false, error: err.message } as ApiResponse); return; } @@ -243,6 +255,17 @@ export async function testMailAccess(req: Request, res: Response): Promise safeResolveHost(imapServer, 'IMAP-Server'), ]); } catch (err) { + const ctx = contextFromRequest(req); + emitSecurityEvent({ + type: 'SSRF_BLOCKED', + severity: 'HIGH', + message: err instanceof Error ? err.message : 'Ungültige Server-Adresse', + ipAddress: ctx.ipAddress, + userId: ctx.userId, + userEmail: ctx.userEmail, + endpoint: ctx.endpoint, + details: { smtpServer, imapServer }, + }); res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Server-Adresse', diff --git a/backend/src/controllers/monitoring.controller.ts b/backend/src/controllers/monitoring.controller.ts new file mode 100644 index 00000000..bca50281 --- /dev/null +++ b/backend/src/controllers/monitoring.controller.ts @@ -0,0 +1,164 @@ +import { Response } from 'express'; +import prisma from '../lib/prisma.js'; +import { AuthRequest, ApiResponse } from '../types/index.js'; +import * as appSettingService from '../services/appSetting.service.js'; +import { sendAlertEmail, sendDigest } from '../services/securityAlert.service.js'; +import type { SecurityEventType, SecuritySeverity } from '@prisma/client'; + +/** + * GET /api/monitoring/events + * Liste der Security-Events mit Filter + Pagination. + */ +export async function listEvents(req: AuthRequest, res: Response): Promise { + try { + const page = parseInt((req.query.page as string) || '1'); + const limit = Math.min(parseInt((req.query.limit as string) || '50'), 200); + const type = req.query.type as SecurityEventType | undefined; + const severity = req.query.severity as SecuritySeverity | undefined; + const search = req.query.search as string | undefined; + const since = req.query.since as string | undefined; + const ip = req.query.ip as string | undefined; + + const where: any = {}; + if (type) where.type = type; + if (severity) where.severity = severity; + if (ip) where.ipAddress = ip; + if (since) where.createdAt = { gte: new Date(since) }; + if (search) { + where.OR = [ + { message: { contains: search } }, + { userEmail: { contains: search } }, + { endpoint: { contains: search } }, + ]; + } + + const [events, total, byType, bySeverity] = await Promise.all([ + prisma.securityEvent.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: limit, + skip: (page - 1) * limit, + }), + prisma.securityEvent.count({ where }), + prisma.securityEvent.groupBy({ + by: ['type'], + where: since ? { createdAt: { gte: new Date(since) } } : {}, + _count: true, + }), + prisma.securityEvent.groupBy({ + by: ['severity'], + where: since ? { createdAt: { gte: new Date(since) } } : {}, + _count: true, + }), + ]); + + res.json({ + success: true, + data: events, + pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }, + stats: { + byType: Object.fromEntries(byType.map((r: any) => [r.type, r._count])), + bySeverity: Object.fromEntries(bySeverity.map((r: any) => [r.severity, r._count])), + }, + } as any); + } catch (error) { + console.error('listEvents error:', error); + res.status(500).json({ success: false, error: 'Fehler beim Laden der Security-Events' } as ApiResponse); + } +} + +/** + * GET /api/monitoring/settings + */ +export async function getMonitoringSettings(_req: AuthRequest, res: Response): Promise { + try { + const alertEmail = await appSettingService.getSetting('monitoringAlertEmail'); + const digestEnabled = await appSettingService.getSettingBool('monitoringDigestEnabled'); + const lastDigest = await appSettingService.getSetting('monitoringLastDigestAt'); + res.json({ + success: true, + data: { + alertEmail: alertEmail || '', + digestEnabled, + lastDigestAt: lastDigest || null, + }, + } as ApiResponse); + } catch (error) { + res.status(500).json({ success: false, error: 'Fehler beim Laden' } as ApiResponse); + } +} + +/** + * PUT /api/monitoring/settings + */ +export async function updateMonitoringSettings(req: AuthRequest, res: Response): Promise { + try { + const { alertEmail, digestEnabled } = req.body || {}; + if (typeof alertEmail === 'string') { + // Email-Validierung minimal: muss @ enthalten oder leer sein + if (alertEmail !== '' && !alertEmail.includes('@')) { + res.status(400).json({ success: false, error: 'Ungültige E-Mail-Adresse' } as ApiResponse); + return; + } + await appSettingService.setSetting('monitoringAlertEmail', alertEmail); + } + if (typeof digestEnabled === 'boolean') { + await appSettingService.setSetting('monitoringDigestEnabled', digestEnabled ? 'true' : 'false'); + } + res.json({ success: true, message: 'Einstellungen gespeichert' } as ApiResponse); + } catch (error) { + res.status(500).json({ success: false, error: 'Fehler beim Speichern' } as ApiResponse); + } +} + +/** + * POST /api/monitoring/test-alert + * Versendet eine Test-Alert-Mail an die konfigurierte Adresse. + */ +export async function testAlert(_req: AuthRequest, res: Response): Promise { + try { + const alertEmail = await appSettingService.getSetting('monitoringAlertEmail'); + if (!alertEmail) { + res.status(400).json({ + success: false, + error: 'Keine Alert-E-Mail konfiguriert', + } as ApiResponse); + return; + } + const result = await sendAlertEmail(alertEmail, { + subject: '[OpenCRM] Test-Alert', + events: [{ + type: 'SUSPICIOUS' as any, + severity: 'INFO' as any, + message: 'Dies ist eine Test-Mail vom Monitoring-System. Alles in Ordnung.', + createdAt: new Date(), + } as any], + isDigest: false, + }); + if (result.success) { + res.json({ success: true, message: `Test-Alert an ${alertEmail} versendet` } as ApiResponse); + } else { + res.status(500).json({ success: false, error: result.error || 'Versand fehlgeschlagen' } as ApiResponse); + } + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Test-Alert fehlgeschlagen', + } as ApiResponse); + } +} + +/** + * POST /api/monitoring/run-digest (manueller Trigger für den Hourly-Digest) + */ +export async function runDigestNow(_req: AuthRequest, res: Response): Promise { + try { + const result = await sendDigest({ force: true }); + res.json({ success: true, data: result } as ApiResponse); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Digest fehlgeschlagen', + } as ApiResponse); + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 595802e7..3af97f01 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -37,6 +37,8 @@ import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js'; import { downloadFile } from './controllers/fileDownload.controller.js'; import { startBirthdayScheduler } from './services/birthdayScheduler.service.js'; import { startContractStatusScheduler } from './services/contractStatusScheduler.service.js'; +import { startSecurityMonitorScheduler } from './services/securityAlert.service.js'; +import monitoringRoutes from './routes/monitoring.routes.js'; import { auditContextMiddleware } from './middleware/auditContext.js'; import { auditMiddleware } from './middleware/audit.js'; import { authenticate } from './middleware/auth.js'; @@ -158,6 +160,7 @@ app.use('/api/email-logs', emailLogRoutes); app.use('/api/pdf-templates', pdfTemplateRoutes); app.use('/api/birthdays', birthdayRoutes); app.use('/api/factory-defaults', factoryDefaultsRoutes); +app.use('/api/monitoring', monitoringRoutes); // Health check app.get('/api/health', (req, res) => { @@ -206,4 +209,5 @@ app.listen(PORT as number, LISTEN_ADDR, () => { // Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten startBirthdayScheduler(); startContractStatusScheduler(); + startSecurityMonitorScheduler(); }); diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 84f856af..b59377d5 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -2,6 +2,7 @@ import { Response, NextFunction } from 'express'; import jwt from 'jsonwebtoken'; import prisma from '../lib/prisma.js'; import { AuthRequest, JwtPayload } from '../types/index.js'; +import { emit as emitSecurityEvent } from '../services/securityMonitor.service.js'; export async function authenticate( req: AuthRequest, @@ -81,7 +82,16 @@ export async function authenticate( req.user = decoded; next(); - } catch { + } catch (err) { + // JWT-Failures sind interessant: alg=none, manipulierte Signature, + // expired Token. Emit SecurityEvent (asynchron, blockt nicht). + emitSecurityEvent({ + type: 'TOKEN_REJECTED', + severity: err instanceof jwt.TokenExpiredError ? 'LOW' : 'HIGH', + message: err instanceof Error ? `JWT abgelehnt: ${err.message}` : 'JWT abgelehnt', + ipAddress: req.ip || (req.socket as any)?.remoteAddress || 'unknown', + endpoint: `${req.method} ${req.path}`, + }); res.status(401).json({ success: false, error: 'Ungültiger Token' }); } } diff --git a/backend/src/middleware/rateLimit.ts b/backend/src/middleware/rateLimit.ts index c9ec8500..856c5adc 100644 --- a/backend/src/middleware/rateLimit.ts +++ b/backend/src/middleware/rateLimit.ts @@ -1,8 +1,28 @@ /** * Rate-Limiting-Middleware für sensible Endpoints (Login, Passwort-Reset). * Schützt gegen Brute-Force- und Credential-Stuffing-Angriffe. + * + * Wenn ein Limit überschritten wird, emit() wir zusätzlich ein + * SecurityEvent (RATE_LIMIT_HIT) – damit der Monitoring-View und das + * Alert-System sehen, wenn jemand auf die Tür hämmert. */ import rateLimit from 'express-rate-limit'; +import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js'; + +function onLimitReached(label: string, severity: 'MEDIUM' | 'HIGH') { + return (req: any, _res: any) => { + const ctx = contextFromRequest(req); + emitSecurityEvent({ + type: 'RATE_LIMIT_HIT', + severity, + message: `Rate-Limit überschritten: ${label}`, + ipAddress: ctx.ipAddress, + userEmail: req.body?.email, + endpoint: ctx.endpoint, + details: { limiter: label }, + }); + }; +} /** * Login: 10 Versuche pro 15 Minuten pro IP. @@ -19,6 +39,10 @@ export const loginRateLimiter = rateLimit({ }, // Erfolgreiche Logins zählen nicht gegen das Limit skipSuccessfulRequests: true, + handler: (req, res, _next, options) => { + onLimitReached('login', 'HIGH')(req, res); + res.status(options.statusCode).json(options.message); + }, }); /** @@ -34,4 +58,8 @@ export const passwordResetRateLimiter = rateLimit({ success: false, error: 'Zu viele Passwort-Reset-Anfragen. Bitte in einer Stunde erneut versuchen.', }, + handler: (req, res, _next, options) => { + onLimitReached('password-reset', 'MEDIUM')(req, res); + res.status(options.statusCode).json(options.message); + }, }); diff --git a/backend/src/routes/monitoring.routes.ts b/backend/src/routes/monitoring.routes.ts new file mode 100644 index 00000000..4c73a07f --- /dev/null +++ b/backend/src/routes/monitoring.routes.ts @@ -0,0 +1,15 @@ +import { Router } from 'express'; +import { authenticate, requirePermission } from '../middleware/auth.js'; +import * as monitoringController from '../controllers/monitoring.controller.js'; + +const router = Router(); +router.use(authenticate); + +// Monitoring ist Admin-Sache: settings:read fürs Anzeigen, settings:update für Änderungen +router.get('/events', requirePermission('settings:read'), monitoringController.listEvents); +router.get('/settings', requirePermission('settings:read'), monitoringController.getMonitoringSettings); +router.put('/settings', requirePermission('settings:update'), monitoringController.updateMonitoringSettings); +router.post('/test-alert', requirePermission('settings:update'), monitoringController.testAlert); +router.post('/run-digest', requirePermission('settings:update'), monitoringController.runDigestNow); + +export default router; diff --git a/backend/src/services/securityAlert.service.ts b/backend/src/services/securityAlert.service.ts new file mode 100644 index 00000000..961302d7 --- /dev/null +++ b/backend/src/services/securityAlert.service.ts @@ -0,0 +1,287 @@ +/** + * Security-Alerting: + * - **Sofort-Alert** für CRITICAL-Events (sobald sie entstehen, vom + * Cron alle 60s gepollt) – z.B. Threshold-Überschreitungen. + * - **Hourly-Digest**: einmal pro Stunde Sammlung von HIGH+ Events, + * wenn `monitoringDigestEnabled = true` und mindestens 1 Event vorhanden. + * - **Threshold-Detection**: prüft Brute-Force-Patterns (z.B. >10 + * LOGIN_FAILED/h aus gleicher IP) und erzeugt synthetische CRITICAL- + * Events wenn die Schwelle erreicht ist. + * + * Alle E-Mails laufen über die System-E-Mail-Konfiguration des Providers + * (genau wie Geburtstagsgrüße / Passwort-Reset). Daher gleiche Voraussetzungen. + */ +import cron from 'node-cron'; +import prisma from '../lib/prisma.js'; +import { sendEmail, SmtpCredentials } from './smtpService.js'; +import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js'; +import * as appSettingService from './appSetting.service.js'; +import { emit as emitSecurityEvent } from './securityMonitor.service.js'; +import type { SecurityEvent } from '@prisma/client'; + +interface AlertEmailParams { + subject: string; + events: SecurityEvent[]; + isDigest: boolean; +} + +interface SendResult { + success: boolean; + error?: string; +} + +function severityIcon(s: string): string { + switch (s) { + case 'CRITICAL': return '🚨'; + case 'HIGH': return '⚠️'; + case 'MEDIUM': return '🟡'; + case 'LOW': return '🟢'; + default: return 'ℹ️'; + } +} + +function eventToHtmlRow(e: SecurityEvent): string { + const ts = e.createdAt.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'medium' }); + const ip = e.ipAddress || '–'; + const who = e.userEmail || (e.userId ? `User #${e.userId}` : e.customerId ? `Kunde #${e.customerId}` : '–'); + const ep = e.endpoint || '–'; + return ` + ${ts} + ${severityIcon(e.severity)} ${e.severity} + ${e.type} + ${e.message} + ${who} + ${ip} + ${ep} + `; +} + +function buildHtmlEmail(params: AlertEmailParams): string { + const rows = params.events.map(eventToHtmlRow).join('\n'); + const heading = params.isDigest + ? `

OpenCRM Security-Digest

Übersicht der wichtigen Events der letzten Stunde:

` + : `

OpenCRM Security-Alert

Folgendes Event wurde als kritisch eingestuft:

`; + return ` +${heading} + + + + + + + + + + + + + ${rows} +
ZeitSeverityTypNachrichtWerIPEndpoint
+

Diese Mail wurde vom OpenCRM Monitoring-System gesendet. +Konfiguration: Einstellungen → Monitoring.

+`; +} + +/** + * Versendet einen Alert per E-Mail. Nutzt die System-E-Mail des Providers. + */ +export async function sendAlertEmail(toAddress: string, params: AlertEmailParams): Promise { + const sysEmail = await getSystemEmailCredentials(); + if (!sysEmail) { + return { success: false, error: 'System-E-Mail nicht konfiguriert (in Einstellungen → E-Mail-Provider)' }; + } + + const credentials: SmtpCredentials = { + host: sysEmail.smtpServer, + port: sysEmail.smtpPort, + user: sysEmail.emailAddress, + password: sysEmail.password, + encryption: sysEmail.smtpEncryption, + allowSelfSignedCerts: sysEmail.allowSelfSignedCerts, + }; + + const result = await sendEmail( + credentials, + sysEmail.emailAddress, + { + to: toAddress, + subject: params.subject, + html: buildHtmlEmail(params), + }, + { context: 'security-alert', triggeredBy: 'monitor' }, + ); + + return result.success + ? { success: true } + : { success: false, error: result.error }; +} + +/** + * Threshold-Detection: prüft ob in den letzten N Minuten verdächtige Patterns + * aufgetreten sind, die einen CRITICAL-Alert rechtfertigen. + * + * Regeln (alle pro IP): + * - >= 10 LOGIN_FAILED in 60 min → CRITICAL Brute-Force-Verdacht + * - >= 5 ACCESS_DENIED in 5 min → CRITICAL IDOR-Probing-Verdacht + * - >= 3 SSRF_BLOCKED in 60 min → CRITICAL SSRF-Probing + * - >= 3 TOKEN_REJECTED HIGH in 5 min → CRITICAL JWT-Manipulation + */ +export async function detectThresholds(): Promise { + const now = new Date(); + const fiveMinAgo = new Date(now.getTime() - 5 * 60 * 1000); + const sixtyMinAgo = new Date(now.getTime() - 60 * 60 * 1000); + + type Bucket = { + windowStart: Date; + type: 'LOGIN_FAILED' | 'ACCESS_DENIED' | 'SSRF_BLOCKED' | 'TOKEN_REJECTED'; + threshold: number; + label: string; + }; + const buckets: Bucket[] = [ + { windowStart: sixtyMinAgo, type: 'LOGIN_FAILED', threshold: 10, label: 'Brute-Force-Login-Verdacht' }, + { windowStart: fiveMinAgo, type: 'ACCESS_DENIED', threshold: 5, label: 'IDOR-Probing-Verdacht' }, + { windowStart: sixtyMinAgo, type: 'SSRF_BLOCKED', threshold: 3, label: 'SSRF-Probing-Verdacht' }, + { windowStart: fiveMinAgo, type: 'TOKEN_REJECTED', threshold: 3, label: 'JWT-Manipulations-Verdacht' }, + ]; + + for (const b of buckets) { + const grouped = await prisma.securityEvent.groupBy({ + by: ['ipAddress'], + where: { + type: b.type as any, + createdAt: { gte: b.windowStart }, + }, + _count: true, + }); + for (const g of grouped) { + if ((g._count as number) < b.threshold) continue; + // Prüfen ob wir für diese (IP+Type+Stunde) schon einen CRITICAL emittiert haben + const hourBucket = new Date(now.getTime() - (now.getTime() % (60 * 60 * 1000))); + const existing = await prisma.securityEvent.findFirst({ + where: { + type: 'SUSPICIOUS', + severity: 'CRITICAL', + ipAddress: g.ipAddress, + createdAt: { gte: hourBucket }, + }, + }); + if (existing) continue; + + await emitSecurityEvent({ + type: 'SUSPICIOUS', + severity: 'CRITICAL', + message: `${b.label}: ${g._count}× ${b.type} in ${b.windowStart === fiveMinAgo ? '5min' : '60min'} von ${g.ipAddress}`, + ipAddress: g.ipAddress, + details: { rule: b.type, count: g._count, threshold: b.threshold }, + }); + } + } +} + +/** + * Sendet pending CRITICAL-Events sofort als Einzel-Mails (debounced auf + * 1 Mail pro IP pro Stunde, damit nicht spammend). + */ +async function sendPendingCriticalAlerts(): Promise<{ sent: number; skipped: number }> { + const alertEmail = await appSettingService.getSetting('monitoringAlertEmail'); + if (!alertEmail) return { sent: 0, skipped: 0 }; + + const pending = await prisma.securityEvent.findMany({ + where: { severity: 'CRITICAL', alerted: false }, + orderBy: { createdAt: 'asc' }, + take: 50, + }); + + let sent = 0; + let skipped = 0; + for (const ev of pending) { + const result = await sendAlertEmail(alertEmail, { + subject: `[OpenCRM] 🚨 ${ev.type}: ${ev.message.substring(0, 80)}`, + events: [ev], + isDigest: false, + }); + if (result.success) { + sent++; + await prisma.securityEvent.update({ + where: { id: ev.id }, + data: { alerted: true, alertedAt: new Date() }, + }); + } else { + skipped++; + console.error(`[securityAlert] Send failed for event #${ev.id}:`, result.error); + } + } + return { sent, skipped }; +} + +/** + * Hourly-Digest: alle HIGH-Events der letzten Stunde, die noch nicht + * alert-versendet wurden, in einer einzigen Mail zusammenfassen. + */ +export async function sendDigest(opts: { force?: boolean } = {}): Promise<{ sent: boolean; eventCount: number; reason?: string }> { + const alertEmail = await appSettingService.getSetting('monitoringAlertEmail'); + if (!alertEmail) return { sent: false, eventCount: 0, reason: 'Keine Alert-E-Mail konfiguriert' }; + const enabled = await appSettingService.getSettingBool('monitoringDigestEnabled'); + if (!enabled && !opts.force) return { sent: false, eventCount: 0, reason: 'Digest deaktiviert' }; + + const lastDigestAt = await appSettingService.getSetting('monitoringLastDigestAt'); + const since = lastDigestAt ? new Date(lastDigestAt) : new Date(Date.now() - 60 * 60 * 1000); + + const events = await prisma.securityEvent.findMany({ + where: { + severity: { in: ['HIGH', 'MEDIUM'] }, + alerted: false, + createdAt: { gte: since }, + }, + orderBy: { createdAt: 'desc' }, + take: 200, + }); + + if (events.length === 0) { + await appSettingService.setSetting('monitoringLastDigestAt', new Date().toISOString()); + return { sent: false, eventCount: 0, reason: 'Keine neuen Events seit letztem Digest' }; + } + + const result = await sendAlertEmail(alertEmail, { + subject: `[OpenCRM] Security-Digest (${events.length} Events)`, + events, + isDigest: true, + }); + + if (result.success) { + await prisma.securityEvent.updateMany({ + where: { id: { in: events.map((e) => e.id) } }, + data: { alerted: true, alertedAt: new Date() }, + }); + await appSettingService.setSetting('monitoringLastDigestAt', new Date().toISOString()); + return { sent: true, eventCount: events.length }; + } + + return { sent: false, eventCount: events.length, reason: result.error }; +} + +/** + * Cron-Scheduler: + * - Jede Minute: Threshold-Detection + Sofort-Alerts für CRITICAL + * - Jede volle Stunde: Hourly-Digest (HIGH+MEDIUM) + */ +export function startSecurityMonitorScheduler(): void { + cron.schedule('* * * * *', async () => { + try { + await detectThresholds(); + await sendPendingCriticalAlerts(); + } catch (err) { + console.error('[securityAlert] minute-cron failed:', err); + } + }); + + cron.schedule('0 * * * *', async () => { + try { + await sendDigest(); + } catch (err) { + console.error('[securityAlert] hourly-digest failed:', err); + } + }); + + console.log('[securityAlert] Scheduler gestartet (1min Threshold-Check, hourly Digest)'); +} diff --git a/backend/src/services/securityMonitor.service.ts b/backend/src/services/securityMonitor.service.ts new file mode 100644 index 00000000..69f7c299 --- /dev/null +++ b/backend/src/services/securityMonitor.service.ts @@ -0,0 +1,81 @@ +/** + * Security-Monitor: zentrale `emit()`-Funktion für sicherheitsrelevante + * Events. Schreibt in die `SecurityEvent`-Tabelle (nicht im AuditLog, + * weil hier andere Anforderungen gelten: schnelles Filtern, Threshold- + * Detection, Realtime-Alerting statt forensischer Hash-Chain). + * + * Hooks für die wichtigsten Klassen: + * - LOGIN_FAILED → Login mit falschem Passwort + * - LOGIN_SUCCESS → erfolgreicher Login (informativ) + * - RATE_LIMIT_HIT → express-rate-limit hat zugeschlagen + * - ACCESS_DENIED → 403 von canAccess* (versuchter IDOR) + * - SSRF_BLOCKED → ssrfGuard hat geblockt + * - PASSWORD_RESET_REQUEST → Reset angefordert + * - PASSWORD_RESET_CONFIRM → Reset abgeschlossen + * - LOGOUT → expliziter Logout + * - TOKEN_REJECTED → JWT verify-Failure + * - PERMISSION_CHANGED → Rolle/Permission-Update + * + * Sofort-Alert für CRITICAL+HIGH-Events (wenn `monitoringAlertEmail` + * konfiguriert), sonst Sammlung im stündlichen Digest. + */ +import prisma from '../lib/prisma.js'; +import type { SecurityEventType, SecuritySeverity } from '@prisma/client'; + +export interface SecurityEventInput { + type: SecurityEventType; + severity: SecuritySeverity; + message: string; + ipAddress?: string | null; + userId?: number | null; + customerId?: number | null; + userEmail?: string | null; + endpoint?: string | null; + details?: Record; +} + +/** + * Schreibt ein SecurityEvent. Fehler beim Schreiben werden geschluckt, + * damit ein kaputtes Monitoring nicht den Login-Flow stoppt. + */ +export async function emit(event: SecurityEventInput): Promise { + try { + await prisma.securityEvent.create({ + data: { + type: event.type, + severity: event.severity, + message: event.message, + ipAddress: event.ipAddress || null, + userId: event.userId || null, + customerId: event.customerId || null, + userEmail: event.userEmail || null, + endpoint: event.endpoint || null, + details: event.details ? (event.details as any) : undefined, + }, + }); + } catch (err) { + console.error('[securityMonitor] emit failed:', err); + } +} + +/** + * Helper: aus einem Express-Request die wichtigsten Kontextfelder extrahieren. + * Funktioniert sowohl mit AuthRequest (eingeloggt) als auch mit anonymen + * Requests (Login-Versuch etc.). + */ +export function contextFromRequest(req: any): { + ipAddress: string; + userId?: number; + customerId?: number; + userEmail?: string; + endpoint: string; +} { + const user = req?.user; + return { + ipAddress: req?.ip || req?.socket?.remoteAddress || 'unknown', + userId: user?.userId, + customerId: user?.customerId, + userEmail: user?.email, + endpoint: `${req?.method || ''} ${req?.path || req?.originalUrl || ''}`.trim(), + }; +} diff --git a/backend/src/utils/accessControl.ts b/backend/src/utils/accessControl.ts index bf1c7162..725607ff 100644 --- a/backend/src/utils/accessControl.ts +++ b/backend/src/utils/accessControl.ts @@ -11,6 +11,26 @@ import { Response } from 'express'; import prisma from '../lib/prisma.js'; import * as authorizationService from '../services/authorization.service.js'; import { AuthRequest } from '../types/index.js'; +import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js'; + +/** + * Wird intern aufgerufen, wenn ein canAccess*-Check 403 zurückgibt. + * Schreibt ein SecurityEvent für Monitoring + spätere Threshold-Detection. + */ +function emitAccessDenied(req: AuthRequest, label: string, targetId: number | string): void { + const ctx = contextFromRequest(req); + emitSecurityEvent({ + type: 'ACCESS_DENIED', + severity: 'MEDIUM', + message: `Zugriff verweigert: ${label} #${targetId}`, + ipAddress: ctx.ipAddress, + userId: ctx.userId, + customerId: ctx.customerId, + userEmail: ctx.userEmail, + endpoint: ctx.endpoint, + details: { resource: label, targetId }, + }); +} /** * Prüft ob der authentifizierte User auf einen bestimmten Vertrag zugreifen darf. @@ -54,6 +74,7 @@ export async function canAccessContract( // Fremde Verträge nur mit aktiver Vollmacht const representedIds: number[] = (req.user as any).representedCustomerIds || []; if (!representedIds.includes(contract.customerId)) { + emitAccessDenied(req, 'Contract', contractId); res.status(403).json({ success: false, error: 'Kein Zugriff auf diesen Vertrag' }); return false; } @@ -63,6 +84,7 @@ export async function canAccessContract( req.user.customerId, ); if (!hasAuth) { + emitAccessDenied(req, 'Contract (Vollmacht fehlt)', contractId); res.status(403).json({ success: false, error: 'Vollmacht erforderlich' }); return false; } @@ -93,12 +115,14 @@ export async function canAccessCustomer( const representedIds: number[] = (req.user as any).representedCustomerIds || []; if (!representedIds.includes(customerId)) { + emitAccessDenied(req, 'Customer', customerId); res.status(403).json({ success: false, error: 'Kein Zugriff auf diese Kundendaten' }); return false; } const hasAuth = await authorizationService.hasAuthorization(customerId, req.user.customerId); if (!hasAuth) { + emitAccessDenied(req, 'Customer (Vollmacht fehlt)', customerId); res.status(403).json({ success: false, error: 'Vollmacht erforderlich' }); return false; } diff --git a/docs/SECURITY-HARDENING.md b/docs/SECURITY-HARDENING.md index f95d4c3d..df966944 100644 --- a/docs/SECURITY-HARDENING.md +++ b/docs/SECURITY-HARDENING.md @@ -175,6 +175,45 @@ Plus Error-Handler: `err.status` wird respektiert (413/400 statt pauschalem 500) oder Contract → `canAccessCustomer`/`canAccessContract`. Backwards- Compat-Shim für `/api/uploads/*` ruft denselben Owner-Check. +### Runde 10 – Security-Monitoring + Alerting + +Defense-in-Depth: was nicht durch Code-Härtung verhindert wurde, soll jetzt +zumindest **gesehen** werden. Ergänzt: + +- **Neues Modell `SecurityEvent`** (Prisma) mit Type/Severity/IP/User/Endpoint + + Indexen für schnelles Filter+Threshold-Detection. +- **Service `securityMonitor.service.ts`** mit zentraler `emit()`-Funktion. + Hooks an: Login (Success/Failed), Logout, Rate-Limit-Hit, IDOR-403 + (`canAccessCustomer`/`canAccessContract`), SSRF-Block, Password-Reset + (Request + Confirm), JWT-Reject (`alg=none`, expired etc.). +- **Service `securityAlert.service.ts`** mit: + - **Threshold-Detection** (jede Minute via Cron): >10 LOGIN_FAILED/h aus + gleicher IP, >5 ACCESS_DENIED/5min, >3 SSRF_BLOCKED/h, >3 TOKEN_REJECTED + HIGH/5min → erzeugt synthetische CRITICAL-Events. + - **Sofort-Alert**: CRITICAL-Events werden binnen 1 Minute per Email versendet + (debounced, max. 1× pro Stunde + IP). + - **Hourly-Digest**: HIGH+MEDIUM-Events der letzten Stunde gesammelt + in einer Mail (wenn `monitoringDigestEnabled = true`). +- **Settings-Page „Sicherheits-Monitoring"** in Einstellungen: + Alert-E-Mail-Feld, Digest-Toggle, Test-Alert-Button, Digest-jetzt-Button, + Stats-Cards pro Severity, Filter (Type/Severity/Search/IP), Pagination, + Auto-Refresh alle 30s. +- **API-Routes** unter `/api/monitoring/{events,settings,test-alert,run-digest}` + – alle hinter `settings:read` / `settings:update`. + +Live-verifiziert (1. Mai 2026): + +| Test | Resultat | +| --------------------------------------------------- | --------------------------------------------------- | +| Login-Fehlversuch | ✅ `LOW LOGIN_FAILED` Event erzeugt | +| Login-Erfolg | ✅ `INFO LOGIN_SUCCESS` Event | +| Portal-User probiert 4× fremde Customer-IDs | ✅ 4× `MEDIUM ACCESS_DENIED` Events | +| Admin SSRF-Probe (169.254.169.254) | ✅ `HIGH SSRF_BLOCKED` Event | +| 12× LOGIN_FAILED von gleicher IP innerhalb 60 min | ✅ Cron erzeugt `CRITICAL SUSPICIOUS` Event nach ≤60s | +| CRITICAL-Sofort-Alert per E-Mail | ✅ binnen 30 s zugestellt | +| Test-Alert-Button | ✅ E-Mail mit Test-Marker zugestellt | +| Hourly-Digest mit 5 Events | ✅ E-Mail mit Tabellen-Übersicht zugestellt | + ### Runde 9 – Diminishing-Returns-Runde Nichts Kritisches mehr gefunden. Liefert noch: @@ -289,7 +328,8 @@ Vor jedem Launch mit echten Tokens probieren. | `4e91d96` | 6 | Customer-List-Leak + XFF-Bypass + Auth-Toggle | | `12b9abe` | 7 | SSRF-Schutz + Logout | | `d063d67` | 8 | DNS-Rebinding + Per-File-Ownership | -| (folgt) | 9 | `npm audit fix` + Audit-Chain-Rehash + Doku | +| `c9a2b9f` | 9 | `npm audit fix` + Audit-Chain-Rehash + Doku | +| (folgt) | 10 | Security-Monitoring (SecurityEvent + Hooks + Alerts + UI) | --- diff --git a/docs/todo.md b/docs/todo.md index ff833d89..b9626fe5 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -116,7 +116,7 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung `cancellationConfirmationDate` genügen, um "gesendet vs. bestätigt" abzubilden. `ACTIVE` bleibt bis zur Bestätigung. -- [x] **🛡️ Security-Hardening vor Production-Deployment (9 Runden)** +- [x] **🛡️ Security-Hardening vor Production-Deployment (10 Runden)** - Vollständige Story inkl. aller Live-Test-Tabellen + Trade-offs: **[SECURITY-HARDENING.md](./SECURITY-HARDENING.md)** - Erste 2 Runden zusätzlich ausführlich in @@ -133,6 +133,9 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung - Runde 8: DNS-Rebinding-Schutz, Per-File-Ownership-Check - Runde 9: `npm audit fix` (8 Vulns weg), Audit-Chain-Rehash, keine neuen Critical-Findings → diminishing returns erreicht + - Runde 10: Security-Monitoring (SecurityEvent-Tabelle + Hooks an + Login/IDOR/SSRF/Reset/Logout/JWT-Reject + Threshold-Detection + + Sofort-Alert für CRITICAL + Hourly-Digest + UI in Einstellungen) - Deployment-Checkliste komplett (in HARDENING.md) - [x] **🎉 Version 1.0.0 Feinschliff: Passwort-Reset + Rate-Limiting + Auto-Geburtstagsgrüße** diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 77c65780..b29d2230 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -30,6 +30,7 @@ import DatabaseBackup from './pages/settings/DatabaseBackup'; import FactoryDefaults from './pages/settings/FactoryDefaults'; import AuditLogs from './pages/settings/AuditLogs'; import EmailLogPage from './pages/settings/EmailLogs'; +import Monitoring from './pages/settings/Monitoring'; import GDPRDashboard from './pages/settings/GDPRDashboard'; import UserList from './pages/users/UserList'; import Settings from './pages/Settings'; @@ -202,6 +203,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 2979c94c..f4c1daa8 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,7 +1,7 @@ import { Link } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import Card from '../components/ui/Card'; -import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database, Shield, FileText, FileEdit, PackageCheck } from 'lucide-react'; +import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database, Shield, ShieldAlert, FileText, FileEdit, PackageCheck } from 'lucide-react'; export default function Settings() { const { hasPermission, developerMode, setDeveloperMode } = useAuth(); @@ -238,6 +238,27 @@ export default function Settings() { )} + {hasPermission('settings:read') && ( + +
+
+ +
+
+

+ Sicherheits-Monitoring + +

+

+ Login-Fehlversuche, IDOR-Abwehr, SSRF-Blocks etc. + Alert-E-Mail-Adresse konfigurieren. +

+
+
+ + )} {hasPermission('gdpr:admin') && ( monitoringApi.getEvents({ page, limit: 50, ...filters }), + refetchInterval: 30_000, // alle 30s neu laden + }); + + const saveSettings = useMutation({ + mutationFn: () => monitoringApi.updateSettings({ alertEmail, digestEnabled }), + onSuccess: () => { + toast.success('Einstellungen gespeichert'); + queryClient.invalidateQueries({ queryKey: ['monitoring-settings'] }); + }, + onError: (e: Error) => toast.error(e.message || 'Speichern fehlgeschlagen'), + }); + + const testAlert = useMutation({ + mutationFn: () => monitoringApi.testAlert(), + onSuccess: (res) => toast.success(res.message || 'Test-Alert versendet'), + onError: (e: Error) => toast.error(e.message || 'Test fehlgeschlagen'), + }); + + const runDigest = useMutation({ + mutationFn: () => monitoringApi.runDigest(), + onSuccess: (res) => { + const r = res.data; + if (r?.sent) toast.success(`Digest mit ${r.eventCount} Events versendet`); + else toast(r?.reason || 'Kein Digest versendet', { icon: 'ℹ️' }); + }, + onError: (e: Error) => toast.error(e.message || 'Digest fehlgeschlagen'), + }); + + const events = eventsData?.data || []; + const stats = eventsData?.stats; + const pagination = eventsData?.pagination; + + return ( +
+
+ +

+ Sicherheits-Monitoring +

+

+ Sicherheitsrelevante Ereignisse + Alert-Einstellungen. +

+
+ + {/* Settings */} + +

+ Alert-Empfänger +

+
+
+ setAlertEmail(e.target.value)} + placeholder="security@deine-firma.de" + /> +

Leer lassen, um Alerts zu deaktivieren.

+
+
+ +
+
+
+ + + + {settingsData?.data?.lastDigestAt && ( + + Letzter Digest: {new Date(settingsData.data.lastDigestAt).toLocaleString('de-DE')} + + )} +
+
+ Sofort-Alert: CRITICAL-Events (z.B. Brute-Force-Verdacht) werden binnen 1 Minute per + E-Mail versendet.
+ Digest: HIGH+MEDIUM-Events werden zur vollen Stunde gesammelt verschickt (wenn aktiviert). +
+
+ + {/* Stats-Cards */} + {stats && ( +
+ {(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'] as SecuritySeverity[]).map((sev) => ( + +
c.startsWith('text-'))[0]}`}> + {severityIcon(sev)} {sev} +
+
{stats.bySeverity[sev] || 0}
+
+ ))} +
+ )} + + {/* Filter */} + +
+ { setFilters((f) => ({ ...f, severity: e.target.value as any })); setPage(1); }} + options={SEVERITY_OPTIONS} + /> + { setFilters((f) => ({ ...f, search: e.target.value })); setPage(1); }} + placeholder="z.B. admin@admin.com" + /> + { setFilters((f) => ({ ...f, ip: e.target.value })); setPage(1); }} + placeholder="z.B. 1.2.3.4" + /> +
+
+ + {/* Tabelle */} + +

Events

+ {eventsLoading ? ( +
Lade…
+ ) : events.length === 0 ? ( +
Keine Events für diese Filter.
+ ) : ( +
+ + + + + + + + + + + + + + + {events.map((e) => ( + + + + + + + + + + + ))} + +
ZeitSeverityTypNachrichtWerIPEndpointAlert
+ {new Date(e.createdAt).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'medium' })} + + + {severityIcon(e.severity)} {e.severity} + + {e.type}{e.message}{e.userEmail || (e.userId ? `User #${e.userId}` : e.customerId ? `Kunde #${e.customerId}` : '–')}{e.ipAddress || '–'}{e.endpoint || '–'}{e.alerted ? '✉️ ja' : '–'}
+
+ )} + {pagination && pagination.totalPages > 1 && ( +
+ Seite {pagination.page} von {pagination.totalPages} ({pagination.total} Einträge) +
+ + +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 8f32b3d4..cadf8d6f 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1426,6 +1426,71 @@ export interface EmailLog { sentAt: string; } +// ==================== MONITORING ==================== + +export type SecurityEventType = + | 'LOGIN_FAILED' | 'LOGIN_SUCCESS' | 'RATE_LIMIT_HIT' | 'ACCESS_DENIED' + | 'SSRF_BLOCKED' | 'PASSWORD_RESET_REQUEST' | 'PASSWORD_RESET_CONFIRM' + | 'LOGOUT' | 'TOKEN_REJECTED' | 'PERMISSION_CHANGED' | 'SUSPICIOUS'; + +export type SecuritySeverity = 'INFO' | 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'; + +export interface SecurityEvent { + id: number; + type: SecurityEventType; + severity: SecuritySeverity; + message: string; + ipAddress: string | null; + userId: number | null; + customerId: number | null; + userEmail: string | null; + endpoint: string | null; + details: Record | null; + alerted: boolean; + alertedAt: string | null; + createdAt: string; +} + +export interface MonitoringSettings { + alertEmail: string; + digestEnabled: boolean; + lastDigestAt: string | null; +} + +export const monitoringApi = { + getEvents: async (params?: { page?: number; limit?: number; type?: SecurityEventType | ''; severity?: SecuritySeverity | ''; search?: string; ip?: string; since?: string }) => { + const q = new URLSearchParams(); + if (params?.page) q.set('page', String(params.page)); + if (params?.limit) q.set('limit', String(params.limit)); + if (params?.type) q.set('type', params.type); + if (params?.severity) q.set('severity', params.severity); + if (params?.search) q.set('search', params.search); + if (params?.ip) q.set('ip', params.ip); + if (params?.since) q.set('since', params.since); + const res = await api.get & { + pagination: { page: number; limit: number; total: number; totalPages: number }; + stats: { byType: Record; bySeverity: Record }; + }>(`/monitoring/events?${q}`); + return res.data; + }, + getSettings: async () => { + const res = await api.get>('/monitoring/settings'); + return res.data; + }, + updateSettings: async (data: { alertEmail?: string; digestEnabled?: boolean }) => { + const res = await api.put>('/monitoring/settings', data); + return res.data; + }, + testAlert: async () => { + const res = await api.post>('/monitoring/test-alert'); + return res.data; + }, + runDigest: async () => { + const res = await api.post>('/monitoring/run-digest'); + return res.data; + }, +}; + export const emailLogApi = { getLogs: async (params?: { page?: number; limit?: number; success?: string; search?: string; context?: string }) => { const query = new URLSearchParams();