Security Hardening: Proaktive Kontosperren, Fail2Ban-Integration, SecurityLogger, Alerting und API-Schutz

This commit is contained in:
Deploymentcenter Bot
2026-09-08 11:45:04 +02:00
parent 087f4392a9
commit edea46e342
17 changed files with 927 additions and 169 deletions
+21 -5
View File
@@ -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.');
+37 -1
View File
@@ -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",
+50 -5
View File
@@ -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);
?>
<tr<?= $uDisabled ? ' style="opacity:0.55;"' : '' ?>>
<tr<?= $uDisabled ? ' style="opacity:0.55;"' : ($isLocked ? ' style="background:rgba(239,68,68,0.08);"' : '') ?>>
<td>
<strong><?= e((string)$u['username']) ?></strong>
<?php if ($isSelf): ?><span class="badge badge-up">Sie</span><?php endif; ?>
@@ -6296,13 +6316,38 @@ Antwort:
<?= e(strtoupper($uRole)) ?>
</span>
</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);">
<?= !empty($u['last_login_at']) ? e((string)$u['last_login_at']) : 'nie' ?>
</td>
<td style="font-size:0.8rem; color:var(--text-muted);"><?= e((string)($u['created_at'] ?? '-')) ?></td>
<td>
<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;"
onsubmit="return confirm('Passwort für <?= e((string)$u['username']) ?> wirklich ändern?');">
<?= $csrfField ?>
+27 -7
View File
@@ -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