Security Hardening: Proaktive Kontosperren, Fail2Ban-Integration, SecurityLogger, Alerting und API-Schutz
This commit is contained in:
@@ -62,6 +62,11 @@ return [
|
|||||||
// Bewusst asymmetrisch: geprüft wird auf fremden Systemen, dort darf
|
// Bewusst asymmetrisch: geprüft wird auf fremden Systemen, dort darf
|
||||||
// der Signierschlüssel nicht liegen.
|
// der Signierschlüssel nicht liegen.
|
||||||
'release_private_key' => dc_env('DC_RELEASE_SIGNING_KEY', ''),
|
'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' => [
|
'bugtracker' => [
|
||||||
// Projekt-Slug, unter dem das Deploymentcenter eigene Fehler meldet.
|
// Projekt-Slug, unter dem das Deploymentcenter eigene Fehler meldet.
|
||||||
|
|||||||
@@ -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=<HOST>(?: |$).*$
|
||||||
|
|
||||||
|
ignoreregex =
|
||||||
|
|
||||||
|
# Zeitstempel-Muster fuer [YYYY-MM-DD HH:MM:SS]
|
||||||
|
datepattern = ^\[%%Y-%%m-%%d %%H:%%M:%%S\]
|
||||||
@@ -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
|
||||||
@@ -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=<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.
|
||||||
@@ -69,17 +69,33 @@ switch (resolveSetupAction()) {
|
|||||||
Http::fail(400, 'missing_credentials', 'Benutzername und Passwort werden benoetigt.');
|
Http::fail(400, 'missing_credentials', 'Benutzername und Passwort werden benoetigt.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$lockout = Auth::lockoutSeconds($db, Http::clientIp());
|
$ipLockout = Auth::lockoutSeconds($db, Http::clientIp());
|
||||||
if ($lockout > 0) {
|
if ($ipLockout > 0) {
|
||||||
Http::fail(429, 'locked_out', sprintf(
|
Http::fail(429, 'locked_out', sprintf(
|
||||||
'Zu viele fehlgeschlagene Anmeldungen. Bitte in %d Minute(n) erneut versuchen.',
|
'Zu viele fehlgeschlagene Anmeldungen von diesem Netzwerk. Bitte in %d Minute(n) erneut versuchen.',
|
||||||
(int)ceil($lockout / 60)
|
(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);
|
$user = Auth::verifyCredentials($db, $username, $password);
|
||||||
|
|
||||||
if ($user === null) {
|
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
|
// Bewusst dieselbe Meldung fuer falschen Benutzer, falsches
|
||||||
// Passwort und deaktiviertes Konto.
|
// Passwort und deaktiviertes Konto.
|
||||||
Http::fail(401, 'invalid_credentials', 'Ungueltige Anmeldedaten.');
|
Http::fail(401, 'invalid_credentials', 'Ungueltige Anmeldedaten.');
|
||||||
|
|||||||
@@ -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.",
|
"_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,
|
"schema": 1,
|
||||||
"current": "2.8.1",
|
"current": "2.8.2",
|
||||||
"versions": [
|
"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",
|
"version": "2.8.1",
|
||||||
"date": "2026-08-31",
|
"date": "2026-08-31",
|
||||||
|
|||||||
+50
-5
@@ -1613,6 +1613,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
dc_redirect('#sub-system-users');
|
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:
|
default:
|
||||||
dc_flash('Unbekannte Aktion.', 'danger');
|
dc_flash('Unbekannte Aktion.', 'danger');
|
||||||
dc_redirect();
|
dc_redirect();
|
||||||
@@ -1869,12 +1879,19 @@ $releases = $updateManager->getReleases();
|
|||||||
// mitgelesen - er hat in der Ansicht nichts verloren.
|
// mitgelesen - er hat in der Ansicht nichts verloren.
|
||||||
try {
|
try {
|
||||||
$users = $pdo->query('
|
$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
|
FROM dc_users ORDER BY role ASC, username ASC
|
||||||
')->fetchAll() ?: [];
|
')->fetchAll() ?: [];
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
// Vor Migration 012 fehlen role, disabled und last_login_at.
|
try {
|
||||||
$users = $pdo->query('SELECT id, username, created_at FROM dc_users ORDER BY username ASC')->fetchAll() ?: [];
|
$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]);
|
$usersSchemaReady = $users === [] || array_key_exists('role', $users[0]);
|
||||||
|
|
||||||
@@ -6285,8 +6302,11 @@ Antwort:
|
|||||||
$uRole = (string)($u['role'] ?? 'admin');
|
$uRole = (string)($u['role'] ?? 'admin');
|
||||||
$uDisabled = (int)($u['disabled'] ?? 0) === 1;
|
$uDisabled = (int)($u['disabled'] ?? 0) === 1;
|
||||||
$isSelf = (int)$u['id'] === Auth::userId();
|
$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);
|
||||||
?>
|
?>
|
||||||
<tr<?= $uDisabled ? ' style="opacity:0.55;"' : '' ?>>
|
<tr<?= $uDisabled ? ' style="opacity:0.55;"' : ($isLocked ? ' style="background:rgba(239,68,68,0.08);"' : '') ?>>
|
||||||
<td>
|
<td>
|
||||||
<strong><?= e((string)$u['username']) ?></strong>
|
<strong><?= e((string)$u['username']) ?></strong>
|
||||||
<?php if ($isSelf): ?><span class="badge badge-up">Sie</span><?php endif; ?>
|
<?php if ($isSelf): ?><span class="badge badge-up">Sie</span><?php endif; ?>
|
||||||
@@ -6296,13 +6316,38 @@ Antwort:
|
|||||||
<?= e(strtoupper($uRole)) ?>
|
<?= e(strtoupper($uRole)) ?>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td><?= $uDisabled ? '<span class="badge badge-down">DEAKTIVIERT</span>' : 'aktiv' ?></td>
|
<td>
|
||||||
|
<?php if ($isLocked): ?>
|
||||||
|
<span class="badge badge-down">GESPERRT (noch <?= $lockMins ?>m)</span>
|
||||||
|
<?php elseif ($uDisabled): ?>
|
||||||
|
<span class="badge badge-down">DEAKTIVIERT</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span style="color:var(--text-main);">aktiv</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($failCount > 0 && !$isLocked): ?>
|
||||||
|
<div style="font-size:0.75rem; color:var(--text-muted); margin-top:2px;">
|
||||||
|
<?= $failCount ?> Fehlversuch(e)
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
<td style="font-size:0.8rem; color:var(--text-muted);">
|
<td style="font-size:0.8rem; color:var(--text-muted);">
|
||||||
<?= !empty($u['last_login_at']) ? e((string)$u['last_login_at']) : 'nie' ?>
|
<?= !empty($u['last_login_at']) ? e((string)$u['last_login_at']) : 'nie' ?>
|
||||||
</td>
|
</td>
|
||||||
<td style="font-size:0.8rem; color:var(--text-muted);"><?= e((string)($u['created_at'] ?? '-')) ?></td>
|
<td style="font-size:0.8rem; color:var(--text-muted);"><?= e((string)($u['created_at'] ?? '-')) ?></td>
|
||||||
<td>
|
<td>
|
||||||
<div style="display:flex; flex-wrap:wrap; gap:0.35rem;">
|
<div style="display:flex; flex-wrap:wrap; gap:0.35rem;">
|
||||||
|
<?php if ($isLocked): ?>
|
||||||
|
<form method="POST" action="index.php" style="display:inline;">
|
||||||
|
<?= $csrfField ?>
|
||||||
|
<input type="hidden" name="action" value="unlock_user">
|
||||||
|
<input type="hidden" name="_return" value="sub-system-users">
|
||||||
|
<input type="hidden" name="user_id" value="<?= (int)$u['id'] ?>">
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary">
|
||||||
|
🔓 Entsperren
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<form method="POST" action="index.php" style="display:inline-flex; gap:0.25rem;"
|
<form method="POST" action="index.php" style="display:inline-flex; gap:0.25rem;"
|
||||||
onsubmit="return confirm('Passwort für <?= e((string)$u['username']) ?> wirklich ändern?');">
|
onsubmit="return confirm('Passwort für <?= e((string)$u['username']) ?> wirklich ändern?');">
|
||||||
<?= $csrfField ?>
|
<?= $csrfField ?>
|
||||||
|
|||||||
+27
-7
@@ -31,20 +31,40 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
$pdo = Db::init();
|
$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(
|
$error = sprintf(
|
||||||
'Zu viele fehlgeschlagene Anmeldeversuche. Bitte in %d Minute(n) erneut versuchen.',
|
'Zu viele fehlgeschlagene Anmeldeversuche von diesem Netzwerk. Bitte in %d Minute(n) erneut versuchen.',
|
||||||
(int)ceil($lockoutSeconds / 60)
|
(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)) {
|
} elseif (Auth::login($pdo, $username, $password)) {
|
||||||
header('Location: /index.php');
|
header('Location: /index.php');
|
||||||
exit;
|
exit;
|
||||||
} else {
|
} else {
|
||||||
// Bewusst dieselbe Meldung fuer falschen Benutzer und falsches
|
// Pruefen, ob das Konto oder die IP durch diesen Fehlversuch soeben gesperrt wurde
|
||||||
// Passwort - sonst laesst sich herausfinden, welche Konten es gibt.
|
$newAccLockout = Auth::accountLockoutSeconds($pdo, $username);
|
||||||
$error = 'Ungueltige Anmeldedaten.';
|
$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) {
|
} catch (\Throwable $e) {
|
||||||
// Die urspruengliche Fassung gab hier die rohe PDO-Meldung samt
|
// Die urspruengliche Fassung gab hier die rohe PDO-Meldung samt
|
||||||
|
|||||||
@@ -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;
|
||||||
+13
-1
@@ -14,11 +14,14 @@ CREATE TABLE IF NOT EXISTS dc_users (
|
|||||||
role ENUM('admin','installer') NOT NULL DEFAULT 'admin',
|
role ENUM('admin','installer') NOT NULL DEFAULT 'admin',
|
||||||
last_login_at DATETIME NULL,
|
last_login_at DATETIME NULL,
|
||||||
disabled TINYINT(1) NOT NULL DEFAULT 0,
|
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
|
-- Vorgesehen, aber noch nicht ausgewertet: es gibt bislang keine
|
||||||
-- TOTP-Pruefung im Anmeldeweg.
|
-- TOTP-Pruefung im Anmeldeweg.
|
||||||
totp_secret VARCHAR(64) NULL,
|
totp_secret VARCHAR(64) NULL,
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
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;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS dc_settings (
|
CREATE TABLE IF NOT EXISTS dc_settings (
|
||||||
@@ -26,6 +29,15 @@ CREATE TABLE IF NOT EXISTS dc_settings (
|
|||||||
svalue TEXT NOT NULL
|
svalue TEXT NOT NULL
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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)
|
-- Core Projects Table (Shared across Lizenzen, Watchdog, UpdateService)
|
||||||
CREATE TABLE IF NOT EXISTS dc_projects (
|
CREATE TABLE IF NOT EXISTS dc_projects (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
|||||||
+27
-1
@@ -57,6 +57,14 @@ final class ApiAuth
|
|||||||
?string $environment = null,
|
?string $environment = null,
|
||||||
bool $allowSession = true
|
bool $allowSession = true
|
||||||
): ?array {
|
): ?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();
|
$presented = Http::bearerToken();
|
||||||
|
|
||||||
// 1. Shared Key
|
// 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', [
|
Logger::warning('Token abgelehnt', [
|
||||||
'scope' => $scope,
|
'scope' => $scope,
|
||||||
'ip' => Http::clientIp(),
|
'ip' => $ip,
|
||||||
'path' => Http::path(),
|
'path' => Http::path(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
+238
-78
@@ -4,16 +4,19 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Deploymentcenter\Core;
|
namespace Deploymentcenter\Core;
|
||||||
|
|
||||||
|
use Deploymentcenter\Modules\Notify\WebhookDispatcher;
|
||||||
use PDO;
|
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-Cookie mit HttpOnly, SameSite=Lax und Secure (bei HTTPS)
|
||||||
* - session_regenerate_id() nach erfolgreicher Anmeldung (Session Fixation)
|
* - session_regenerate_id() nach erfolgreicher Anmeldung (Session Fixation)
|
||||||
* - Leerlauf- und Absolut-Timeout
|
* - 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
|
final class Auth
|
||||||
{
|
{
|
||||||
@@ -23,11 +26,17 @@ final class Auth
|
|||||||
/** Absolutes Session-Maximum in Sekunden (12 Stunden). */
|
/** Absolutes Session-Maximum in Sekunden (12 Stunden). */
|
||||||
private const ABSOLUTE_TIMEOUT = 43200;
|
private const ABSOLUTE_TIMEOUT = 43200;
|
||||||
|
|
||||||
/** Fehlversuche pro IP, bevor gesperrt wird. */
|
/** Fehlversuche pro IP, bevor IP-Sperre greift. */
|
||||||
private const MAX_ATTEMPTS = 10;
|
public const DEFAULT_MAX_IP_ATTEMPTS = 10;
|
||||||
|
|
||||||
/** Sperrfenster in Sekunden. */
|
/** IP-Sperrfenster in Sekunden (15 Minuten). */
|
||||||
private const LOCKOUT_WINDOW = 900;
|
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
|
public static function startSession(): void
|
||||||
{
|
{
|
||||||
@@ -84,13 +93,95 @@ final class Auth
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prueft Zugangsdaten, ohne eine Session anzufassen.
|
* Verbleibende Kontosperrzeit in Sekunden, oder 0 wenn nicht gesperrt.
|
||||||
*
|
*/
|
||||||
* Getrennt von login(), weil der Setup-Weg dieselbe Pruefung braucht,
|
public static function accountLockoutSeconds(PDO $db, string $username): int
|
||||||
* aber ein kurzlebiges Token statt eines Session-Cookies ausstellt.
|
{
|
||||||
* Drosselung, Timing-Angleichung und Rehash gelten dort genauso - sie
|
$username = trim($username);
|
||||||
* hier zu wiederholen hiesse, zwei Anmeldewege mit zwei Haertungsgraden
|
if ($username === '') {
|
||||||
* zu haben.
|
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<string,mixed>|null Der Benutzerdatensatz, oder null
|
* @return array<string,mixed>|null Der Benutzerdatensatz, oder null
|
||||||
*/
|
*/
|
||||||
@@ -98,8 +189,17 @@ final class Auth
|
|||||||
{
|
{
|
||||||
$ip = Http::clientIp();
|
$ip = Http::clientIp();
|
||||||
|
|
||||||
if (self::isLockedOut($db, $ip)) {
|
// 1. IP-Sperre pruefen
|
||||||
Logger::warning('Anmeldung gesperrt (zu viele Fehlversuche)', ['ip' => $ip, 'username' => $username]);
|
$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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,29 +208,55 @@ final class Auth
|
|||||||
$user = $stmt->fetch();
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
$found = is_array($user) && isset($user['password_hash']);
|
$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();
|
$hash = $found ? (string)$user['password_hash'] : self::dummyHash();
|
||||||
|
|
||||||
$verified = password_verify($password, $hash);
|
$verified = password_verify($password, $hash);
|
||||||
|
|
||||||
if (!$verified || !$found) {
|
if (!$verified || !$found) {
|
||||||
self::recordAttempt($db, $ip, $username, false);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ein deaktiviertes Konto bleibt bestehen, damit Protokolle weiter
|
// Deaktiviertes Konto
|
||||||
// darauf verweisen koennen - anmelden darf es sich nicht.
|
|
||||||
if ((int)($user['disabled'] ?? 0) === 1) {
|
if ((int)($user['disabled'] ?? 0) === 1) {
|
||||||
self::recordAttempt($db, $ip, $username, false);
|
self::recordAttempt($db, $ip, $username, false);
|
||||||
Logger::warning('Anmeldung eines deaktivierten Kontos abgelehnt', [
|
SecurityLogger::authFail($ip, $username, 'account_disabled');
|
||||||
'username' => $username,
|
|
||||||
'ip' => $ip,
|
|
||||||
]);
|
|
||||||
return null;
|
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)) {
|
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
|
||||||
$upd = $db->prepare('UPDATE dc_users SET password_hash = :h WHERE id = :id');
|
$upd = $db->prepare('UPDATE dc_users SET password_hash = :h WHERE id = :id');
|
||||||
$upd->execute([':h' => password_hash($password, PASSWORD_DEFAULT), ':id' => $user['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.
|
* 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
|
public static function roleOf(?array $user): string
|
||||||
{
|
{
|
||||||
@@ -168,11 +290,9 @@ final class Auth
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ein Installationskonto hat in der Verwaltungsoberflaeche nichts zu
|
// Ein Installationskonto hat in der Verwaltungsoberflaeche nichts zu suchen
|
||||||
// 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.
|
|
||||||
if (self::roleOf($user) === 'installer') {
|
if (self::roleOf($user) === 'installer') {
|
||||||
|
SecurityLogger::authFail(Http::clientIp(), $username, 'installer_role_not_allowed_for_webui');
|
||||||
Logger::warning('WebUI-Anmeldung eines Installationskontos abgelehnt', [
|
Logger::warning('WebUI-Anmeldung eines Installationskontos abgelehnt', [
|
||||||
'username' => $username,
|
'username' => $username,
|
||||||
'ip' => Http::clientIp(),
|
'ip' => Http::clientIp(),
|
||||||
@@ -193,14 +313,96 @@ final class Auth
|
|||||||
return true;
|
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
|
private static function touchLastLogin(PDO $db, int $userId): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$stmt = $db->prepare('UPDATE dc_users SET last_login_at = UTC_TIMESTAMP() WHERE id = :id');
|
$stmt = $db->prepare('UPDATE dc_users SET last_login_at = UTC_TIMESTAMP() WHERE id = :id');
|
||||||
$stmt->execute([':id' => $userId]);
|
$stmt->execute([':id' => $userId]);
|
||||||
} catch (\Throwable $e) {
|
} 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()]);
|
Logger::warning('last_login_at nicht gesetzt', ['error' => $e->getMessage()]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,48 +428,6 @@ final class Auth
|
|||||||
session_destroy();
|
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
|
private static function recordAttempt(PDO $db, string $ip, string $username, bool $success): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@@ -281,7 +441,7 @@ final class Auth
|
|||||||
':success' => $success ? 1 : 0,
|
':success' => $success ? 1 : 0,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Bei Erfolg die Fehlversuche dieser IP zuruecksetzen.
|
// Bei Erfolg die Fehlversuche dieser IP zuruecksetzen
|
||||||
if ($success) {
|
if ($success) {
|
||||||
$del = $db->prepare('DELETE FROM dc_login_attempts WHERE ip = :ip AND success = 0');
|
$del = $db->prepare('DELETE FROM dc_login_attempts WHERE ip = :ip AND success = 0');
|
||||||
$del->execute([':ip' => self::packIp($ip)]);
|
$del->execute([':ip' => self::packIp($ip)]);
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Deploymentcenter\Core;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Universelle Zaehler-Drosselung (Rate Limiter) pro IP, Zeitfenster und Bucket.
|
||||||
|
*
|
||||||
|
* Verwaltet atomare Zaehler in dc_rate_limits ueber INSERT ... ON DUPLICATE KEY UPDATE.
|
||||||
|
* Schlaegt die Pruefung fehl (z. B. waehrend einer laufenden Migration), wird
|
||||||
|
* die Anfrage durchgelassen, um den Dienst nicht zu blockieren.
|
||||||
|
*/
|
||||||
|
final class RateLimiter
|
||||||
|
{
|
||||||
|
private PDO $db;
|
||||||
|
private int $limit;
|
||||||
|
private int $windowSeconds;
|
||||||
|
private string $bucket;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Deploymentcenter\Core;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dedizierter Sicherheits-Logger fuer Authentifizierungs- und Schutzereignisse.
|
||||||
|
*
|
||||||
|
* Schreibt standardmaessig in eine feste Datei (var/log/security.log), damit
|
||||||
|
* Daemons wie Fail2Ban den Stream zuverlaessig ueberwachen koennen, ohne dass
|
||||||
|
* rotierende Dateinamen die Erkennung unterbrechen.
|
||||||
|
*
|
||||||
|
* Format jeder Zeile:
|
||||||
|
* [YYYY-MM-DD HH:MM:SS] [SECURITY] [EVENT_TYPE] ip=<IP> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,92 +4,28 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Deploymentcenter\Modules\License;
|
namespace Deploymentcenter\Modules\License;
|
||||||
|
|
||||||
use Deploymentcenter\Core\Logger;
|
use Deploymentcenter\Core\RateLimiter as CoreRateLimiter;
|
||||||
use PDO;
|
use PDO;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Einfache Zaehler-Drosselung pro IP und Zeitfenster.
|
* Rueckwaertskompatibler Wrapper fuer den zentralen Core\RateLimiter.
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
final class RateLimiter
|
final class RateLimiter
|
||||||
{
|
{
|
||||||
private PDO $db;
|
private CoreRateLimiter $core;
|
||||||
private int $limit;
|
|
||||||
private int $windowSeconds;
|
|
||||||
private string $bucket;
|
|
||||||
|
|
||||||
public function __construct(PDO $db, int $limit = 60, int $windowSeconds = 60, string $bucket = 'default')
|
public function __construct(PDO $db, int $limit = 60, int $windowSeconds = 60, string $bucket = 'default')
|
||||||
{
|
{
|
||||||
$this->db = $db;
|
$this->core = new CoreRateLimiter($db, $limit, $windowSeconds, $bucket);
|
||||||
$this->limit = max(1, $limit);
|
|
||||||
$this->windowSeconds = max(1, $windowSeconds);
|
|
||||||
$this->bucket = $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
|
public function check(string $ip): bool
|
||||||
{
|
{
|
||||||
$packed = @inet_pton($ip);
|
return $this->core->check($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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Entfernt Zeilen aelterer Zeitfenster. */
|
|
||||||
public function purge(int $olderThanSeconds = 3600): int
|
public function purge(int $olderThanSeconds = 3600): int
|
||||||
{
|
{
|
||||||
try {
|
return $this->core->purge($olderThanSeconds);
|
||||||
$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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ final class WebhookDispatcher
|
|||||||
'monitor.down',
|
'monitor.down',
|
||||||
'monitor.recovered',
|
'monitor.recovered',
|
||||||
'release.published',
|
'release.published',
|
||||||
|
'security.lockout',
|
||||||
|
'security.attack',
|
||||||
];
|
];
|
||||||
|
|
||||||
private const TIMEOUT_SECONDS = 4;
|
private const TIMEOUT_SECONDS = 4;
|
||||||
@@ -67,6 +69,41 @@ final class WebhookDispatcher
|
|||||||
'Nach Zustand' => $payload['to_state'] ?? 'down',
|
'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) {
|
if (self::$dispatchedThisRequest >= self::MAX_TARGETS) {
|
||||||
|
|||||||
@@ -358,10 +358,18 @@ switch ($action) {
|
|||||||
Http::ok(['cleared' => true]);
|
Http::ok(['cleared' => true]);
|
||||||
break;
|
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:
|
default:
|
||||||
Http::fail(400, 'unknown_action', sprintf('Unbekannte Aktion "%s".', $action), null, [
|
Http::fail(400, 'unknown_action', sprintf('Unbekannte Aktion "%s".', $action), null, [
|
||||||
'actions' => ['status', 'wipe', 'migrate', 'reset', 'workbench_token',
|
'actions' => ['status', 'wipe', 'migrate', 'reset', 'workbench_token',
|
||||||
'update_master_token', 'genkey', 'log', 'clearlog'],
|
'update_master_token', 'genkey', 'log', 'clearlog', 'unlock_user'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user