Security Hardening: Proaktive Kontosperren, Fail2Ban-Integration, SecurityLogger, Alerting und API-Schutz
This commit is contained in:
@@ -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.');
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user