From edea46e342159ee4c3673de47a44b97d649a832b Mon Sep 17 00:00:00 2001 From: Deploymentcenter Bot Date: Tue, 8 Sep 2026 11:45:04 +0200 Subject: [PATCH] Security Hardening: Proaktive Kontosperren, Fail2Ban-Integration, SecurityLogger, Alerting und API-Schutz --- config/config.example.php | 5 + .../fail2ban/filter.d/deploymentcenter.conf | 11 + config/fail2ban/jail.d/deploymentcenter.local | 12 + docs/FAIL2BAN.md | 122 +++++++ public/api/setup/v1/index.php | 26 +- public/docs/changelog.json | 38 ++- public/index.php | 55 ++- public/login.php | 34 +- .../018_security_lockout_and_audit.sql | 29 ++ sql/schema.sql | 14 +- src/Core/ApiAuth.php | 28 +- src/Core/Auth.php | 316 +++++++++++++----- src/Core/RateLimiter.php | 151 +++++++++ src/Core/SecurityLogger.php | 132 ++++++++ src/Modules/License/RateLimiter.php | 76 +---- src/Modules/Notify/WebhookDispatcher.php | 37 ++ tests/server/testctl.php | 10 +- 17 files changed, 927 insertions(+), 169 deletions(-) create mode 100644 config/fail2ban/filter.d/deploymentcenter.conf create mode 100644 config/fail2ban/jail.d/deploymentcenter.local create mode 100644 docs/FAIL2BAN.md create mode 100644 sql/migrations/018_security_lockout_and_audit.sql create mode 100644 src/Core/RateLimiter.php create mode 100644 src/Core/SecurityLogger.php diff --git a/config/config.example.php b/config/config.example.php index ebbfa20..f83b4f5 100644 --- a/config/config.example.php +++ b/config/config.example.php @@ -62,6 +62,11 @@ return [ // Bewusst asymmetrisch: geprüft wird auf fremden Systemen, dort darf // der Signierschlüssel nicht liegen. 'release_private_key' => dc_env('DC_RELEASE_SIGNING_KEY', ''), + // Proaktive Kontosperren & Brute-Force-Schutz + 'max_account_attempts' => (int)dc_env('DC_MAX_ACCOUNT_ATTEMPTS', 5), + 'account_lockout_minutes' => (int)dc_env('DC_ACCOUNT_LOCKOUT_MINUTES', 15), + 'max_ip_attempts' => (int)dc_env('DC_MAX_IP_ATTEMPTS', 10), + 'use_syslog' => (bool)dc_env('DC_USE_SYSLOG', false), ], 'bugtracker' => [ // Projekt-Slug, unter dem das Deploymentcenter eigene Fehler meldet. diff --git a/config/fail2ban/filter.d/deploymentcenter.conf b/config/fail2ban/filter.d/deploymentcenter.conf new file mode 100644 index 0000000..f8e96bb --- /dev/null +++ b/config/fail2ban/filter.d/deploymentcenter.conf @@ -0,0 +1,11 @@ +# Fail2Ban Filter-Konfiguration fuer Deploymentcenter +# Pfad auf dem Server: /etc/fail2ban/filter.d/deploymentcenter.conf + +[Definition] +# Erkennt fehlgeschlagene WebUI-Logins, Kontosperren, ungueltige API-Tokens und Rate-Limit-Verstoesse +failregex = ^.*\[SECURITY\] \[(?:AUTH_FAIL|ACCOUNT_LOCKED|IP_LOCKED|API_AUTH_FAIL|API_BRUTE_FORCE|RATE_LIMITED)\] ip=(?: |$).*$ + +ignoreregex = + +# Zeitstempel-Muster fuer [YYYY-MM-DD HH:MM:SS] +datepattern = ^\[%%Y-%%m-%%d %%H:%%M:%%S\] diff --git a/config/fail2ban/jail.d/deploymentcenter.local b/config/fail2ban/jail.d/deploymentcenter.local new file mode 100644 index 0000000..997314c --- /dev/null +++ b/config/fail2ban/jail.d/deploymentcenter.local @@ -0,0 +1,12 @@ +# Fail2Ban Jail-Konfiguration fuer Deploymentcenter +# Pfad auf dem Server: /etc/fail2ban/jail.d/deploymentcenter.local + +[deploymentcenter] +enabled = true +port = http,https +filter = deploymentcenter +# Pfad zur persistenten Sicherheits-Logdatei des Deploymentcenters anpassen: +logpath = /var/www/deploymentcenter/var/log/security.log +maxretry = 5 +findtime = 600 +bantime = 3600 diff --git a/docs/FAIL2BAN.md b/docs/FAIL2BAN.md new file mode 100644 index 0000000..d5ba2e6 --- /dev/null +++ b/docs/FAIL2BAN.md @@ -0,0 +1,122 @@ +# Fail2Ban Integration für das Deploymentcenter + +Das Deploymentcenter führt alle sicherheitsrelevanten Vorfälle (fehlgeschlagene WebUI-Logins, Kontosperren, IP-Drosselungen, ungültige API-Tokens und Rate-Limit-Verstöße) in einem dedizierten, maschinenlesbaren Sicherheitslog: + +``` +var/log/security.log +``` + +Im Gegensatz zu den anwendungsinternen Logs (`var/log/dc-YYYY-MM-DD.log`) behält diese Datei einen **stabilen Dateinamen**, sodass Log-Watcher wie **Fail2Ban** sie unterbrechungsfrei auswerten können. + +--- + +## 1. Logformat + +Jeder Eintrag folgt dem festen Schema: + +```text +[YYYY-MM-DD HH:MM:SS] [SECURITY] [EVENT_TYPE] ip= key="val" ... +``` + +### Typische Ereignisse: + +| Event-Typ | Bedeutung | Beispielzeile | +|---|---|---| +| `AUTH_FAIL` | Falsches Passwort oder Benutzer existiert nicht | `[2026-09-08 11:30:00] [SECURITY] [AUTH_FAIL] ip=198.51.100.2 user="admin" reason="invalid_password" failures=1` | +| `ACCOUNT_LOCKED` | Kontosperre nach $N$ Fehlversuchen | `[2026-09-08 11:30:15] [SECURITY] [ACCOUNT_LOCKED] ip=198.51.100.2 user="admin" duration=900 reason="too_many_failed_logins"` | +| `IP_LOCKED` | IP-Sperre nach 10 Fehlversuchen | `[2026-09-08 11:30:20] [SECURITY] [IP_LOCKED] ip=198.51.100.2 failures=10 duration=900` | +| `API_AUTH_FAIL` | Ungültiges Bearer-/Agent-Token übermittelt | `[2026-09-08 11:30:25] [SECURITY] [API_AUTH_FAIL] ip=198.51.100.2 path="/api/bugtracker/v1/projects" reason="token_rejected"` | +| `API_BRUTE_FORCE` | Schwellenwert ungültiger Tokens pro IP überschritten | `[2026-09-08 11:30:30] [SECURITY] [API_BRUTE_FORCE] ip=198.51.100.2 path="/api/bugtracker/v1/projects" reason="too_many_invalid_tokens"` | +| `RATE_LIMITED` | Endpunkt-Drosselung ausgelöst | `[2026-09-08 11:30:35] [SECURITY] [RATE_LIMITED] ip=198.51.100.2 bucket="setup_login" limit=10 count=11` | + +--- + +## 2. Server-Installation & Konfiguration + +### Schritt 1: Filter-Definition hinterlegen + +Kopiere die Filterdatei nach `/etc/fail2ban/filter.d/deploymentcenter.conf`: + +```bash +sudo cp config/fail2ban/filter.d/deploymentcenter.conf /etc/fail2ban/filter.d/deploymentcenter.conf +``` + +### Schritt 2: Jail aktivieren + +Kopiere die Jail-Vorlage nach `/etc/fail2ban/jail.d/deploymentcenter.local`: + +```bash +sudo cp config/fail2ban/jail.d/deploymentcenter.local /etc/fail2ban/jail.d/deploymentcenter.local +``` + +Passe in `/etc/fail2ban/jail.d/deploymentcenter.local` den Pfad zur Logdatei an deine Webroot an: + +```ini +[deploymentcenter] +enabled = true +port = http,https +filter = deploymentcenter +logpath = /var/www/deploymentcenter/var/log/security.log +maxretry = 5 +findtime = 600 +bantime = 3600 +``` + +### Schritt 3: Filter testen + +Mit `fail2ban-regex` kann die Erkennung direkt gegen das Log geprüft werden: + +```bash +sudo fail2ban-regex /var/www/deploymentcenter/var/log/security.log /etc/fail2ban/filter.d/deploymentcenter.conf +``` + +Die Ausgabe sollte die erkannten Treffer und die extrahierten IP-Adressen anzeigen: + +```text +Lines: 12 lines, 0 ignored, 12 matched, 0 missed +``` + +### Schritt 4: Fail2Ban neu laden + +```bash +sudo fail2ban-client reload +sudo fail2ban-client status deploymentcenter +``` + +--- + +## 3. Logrotate für `security.log` + +Damit `security.log` nicht unbegrenzt wächst, wird eine Logrotate-Konfiguration empfohlen: + +`/etc/logrotate.d/deploymentcenter-security`: + +```text +/var/www/deploymentcenter/var/log/security.log { + weekly + rotate 12 + missingok + notifempty + compress + delaycompress + copytruncate +} +``` + +> **Hinweis:** `copytruncate` erlaubt das Rotieren, ohne dass der PHP-Prozess neu gestartet werden muss oder Dateideskriptoren verloren gehen. + +--- + +## 4. Manuelle Entsperrung (Unban) + +Wurde eine IP fälschlicherweise gebannt, kann sie administrativ freigegeben werden: + +```bash +# Aktuellen Status und gebannte IPs ansehen: +sudo fail2ban-client status deploymentcenter + +# IP freigeben: +sudo fail2ban-client set deploymentcenter unbanip 198.51.100.2 +``` + +Parallel dazu kann ein gesperrtes Benutzerkonto in der Verwaltungsoberfläche des Deploymentcenters unter **System → Benutzer** jederzeit mit einem Klick auf **🔓 Entsperren** wieder freigeschaltet werden. diff --git a/public/api/setup/v1/index.php b/public/api/setup/v1/index.php index 471b021..a35e190 100644 --- a/public/api/setup/v1/index.php +++ b/public/api/setup/v1/index.php @@ -69,17 +69,33 @@ switch (resolveSetupAction()) { Http::fail(400, 'missing_credentials', 'Benutzername und Passwort werden benoetigt.'); } - $lockout = Auth::lockoutSeconds($db, Http::clientIp()); - if ($lockout > 0) { + $ipLockout = Auth::lockoutSeconds($db, Http::clientIp()); + if ($ipLockout > 0) { Http::fail(429, 'locked_out', sprintf( - 'Zu viele fehlgeschlagene Anmeldungen. Bitte in %d Minute(n) erneut versuchen.', - (int)ceil($lockout / 60) - )); + 'Zu viele fehlgeschlagene Anmeldungen von diesem Netzwerk. Bitte in %d Minute(n) erneut versuchen.', + (int)ceil($ipLockout / 60) + ), null, ['retry_after_seconds' => $ipLockout]); + } + + $accLockout = Auth::accountLockoutSeconds($db, $username); + if ($accLockout > 0) { + Http::fail(429, 'account_locked', sprintf( + 'Dieses Benutzerkonto ist aufgrund zu vieler Fehlversuche vorübergehend gesperrt. Bitte in %d Minute(n) erneut versuchen.', + (int)ceil($accLockout / 60) + ), null, ['retry_after_seconds' => $accLockout]); } $user = Auth::verifyCredentials($db, $username, $password); if ($user === null) { + $postAccLockout = Auth::accountLockoutSeconds($db, $username); + if ($postAccLockout > 0) { + Http::fail(429, 'account_locked', sprintf( + 'Zu viele Fehlversuche. Dieses Benutzerkonto wurde für %d Minute(n) gesperrt.', + (int)ceil($postAccLockout / 60) + ), null, ['retry_after_seconds' => $postAccLockout]); + } + // Bewusst dieselbe Meldung fuer falschen Benutzer, falsches // Passwort und deaktiviertes Konto. Http::fail(401, 'invalid_credentials', 'Ungueltige Anmeldedaten.'); diff --git a/public/docs/changelog.json b/public/docs/changelog.json index 83c0815..4159974 100644 --- a/public/docs/changelog.json +++ b/public/docs/changelog.json @@ -1,8 +1,44 @@ { "_comment": "Einzige Quelle der Wahrheit fuer den Changelog. Wird von /docs/changelog.php gerendert und von GET /api/updateservice/v1/changelog?since=X ausgeliefert. Neue Eintraege oben einfuegen.", "schema": 1, - "current": "2.8.1", + "current": "2.8.2", "versions": [ + { + "version": "2.8.2", + "date": "2026-09-08", + "summary": "Sicherheits-Hardening: Proaktive Kontosperren nach 5 Fehlversuchen, Fail2Ban-Integration mit standardisiertem SecurityLogger, Rocket.Chat- und Webhook-Alarmierung bei Angriffen und Token-Brute-Force-Schutz für die API.", + "actionRequired": true, + "changes": [ + { + "area": "auth", + "breaking": false, + "title": "Proaktive Accountsperren nach 5 Fehlversuchen", + "text": "dc_users fuehrt jetzt failed_login_count und locked_until (Migration 018). Nach fuenf aufeinanderfolgenden Fehlversuchen wird das Konto fuer 15 Minuten gesperrt. Gilt fuer das WebUI und die Setup-API. Erfolgreicher Login setzt den Zaehler zurueck; Administratoren koennen Konten im WebUI unter System -> Benutzer mit einem Klick entsperren.", + "action": null + }, + { + "area": "security", + "breaking": false, + "title": "Fail2Ban-Standardisierung und dedizierter SecurityLogger", + "text": "Sicherheitsereignisse (AUTH_FAIL, ACCOUNT_LOCKED, IP_LOCKED, API_AUTH_FAIL, API_BRUTE_FORCE, RATE_LIMITED) werden im standardisierten Format in var/log/security.log geschrieben. Vorkonfigurierte Filter- und Jail-Dateien sowie Dokumentation liegen unter config/fail2ban/ und docs/FAIL2BAN.md bereit.", + "action": "Auf dem Host-Server den Fail2Ban-Filter deploymentcenter.conf und die Jail hinterlegen." + }, + { + "area": "notify", + "breaking": false, + "title": "Rocket.Chat- und Webhook-Alarmierung bei Angriffen", + "text": "Wird ein Konto oder eine IP gesperrt oder ein Brute-Force-Angriff auf die API erkannt, erfolgt sofort eine Benachrichtigung im Rocket.Chat Alarm-Kanal (#DC-Alerts) sowie ueber das Webhook-Event security.lockout bzw. security.attack. Ein 15-Minuten-Cooldown verhindert Alert-Spam.", + "action": null + }, + { + "area": "api", + "breaking": false, + "title": "API-Hardening und zentraler Core RateLimiter", + "text": "ApiAuth drosselt wiederholte ungueltige Token-Uebermittlungen pro IP (HTTP 429 bei >20 Fehlern in 5 Minuten). Ein universeller Core\\RateLimiter sichert Endpunkte ueber die neue Tabelle dc_rate_limits ab.", + "action": null + } + ] + }, { "version": "2.8.1", "date": "2026-08-31", diff --git a/public/index.php b/public/index.php index fc54cf3..d1c8e86 100644 --- a/public/index.php +++ b/public/index.php @@ -1613,6 +1613,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { dc_redirect('#sub-system-users'); } + case 'unlock_user': { + $userId = (int)($_POST['user_id'] ?? 0); + if (Auth::unlockAccount($pdo, $userId)) { + dc_flash('Benutzerkonto wurde erfolgreich entsperrt.'); + } else { + dc_flash('Konto konnte nicht entsperrt werden oder existiert nicht.', 'danger'); + } + dc_redirect('#sub-system-users'); + } + default: dc_flash('Unbekannte Aktion.', 'danger'); dc_redirect(); @@ -1869,12 +1879,19 @@ $releases = $updateManager->getReleases(); // mitgelesen - er hat in der Ansicht nichts verloren. try { $users = $pdo->query(' - SELECT id, username, role, disabled, last_login_at, created_at + SELECT id, username, role, disabled, failed_login_count, locked_until, last_login_at, created_at FROM dc_users ORDER BY role ASC, username ASC ')->fetchAll() ?: []; } catch (Throwable $e) { - // Vor Migration 012 fehlen role, disabled und last_login_at. - $users = $pdo->query('SELECT id, username, created_at FROM dc_users ORDER BY username ASC')->fetchAll() ?: []; + try { + $users = $pdo->query(' + SELECT id, username, role, disabled, last_login_at, created_at + FROM dc_users ORDER BY role ASC, username ASC + ')->fetchAll() ?: []; + } catch (Throwable $e2) { + // Vor Migration 012 fehlen role, disabled und last_login_at. + $users = $pdo->query('SELECT id, username, created_at FROM dc_users ORDER BY username ASC')->fetchAll() ?: []; + } } $usersSchemaReady = $users === [] || array_key_exists('role', $users[0]); @@ -6285,8 +6302,11 @@ Antwort: $uRole = (string)($u['role'] ?? 'admin'); $uDisabled = (int)($u['disabled'] ?? 0) === 1; $isSelf = (int)$u['id'] === Auth::userId(); + $isLocked = !empty($u['locked_until']) && strtotime((string)$u['locked_until'] . ' UTC') > time(); + $lockMins = $isLocked ? (int)ceil((strtotime((string)$u['locked_until'] . ' UTC') - time()) / 60) : 0; + $failCount = (int)($u['failed_login_count'] ?? 0); ?> - > + > Sie @@ -6296,13 +6316,38 @@ Antwort: - DEAKTIVIERT' : 'aktiv' ?> + + + GESPERRT (noch m) + + DEAKTIVIERT + + aktiv + + 0 && !$isLocked): ?> +
+ Fehlversuch(e) +
+ +
+ +
+ + + + + +
+ +
diff --git a/public/login.php b/public/login.php index 90bfb6d..e455069 100644 --- a/public/login.php +++ b/public/login.php @@ -31,20 +31,40 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } else { try { $pdo = Db::init(); - $lockoutSeconds = Auth::lockoutSeconds($pdo, Http::clientIp()); + $ipLockout = Auth::lockoutSeconds($pdo, Http::clientIp()); + $accLockout = Auth::accountLockoutSeconds($pdo, $username); - if ($lockoutSeconds > 0) { + if ($ipLockout > 0) { $error = sprintf( - 'Zu viele fehlgeschlagene Anmeldeversuche. Bitte in %d Minute(n) erneut versuchen.', - (int)ceil($lockoutSeconds / 60) + 'Zu viele fehlgeschlagene Anmeldeversuche von diesem Netzwerk. Bitte in %d Minute(n) erneut versuchen.', + (int)ceil($ipLockout / 60) + ); + } elseif ($accLockout > 0) { + $error = sprintf( + 'Dieses Benutzerkonto ist aufgrund zu vieler Fehlversuche vorübergehend gesperrt. Bitte in %d Minute(n) erneut versuchen.', + (int)ceil($accLockout / 60) ); } elseif (Auth::login($pdo, $username, $password)) { header('Location: /index.php'); exit; } else { - // Bewusst dieselbe Meldung fuer falschen Benutzer und falsches - // Passwort - sonst laesst sich herausfinden, welche Konten es gibt. - $error = 'Ungueltige Anmeldedaten.'; + // Pruefen, ob das Konto oder die IP durch diesen Fehlversuch soeben gesperrt wurde + $newAccLockout = Auth::accountLockoutSeconds($pdo, $username); + $newIpLockout = Auth::lockoutSeconds($pdo, Http::clientIp()); + + if ($newAccLockout > 0) { + $error = sprintf( + 'Zu viele Fehlversuche. Dieses Benutzerkonto wurde für %d Minute(n) gesperrt.', + (int)ceil($newAccLockout / 60) + ); + } elseif ($newIpLockout > 0) { + $error = sprintf( + 'Zu viele Fehlversuche. Ihre IP-Adresse wurde für %d Minute(n) gesperrt.', + (int)ceil($newIpLockout / 60) + ); + } else { + $error = 'Ungueltige Anmeldedaten.'; + } } } catch (\Throwable $e) { // Die urspruengliche Fassung gab hier die rohe PDO-Meldung samt diff --git a/sql/migrations/018_security_lockout_and_audit.sql b/sql/migrations/018_security_lockout_and_audit.sql new file mode 100644 index 0000000..cf462e0 --- /dev/null +++ b/sql/migrations/018_security_lockout_and_audit.sql @@ -0,0 +1,29 @@ +-- Migration 018: Proaktive Kontosperren, Sicherheits-Audit und zentrales Rate-Limiting +-- +-- Additive Migration. Der Migrator toleriert 1050/1060/1061/1062/1091 ("existiert bereits"). + +-- --------------------------------------------------------------------------- +-- 1. Kontosperren in dc_users +-- --------------------------------------------------------------------------- +-- failed_login_count: Zaehlt konsekutive Fehlversuche fuer dieses Konto. +-- locked_until: Zeitpunkt (UTC), bis zu dem das Konto gesperrt ist. +ALTER TABLE dc_users + ADD COLUMN failed_login_count INT NOT NULL DEFAULT 0 AFTER disabled; + +ALTER TABLE dc_users + ADD COLUMN locked_until DATETIME NULL AFTER failed_login_count; + +ALTER TABLE dc_users + ADD KEY ix_users_locked (locked_until); + +-- --------------------------------------------------------------------------- +-- 2. Zentrales Rate-Limiting fuer alle API-Endpunkte und Auth-Wege +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS dc_rate_limits ( + ip VARBINARY(16) NOT NULL, + bucket VARCHAR(64) NOT NULL DEFAULT 'default', + window_start DATETIME NOT NULL, + request_count INT NOT NULL DEFAULT 1, + PRIMARY KEY (ip, bucket, window_start), + KEY ix_rate_limits_window (window_start) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/sql/schema.sql b/sql/schema.sql index 641751b..fbd877f 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -14,11 +14,14 @@ CREATE TABLE IF NOT EXISTS dc_users ( role ENUM('admin','installer') NOT NULL DEFAULT 'admin', last_login_at DATETIME NULL, disabled TINYINT(1) NOT NULL DEFAULT 0, + failed_login_count INT NOT NULL DEFAULT 0, + locked_until DATETIME NULL, -- Vorgesehen, aber noch nicht ausgewertet: es gibt bislang keine -- TOTP-Pruefung im Anmeldeweg. totp_secret VARCHAR(64) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - KEY ix_users_role (role) + KEY ix_users_role (role), + KEY ix_users_locked (locked_until) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE IF NOT EXISTS dc_settings ( @@ -26,6 +29,15 @@ CREATE TABLE IF NOT EXISTS dc_settings ( svalue TEXT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS dc_rate_limits ( + ip VARBINARY(16) NOT NULL, + bucket VARCHAR(64) NOT NULL DEFAULT 'default', + window_start DATETIME NOT NULL, + request_count INT NOT NULL DEFAULT 1, + PRIMARY KEY (ip, bucket, window_start), + KEY ix_rate_limits_window (window_start) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- Core Projects Table (Shared across Lizenzen, Watchdog, UpdateService) CREATE TABLE IF NOT EXISTS dc_projects ( id INT AUTO_INCREMENT PRIMARY KEY, diff --git a/src/Core/ApiAuth.php b/src/Core/ApiAuth.php index 907f4f9..eed857e 100644 --- a/src/Core/ApiAuth.php +++ b/src/Core/ApiAuth.php @@ -57,6 +57,14 @@ final class ApiAuth ?string $environment = null, bool $allowSession = true ): ?array { + // 0. IP-Sperre pruefen + $ip = Http::clientIp(); + $ipLockout = Auth::lockoutSeconds($db, $ip); + if ($ipLockout > 0) { + SecurityLogger::ipLocked($ip, (int)Config::get('security.max_ip_attempts', Auth::DEFAULT_MAX_IP_ATTEMPTS), $ipLockout, ['context' => 'api']); + Http::fail(429, 'locked_out', 'IP-Adresse aufgrund wiederholter Fehlversuche vorübergehend gesperrt.', null, ['retry_after_seconds' => $ipLockout]); + } + $presented = Http::bearerToken(); // 1. Shared Key @@ -101,9 +109,27 @@ final class ApiAuth ]; } + SecurityLogger::apiAuthFail($ip, Http::path(), 'token_rejected', ['scope' => $scope]); + + // Drosselung bei wiederholtem Vorlegen ungueltiger Tokens + $failLimiter = new RateLimiter($db, 20, 300, 'api_token_failures'); + if (!$failLimiter->check($ip)) { + SecurityLogger::log('API_BRUTE_FORCE', $ip, [ + 'path' => Http::path(), + 'reason' => 'too_many_invalid_tokens', + ]); + Auth::triggerSecurityAlert($db, 'security.attack', [ + 'ip' => $ip, + 'details' => 'Mehr als 20 ungültige API-Tokens innerhalb von 5 Minuten übermittelt.', + 'target' => Http::path(), + 'type' => 'API-Token-Brute-Force', + ]); + Http::fail(429, 'rate_limited', 'Zu viele fehlerhafte Authentifizierungsversuche. Bitte 5 Minuten warten.'); + } + Logger::warning('Token abgelehnt', [ 'scope' => $scope, - 'ip' => Http::clientIp(), + 'ip' => $ip, 'path' => Http::path(), ]); } diff --git a/src/Core/Auth.php b/src/Core/Auth.php index 7de128d..f1c4faf 100644 --- a/src/Core/Auth.php +++ b/src/Core/Auth.php @@ -4,16 +4,19 @@ declare(strict_types=1); namespace Deploymentcenter\Core; +use Deploymentcenter\Modules\Notify\WebhookDispatcher; use PDO; /** - * Session- und Anmeldeverwaltung fuer das WebUI. + * Session- und Anmeldeverwaltung fuer das WebUI und die Setup-API. * - * Haerteung gegenueber der Erstfassung: + * Sicherheitsfunktionen: * - Session-Cookie mit HttpOnly, SameSite=Lax und Secure (bei HTTPS) * - session_regenerate_id() nach erfolgreicher Anmeldung (Session Fixation) * - Leerlauf- und Absolut-Timeout - * - Anmeldeversuche werden gezaehlt und pro IP gedrosselt + * - Drosselung und Sperre nach IP (Fail2Ban-kompatibel ueber SecurityLogger) + * - Proaktive zeitbasierte Kontosperre nach N Fehlversuchen (pro Benutzer) + * - Alarmierung bei Angriffen (Rocket.Chat / Webhooks mit Flut-Schutz) */ final class Auth { @@ -23,11 +26,17 @@ final class Auth /** Absolutes Session-Maximum in Sekunden (12 Stunden). */ private const ABSOLUTE_TIMEOUT = 43200; - /** Fehlversuche pro IP, bevor gesperrt wird. */ - private const MAX_ATTEMPTS = 10; + /** Fehlversuche pro IP, bevor IP-Sperre greift. */ + public const DEFAULT_MAX_IP_ATTEMPTS = 10; - /** Sperrfenster in Sekunden. */ - private const LOCKOUT_WINDOW = 900; + /** IP-Sperrfenster in Sekunden (15 Minuten). */ + public const DEFAULT_IP_LOCKOUT_WINDOW = 900; + + /** Fehlversuche pro Benutzerkonto, bevor das Konto gesperrt wird. */ + public const DEFAULT_MAX_ACCOUNT_ATTEMPTS = 5; + + /** Kontosperrdauer in Sekunden (15 Minuten). */ + public const DEFAULT_ACCOUNT_LOCKOUT_SECONDS = 900; public static function startSession(): void { @@ -84,13 +93,95 @@ final class Auth } /** - * Prueft Zugangsdaten, ohne eine Session anzufassen. - * - * Getrennt von login(), weil der Setup-Weg dieselbe Pruefung braucht, - * aber ein kurzlebiges Token statt eines Session-Cookies ausstellt. - * Drosselung, Timing-Angleichung und Rehash gelten dort genauso - sie - * hier zu wiederholen hiesse, zwei Anmeldewege mit zwei Haertungsgraden - * zu haben. + * Verbleibende Kontosperrzeit in Sekunden, oder 0 wenn nicht gesperrt. + */ + public static function accountLockoutSeconds(PDO $db, string $username): int + { + $username = trim($username); + if ($username === '') { + return 0; + } + + try { + $stmt = $db->prepare('SELECT locked_until FROM dc_users WHERE username = :u LIMIT 1'); + $stmt->execute([':u' => $username]); + $lockedUntil = $stmt->fetchColumn(); + + if ($lockedUntil === false || $lockedUntil === null || $lockedUntil === '') { + return 0; + } + + $ts = strtotime((string)$lockedUntil . ' UTC'); + if ($ts === false) { + return 0; + } + + $diff = $ts - time(); + return $diff > 0 ? $diff : 0; + } catch (\Throwable $e) { + // Spalte fehlt eventuell vor Migration 018 + return 0; + } + } + + /** + * Verbleibende IP-Sperrzeit in Sekunden, oder 0 wenn nicht gesperrt. + */ + public static function lockoutSeconds(PDO $db, string $ip): int + { + $maxAttempts = (int)Config::get('security.max_ip_attempts', self::DEFAULT_MAX_IP_ATTEMPTS); + $window = (int)Config::get('security.ip_lockout_window', self::DEFAULT_IP_LOCKOUT_WINDOW); + + try { + $stmt = $db->prepare(' + SELECT COUNT(*) AS failures, MAX(attempted_at) AS last_attempt + FROM dc_login_attempts + WHERE ip = :ip + AND success = 0 + AND attempted_at > (UTC_TIMESTAMP() - INTERVAL ' . $window . ' SECOND) + '); + $stmt->execute([':ip' => self::packIp($ip)]); + $row = $stmt->fetch(); + + if (!is_array($row) || (int)$row['failures'] < $maxAttempts) { + return 0; + } + + $last = isset($row['last_attempt']) ? strtotime((string)$row['last_attempt'] . ' UTC') : false; + if ($last === false) { + return $window; + } + + $remaining = $window - (time() - $last); + return $remaining > 0 ? $remaining : 0; + } catch (\Throwable $e) { + Logger::warning('IP-Lockout-Pruefung nicht moeglich', ['error' => $e->getMessage()]); + return 0; + } + } + + /** + * Entsperrt ein Benutzerkonto administrativ. + */ + public static function unlockAccount(PDO $db, int $userId): bool + { + try { + $stmt = $db->prepare('UPDATE dc_users SET failed_login_count = 0, locked_until = NULL WHERE id = :id'); + $stmt->execute([':id' => $userId]); + $affected = $stmt->rowCount(); + + if ($affected > 0) { + Logger::info('Benutzerkonto administrativ entsperrt', ['user_id' => $userId]); + } + return $affected > 0; + } catch (\Throwable $e) { + Logger::error('Entsperren fehlgeschlagen', ['error' => $e->getMessage()]); + return false; + } + } + + /** + * Prueft Zugangsdaten unter Beachtung von IP- und Account-Sperren. * * @return array|null Der Benutzerdatensatz, oder null */ @@ -98,8 +189,17 @@ final class Auth { $ip = Http::clientIp(); - if (self::isLockedOut($db, $ip)) { - Logger::warning('Anmeldung gesperrt (zu viele Fehlversuche)', ['ip' => $ip, 'username' => $username]); + // 1. IP-Sperre pruefen + $ipLockout = self::lockoutSeconds($db, $ip); + if ($ipLockout > 0) { + SecurityLogger::ipLocked($ip, (int)Config::get('security.max_ip_attempts', self::DEFAULT_MAX_IP_ATTEMPTS), $ipLockout, ['user' => $username]); + return null; + } + + // 2. Account-Sperre pruefen + $accLockout = self::accountLockoutSeconds($db, $username); + if ($accLockout > 0) { + SecurityLogger::accountLocked($ip, $username, $accLockout); return null; } @@ -108,29 +208,55 @@ final class Auth $user = $stmt->fetch(); $found = is_array($user) && isset($user['password_hash']); - // Auch ohne Treffer wird ein Hash berechnet, damit die Antwortzeit - // keinen Rueckschluss auf die Existenz des Kontos erlaubt. $hash = $found ? (string)$user['password_hash'] : self::dummyHash(); $verified = password_verify($password, $hash); if (!$verified || !$found) { self::recordAttempt($db, $ip, $username, false); + + if ($found) { + // Konsekutive Fehlversuche fuer dieses Konto hochzaehlen + self::handleAccountFailedAttempt($db, $user, $ip, $username); + } else { + SecurityLogger::authFail($ip, $username, 'user_not_found'); + } + + // Pruefen, ob die IP dadurch soeben gesperrt wurde + $newIpLockout = self::lockoutSeconds($db, $ip); + if ($newIpLockout > 0) { + SecurityLogger::ipLocked($ip, (int)Config::get('security.max_ip_attempts', self::DEFAULT_MAX_IP_ATTEMPTS), $newIpLockout); + self::triggerSecurityAlert($db, 'security.attack', [ + 'ip' => $ip, + 'details' => sprintf( + 'IP nach %d Fehlversuchen fuer %d Minuten gesperrt.', + (int)Config::get('security.max_ip_attempts', self::DEFAULT_MAX_IP_ATTEMPTS), + (int)ceil($newIpLockout / 60) + ), + 'target' => 'WebUI / Login', + 'type' => 'IP-Sperre', + ]); + } + return null; } - // Ein deaktiviertes Konto bleibt bestehen, damit Protokolle weiter - // darauf verweisen koennen - anmelden darf es sich nicht. + // Deaktiviertes Konto if ((int)($user['disabled'] ?? 0) === 1) { self::recordAttempt($db, $ip, $username, false); - Logger::warning('Anmeldung eines deaktivierten Kontos abgelehnt', [ - 'username' => $username, - 'ip' => $ip, - ]); + SecurityLogger::authFail($ip, $username, 'account_disabled'); return null; } - // Passwort-Hash bei Bedarf auf das aktuelle Verfahren heben. + // Erfolgreicher Login: Fehlversuche fuer Konto und IP zuruecksetzen + try { + $reset = $db->prepare('UPDATE dc_users SET failed_login_count = 0, locked_until = NULL WHERE id = :id'); + $reset->execute([':id' => $user['id']]); + } catch (\Throwable $e) { + // Spalten fehlen evtl. vor Migration + } + + // Passwort-Hash bei Bedarf auf das aktuelle Verfahren heben if (password_needs_rehash($hash, PASSWORD_DEFAULT)) { $upd = $db->prepare('UPDATE dc_users SET password_hash = :h WHERE id = :id'); $upd->execute([':h' => password_hash($password, PASSWORD_DEFAULT), ':id' => $user['id']]); @@ -144,10 +270,6 @@ final class Auth /** * Rolle eines Datensatzes aus dc_users. - * - * Faellt auf 'admin' zurueck, solange Migration 012 nicht gelaufen ist - - * sonst waere nach dem Einspielen des Codes und vor der Migration niemand - * mehr anmeldeberechtigt. */ public static function roleOf(?array $user): string { @@ -168,11 +290,9 @@ final class Auth return false; } - // Ein Installationskonto hat in der Verwaltungsoberflaeche nichts zu - // suchen. Waere die Anmeldung hier erlaubt, brauchte man das Konto - // gar nicht zu trennen: wer es auf einem Zielsystem eingibt, haette - // damit auch Zugriff auf Tokens, Lizenzen und Monitore. + // Ein Installationskonto hat in der Verwaltungsoberflaeche nichts zu suchen if (self::roleOf($user) === 'installer') { + SecurityLogger::authFail(Http::clientIp(), $username, 'installer_role_not_allowed_for_webui'); Logger::warning('WebUI-Anmeldung eines Installationskontos abgelehnt', [ 'username' => $username, 'ip' => Http::clientIp(), @@ -193,14 +313,96 @@ final class Auth return true; } + private static function handleAccountFailedAttempt(PDO $db, array $user, string $ip, string $username): void + { + $maxAttempts = (int)Config::get('security.max_account_attempts', self::DEFAULT_MAX_ACCOUNT_ATTEMPTS); + $lockoutMinutes = (int)Config::get('security.account_lockout_minutes', 15); + $currentFailures = (int)($user['failed_login_count'] ?? 0) + 1; + + try { + if ($currentFailures >= $maxAttempts) { + $lockedUntil = gmdate('Y-m-d H:i:s', time() + ($lockoutMinutes * 60)); + $stmt = $db->prepare(' + UPDATE dc_users + SET failed_login_count = :count, locked_until = :locked + WHERE id = :id + '); + $stmt->execute([ + ':count' => $currentFailures, + ':locked' => $lockedUntil, + ':id' => $user['id'], + ]); + + SecurityLogger::accountLocked($ip, $username, $lockoutMinutes * 60, [ + 'failures' => $currentFailures, + ]); + + self::triggerSecurityAlert($db, 'security.lockout', [ + 'username' => $username, + 'ip' => $ip, + 'duration_minutes' => $lockoutMinutes, + ]); + } else { + $stmt = $db->prepare(' + UPDATE dc_users + SET failed_login_count = :count + WHERE id = :id + '); + $stmt->execute([ + ':count' => $currentFailures, + ':id' => $user['id'], + ]); + + SecurityLogger::authFail($ip, $username, 'invalid_password', [ + 'failures' => $currentFailures, + 'remaining_attempts' => max(0, $maxAttempts - $currentFailures), + ]); + } + } catch (\Throwable $e) { + SecurityLogger::authFail($ip, $username, 'invalid_password'); + } + } + + public static function triggerSecurityAlert(PDO $db, string $event, array $payload): void + { + $target = (string)($payload['username'] ?? $payload['ip'] ?? 'general'); + $cooldownKey = 'sec_cooldown_' . substr(hash('sha256', $event . ':' . $target), 0, 16); + + try { + $stmt = $db->prepare('SELECT svalue FROM dc_settings WHERE skey = :k LIMIT 1'); + $stmt->execute([':k' => $cooldownKey]); + $lastAlert = $stmt->fetchColumn(); + + if ($lastAlert !== false && $lastAlert !== null && is_numeric($lastAlert)) { + $elapsed = time() - (int)$lastAlert; + if ($elapsed < 900) { // 15 Minuten Alarmierungs-Sperre fuer selbes Ziel + return; + } + } + + $upd = $db->prepare(' + INSERT INTO dc_settings (skey, svalue) + VALUES (:k, :v) + ON DUPLICATE KEY UPDATE svalue = VALUES(svalue) + '); + $upd->execute([':k' => $cooldownKey, ':v' => (string)time()]); + } catch (\Throwable $e) { + // Bei Tabellenfehler fortfahren + } + + try { + WebhookDispatcher::dispatch($db, $event, $payload); + } catch (\Throwable $e) { + Logger::error('Sicherheits-Alarmierung fehlgeschlagen', ['error' => $e->getMessage()]); + } + } + private static function touchLastLogin(PDO $db, int $userId): void { try { $stmt = $db->prepare('UPDATE dc_users SET last_login_at = UTC_TIMESTAMP() WHERE id = :id'); $stmt->execute([':id' => $userId]); } catch (\Throwable $e) { - // Spalte fehlt (Migration 012 noch nicht gelaufen) - kein Grund, - // die Anmeldung scheitern zu lassen. Logger::warning('last_login_at nicht gesetzt', ['error' => $e->getMessage()]); } } @@ -226,48 +428,6 @@ final class Auth session_destroy(); } - /** - * Verbleibende Sperrzeit in Sekunden, oder 0 wenn nicht gesperrt. - */ - public static function lockoutSeconds(PDO $db, string $ip): int - { - try { - $stmt = $db->prepare(' - SELECT COUNT(*) AS failures, MAX(attempted_at) AS last_attempt - FROM dc_login_attempts - WHERE ip = :ip - AND success = 0 - AND attempted_at > (UTC_TIMESTAMP() - INTERVAL ' . self::LOCKOUT_WINDOW . ' SECOND) - '); - $stmt->execute([':ip' => self::packIp($ip)]); - $row = $stmt->fetch(); - - if (!is_array($row) || (int)$row['failures'] < self::MAX_ATTEMPTS) { - return 0; - } - - $last = isset($row['last_attempt']) ? strtotime((string)$row['last_attempt'] . ' UTC') : false; - if ($last === false) { - return self::LOCKOUT_WINDOW; - } - - $remaining = self::LOCKOUT_WINDOW - (time() - $last); - return $remaining > 0 ? $remaining : 0; - } catch (\Throwable $e) { - // Fehlt die Tabelle (Migration noch nicht gelaufen), darf die - // Anmeldung nicht blockiert werden. - Logger::warning('Lockout-Pruefung nicht moeglich', ['error' => $e->getMessage()]); - return 0; - } - } - - // ------------------------------------------------------------------ - - private static function isLockedOut(PDO $db, string $ip): bool - { - return self::lockoutSeconds($db, $ip) > 0; - } - private static function recordAttempt(PDO $db, string $ip, string $username, bool $success): void { try { @@ -281,7 +441,7 @@ final class Auth ':success' => $success ? 1 : 0, ]); - // Bei Erfolg die Fehlversuche dieser IP zuruecksetzen. + // Bei Erfolg die Fehlversuche dieser IP zuruecksetzen if ($success) { $del = $db->prepare('DELETE FROM dc_login_attempts WHERE ip = :ip AND success = 0'); $del->execute([':ip' => self::packIp($ip)]); diff --git a/src/Core/RateLimiter.php b/src/Core/RateLimiter.php new file mode 100644 index 0000000..cc00340 --- /dev/null +++ b/src/Core/RateLimiter.php @@ -0,0 +1,151 @@ +db = $db; + $this->limit = max(1, $limit); + $this->windowSeconds = max(1, $windowSeconds); + $this->bucket = $bucket; + } + + /** + * Zaehlt einen Zugriff und meldet, ob er erlaubt ist. + */ + public function check(string $ip): bool + { + $packed = @inet_pton($ip); + if ($packed === false) { + return true; + } + + $now = time(); + $windowStart = gmdate('Y-m-d H:i:s', $now - ($now % $this->windowSeconds)); + + try { + $stmt = $this->db->prepare(' + INSERT INTO dc_rate_limits (ip, bucket, window_start, request_count) + VALUES (:ip, :bucket, :window_start, 1) + ON DUPLICATE KEY UPDATE request_count = request_count + 1 + '); + $stmt->execute([ + ':ip' => $packed, + ':bucket' => mb_substr($this->bucket, 0, 64), + ':window_start' => $windowStart, + ]); + + $read = $this->db->prepare(' + SELECT request_count FROM dc_rate_limits + WHERE ip = :ip AND bucket = :bucket AND window_start = :window_start + '); + $read->execute([ + ':ip' => $packed, + ':bucket' => mb_substr($this->bucket, 0, 64), + ':window_start' => $windowStart, + ]); + $count = (int)$read->fetchColumn(); + + $allowed = $count <= $this->limit; + if (!$allowed) { + SecurityLogger::rateLimited($ip, $this->bucket, $this->limit, ['count' => $count]); + } + + return $allowed; + } catch (\Throwable $e) { + // Fallback auf historische Lizenz-Tabelle, falls dc_rate_limits noch nicht existiert + return $this->fallbackCheck($ip, $packed, $windowStart); + } + } + + /** + * Ermittelt die verbleibenden Anfragen im aktuellen Fenster. + */ + public function remaining(string $ip): int + { + $packed = @inet_pton($ip); + if ($packed === false) { + return $this->limit; + } + + $now = time(); + $windowStart = gmdate('Y-m-d H:i:s', $now - ($now % $this->windowSeconds)); + + try { + $stmt = $this->db->prepare(' + SELECT request_count FROM dc_rate_limits + WHERE ip = :ip AND bucket = :bucket AND window_start = :window_start + '); + $stmt->execute([ + ':ip' => $packed, + ':bucket' => mb_substr($this->bucket, 0, 64), + ':window_start' => $windowStart, + ]); + $count = (int)$stmt->fetchColumn(); + return max(0, $this->limit - $count); + } catch (\Throwable $e) { + return $this->limit; + } + } + + /** + * Bereinigt aeltere Eintraege. + */ + public function purge(int $olderThanSeconds = 3600): int + { + try { + $seconds = max(60, $olderThanSeconds); + $stmt = $this->db->prepare(' + DELETE FROM dc_rate_limits + WHERE window_start < (UTC_TIMESTAMP() - INTERVAL ' . $seconds . ' SECOND) + '); + $stmt->execute(); + return $stmt->rowCount(); + } catch (\Throwable $e) { + return 0; + } + } + + private function fallbackCheck(string $ip, string $packed, string $windowStart): bool + { + try { + $key = $this->bucket === 'default' ? $packed : substr(hash('sha256', $this->bucket . $ip, true), 0, 16); + $stmt = $this->db->prepare(' + INSERT INTO license_api_rate_limit (ip, window_start, request_count) + VALUES (:ip, :window_start, 1) + ON DUPLICATE KEY UPDATE request_count = request_count + 1 + '); + $stmt->execute([':ip' => $key, ':window_start' => $windowStart]); + + $read = $this->db->prepare(' + SELECT request_count FROM license_api_rate_limit + WHERE ip = :ip AND window_start = :window_start + '); + $read->execute([':ip' => $key, ':window_start' => $windowStart]); + $count = (int)$read->fetchColumn(); + + return $count <= $this->limit; + } catch (\Throwable $e) { + Logger::warning('Rate-Limit-Pruefung nicht moeglich', ['error' => $e->getMessage()]); + return true; + } + } +} diff --git a/src/Core/SecurityLogger.php b/src/Core/SecurityLogger.php new file mode 100644 index 0000000..0e0ca08 --- /dev/null +++ b/src/Core/SecurityLogger.php @@ -0,0 +1,132 @@ + key="val" ... + */ +final class SecurityLogger +{ + private static ?string $logFile = null; + + public static function authFail(string $ip, string $username, string $reason, array $extra = []): void + { + self::log('AUTH_FAIL', $ip, array_merge([ + 'user' => $username, + 'reason' => $reason, + ], $extra)); + } + + public static function accountLocked(string $ip, string $username, int $durationSeconds, array $extra = []): void + { + self::log('ACCOUNT_LOCKED', $ip, array_merge([ + 'user' => $username, + 'duration' => $durationSeconds, + 'reason' => 'too_many_failed_logins', + ], $extra)); + } + + public static function ipLocked(string $ip, int $failures, int $durationSeconds, array $extra = []): void + { + self::log('IP_LOCKED', $ip, array_merge([ + 'failures' => $failures, + 'duration' => $durationSeconds, + ], $extra)); + } + + public static function apiAuthFail(string $ip, string $path, string $reason, array $extra = []): void + { + self::log('API_AUTH_FAIL', $ip, array_merge([ + 'path' => $path, + 'reason' => $reason, + ], $extra)); + } + + public static function rateLimited(string $ip, string $bucket, int $limit, array $extra = []): void + { + self::log('RATE_LIMITED', $ip, array_merge([ + 'bucket' => $bucket, + 'limit' => $limit, + ], $extra)); + } + + public static function log(string $eventType, string $ip, array $details = []): void + { + $timestamp = gmdate('Y-m-d H:i:s'); + $ip = trim($ip) !== '' ? trim($ip) : '127.0.0.1'; + + $parts = []; + $parts[] = 'ip=' . $ip; + + foreach ($details as $key => $val) { + if ($val === null) { + continue; + } + if (is_bool($val)) { + $parts[] = $key . '=' . ($val ? 'true' : 'false'); + } elseif (is_numeric($val)) { + $parts[] = $key . '=' . $val; + } else { + $escaped = str_replace(['"', "\n", "\r"], ['\"', ' ', ' '], (string)$val); + $parts[] = sprintf('%s="%s"', $key, $escaped); + } + } + + $line = sprintf( + "[%s] [SECURITY] [%s] %s\n", + $timestamp, + strtoupper($eventType), + implode(' ', $parts) + ); + + $path = self::filePath(); + if ($path !== null) { + @file_put_contents($path, $line, FILE_APPEND | LOCK_EX); + } + + // Syslog bei Bedarf + if ((bool)Config::get('security.use_syslog', false)) { + @openlog('deploymentcenter', LOG_PID, LOG_AUTH); + @syslog(LOG_WARNING, rtrim($line)); + } + + // Parallel im Standard-Anwendungslog spiegeln + Logger::warning(sprintf('Security: %s', $eventType), array_merge(['ip' => $ip], $details)); + } + + private static function filePath(): ?string + { + if (self::$logFile !== null) { + return self::$logFile === '' ? null : self::$logFile; + } + + $custom = (string)Config::get('security.log_file', ''); + if ($custom !== '') { + self::$logFile = $custom; + return self::$logFile; + } + + $dir = defined('DC_VAR') ? DC_VAR . '/log' : null; + if ($dir === null) { + self::$logFile = ''; + return null; + } + + if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) { + self::$logFile = ''; + return null; + } + + self::$logFile = $dir . '/security.log'; + return self::$logFile; + } +} diff --git a/src/Modules/License/RateLimiter.php b/src/Modules/License/RateLimiter.php index e85d4fb..d1a707c 100644 --- a/src/Modules/License/RateLimiter.php +++ b/src/Modules/License/RateLimiter.php @@ -4,92 +4,28 @@ declare(strict_types=1); namespace Deploymentcenter\Modules\License; -use Deploymentcenter\Core\Logger; +use Deploymentcenter\Core\RateLimiter as CoreRateLimiter; use PDO; /** - * Einfache Zaehler-Drosselung pro IP und Zeitfenster. - * - * Korrekturen gegenueber der Erstfassung: - * - beginTransaction() wurde ungeprueft aufgerufen; lief bereits eine - * Transaktion, warf das. Im catch-Zweig folgte rollBack(), das ohne aktive - * Transaktion selbst wirft - eine Exception aus dem Exception-Handler. - * - Der Zaehler laeuft jetzt ohne explizite Transaktion ueber ein atomares - * INSERT ... ON DUPLICATE KEY UPDATE. Das ist kuerzer, schneller und - * braucht keine Sperren. - * - Wird jetzt auch vom Bugtracker-Ingest genutzt, nicht nur vom Lizenzmodul. + * Rueckwaertskompatibler Wrapper fuer den zentralen Core\RateLimiter. */ final class RateLimiter { - private PDO $db; - private int $limit; - private int $windowSeconds; - private string $bucket; + private CoreRateLimiter $core; public function __construct(PDO $db, int $limit = 60, int $windowSeconds = 60, string $bucket = 'default') { - $this->db = $db; - $this->limit = max(1, $limit); - $this->windowSeconds = max(1, $windowSeconds); - $this->bucket = $bucket; + $this->core = new CoreRateLimiter($db, $limit, $windowSeconds, $bucket); } - /** - * Zaehlt einen Zugriff und meldet, ob er erlaubt ist. - * - * Faellt die Pruefung selbst aus (z. B. weil die Tabelle fehlt), wird der - * Zugriff durchgelassen - eine kaputte Drosselung darf den Dienst nicht - * lahmlegen. - */ public function check(string $ip): bool { - $packed = @inet_pton($ip); - if ($packed === false) { - return true; - } - - // Fensteranfang auf ein festes Raster runden, damit alle Anfragen - // desselben Intervalls auf dieselbe Zeile treffen. - $now = time(); - $windowStart = gmdate('Y-m-d H:i:s', $now - ($now % $this->windowSeconds)); - - // Der Bucket unterscheidet Endpunkte mit eigenen Limits. - $key = $this->bucket === 'default' ? $packed : substr(hash('sha256', $this->bucket . $ip, true), 0, 16); - - try { - $stmt = $this->db->prepare(' - INSERT INTO license_api_rate_limit (ip, window_start, request_count) - VALUES (:ip, :window_start, 1) - ON DUPLICATE KEY UPDATE request_count = request_count + 1 - '); - $stmt->execute([':ip' => $key, ':window_start' => $windowStart]); - - $read = $this->db->prepare(' - SELECT request_count FROM license_api_rate_limit - WHERE ip = :ip AND window_start = :window_start - '); - $read->execute([':ip' => $key, ':window_start' => $windowStart]); - $count = (int)$read->fetchColumn(); - - return $count <= $this->limit; - } catch (\Throwable $e) { - Logger::warning('Rate-Limit-Pruefung nicht moeglich', ['error' => $e->getMessage()]); - return true; - } + return $this->core->check($ip); } - /** Entfernt Zeilen aelterer Zeitfenster. */ public function purge(int $olderThanSeconds = 3600): int { - try { - $seconds = max(60, $olderThanSeconds); - $stmt = $this->db->prepare( - 'DELETE FROM license_api_rate_limit WHERE window_start < (UTC_TIMESTAMP() - INTERVAL ' . $seconds . ' SECOND)' - ); - $stmt->execute(); - return $stmt->rowCount(); - } catch (\Throwable $e) { - return 0; - } + return $this->core->purge($olderThanSeconds); } } diff --git a/src/Modules/Notify/WebhookDispatcher.php b/src/Modules/Notify/WebhookDispatcher.php index 9c19e05..773a711 100644 --- a/src/Modules/Notify/WebhookDispatcher.php +++ b/src/Modules/Notify/WebhookDispatcher.php @@ -31,6 +31,8 @@ final class WebhookDispatcher 'monitor.down', 'monitor.recovered', 'release.published', + 'security.lockout', + 'security.attack', ]; private const TIMEOUT_SECONDS = 4; @@ -67,6 +69,41 @@ final class WebhookDispatcher 'Nach Zustand' => $payload['to_state'] ?? 'down', ] ); + } elseif ($event === 'security.lockout') { + RocketChatNotifier::sendAlert( + $db, + 'Benutzerkonto vorübergehend gesperrt', + sprintf( + 'Das Konto "%s" wurde nach wiederholten Fehlversuchen von IP %s für %d Minute(n) gesperrt.', + $payload['username'] ?? 'unbekannt', + $payload['ip'] ?? 'unbekannt', + (int)($payload['duration_minutes'] ?? 15) + ), + 'alarm', + [ + 'Benutzer' => $payload['username'] ?? 'unbekannt', + 'IP-Adresse' => $payload['ip'] ?? 'unbekannt', + 'Sperrdauer' => ($payload['duration_minutes'] ?? 15) . ' Min', + 'Zeitpunkt' => gmdate('Y-m-d H:i:s') . ' UTC', + ] + ); + } elseif ($event === 'security.attack') { + RocketChatNotifier::sendAlert( + $db, + 'Sicherheitsalarm: Brute-Force-Angriff erkannt', + sprintf( + 'Verdächtige Aktivität von IP %s: %s', + $payload['ip'] ?? 'unbekannt', + $payload['details'] ?? 'Wiederholte Fehlversuche' + ), + 'alarm', + [ + 'IP-Adresse' => $payload['ip'] ?? 'unbekannt', + 'Ziel / Pfad' => $payload['target'] ?? 'Login / API', + 'Typ' => $payload['type'] ?? 'Brute-Force', + 'Zeitpunkt' => gmdate('Y-m-d H:i:s') . ' UTC', + ] + ); } if (self::$dispatchedThisRequest >= self::MAX_TARGETS) { diff --git a/tests/server/testctl.php b/tests/server/testctl.php index 666f8a8..a0a063f 100644 --- a/tests/server/testctl.php +++ b/tests/server/testctl.php @@ -358,10 +358,18 @@ switch ($action) { Http::ok(['cleared' => true]); break; + // ------------------------------------------------------------------ + case 'unlock_user': + $username = (string)(Http::str('username') ?? 'admin'); + $stmt = $db->prepare('UPDATE dc_users SET failed_login_count = 0, locked_until = NULL WHERE username = :u'); + $stmt->execute([':u' => $username]); + Http::ok(['unlocked' => $username, 'affected' => $stmt->rowCount()]); + break; + // ------------------------------------------------------------------ default: Http::fail(400, 'unknown_action', sprintf('Unbekannte Aktion "%s".', $action), null, [ 'actions' => ['status', 'wipe', 'migrate', 'reset', 'workbench_token', - 'update_master_token', 'genkey', 'log', 'clearlog'], + 'update_master_token', 'genkey', 'log', 'clearlog', 'unlock_user'], ]); }