fix(2.9.3): Der zweite Faktor war neben der Zeremonie umgehbar
Ergebnis einer beauftragten Sicherheitsbegutachtung. Die WebAuthn- Zeremonie selbst ist nicht angefasst worden - sie war in Ordnung. Alle Befunde lagen DANEBEN: Ein zweiter Faktor ist nur so stark wie der schwaechste Weg zum Konto, und Sitzungen, die aus ihm entstehen, erben seinen Schutz nicht. KRITISCH /api/setup/v1/login prueste auf totp_secret - eine Spalte, die kein Anmeldeweg auswertet - und nicht auf dc_user_credentials. Ein Administratorpasswort genuegte dort fuer ein setup:install-Token ohne Hardwarekey. Neu: Konten mit zweitem Faktor abgewiesen, und die Rollentrennung gilt symmetrisch. Der Docblock behauptete sie seit jeher; erzwungen hat sie nichts. HOCH secondFactorRequired(), accountLockoutSeconds() und lockoutSeconds() fingen jedes Throwable und fielen auf "kein Faktor" bzw. "nicht gesperrt" zurueck. Gefangen wurden damit auch Verbindungsabbrueche und Lock-Timeouts - jeder davon schaltete still fuer alle Konten auf Passwort-allein. Toleriert werden jetzt nur noch 42S02 und 42S22. HOCH Fuenf Aktionen aenderten die Anmeldemittel ohne erneute Bestaetigung. Eine uebernommene Sitzung konnte sich damit dauerhaft einnisten. Neu: Authentifizierung aus den letzten zehn Minuten noetig, geprueft an derselben Stelle, an der auch der Leerlauf sperrt. WEITER - Leerlauf 30 statt 120 Minuten, und GESPERRT statt beendet: AAL2 laesst zur Reaktivierung einen Faktor genuegen. Wer alle 30 Minuten den Hardwarekey vorzeigen muss, laesst ihn stecken. - Migration 022: session_epoch. Ein Passwortwechsel beendet die uebrigen Sitzungen des Kontos, die eigene bleibt. - Kontosperre wirkt auf der zweiten Stufe; Zaehler gegen unbegrenztes Raten im Zwischenzustand. - Drosselung auf login.php (10/60s) und /api/auth/v1/ (30/60s). - Anmeldefehler ununterscheidbar; die IP-Sperre darf benannt bleiben, sie sagt nichts ueber ein Konto. - HSTS, TLS-Zwang VOR allen Routen, CSP. Die CSP traegt wegen 93 Inline-Handlern nicht gegen XSS - das steht so im Kommentar. - session.use_strict_mode, CSRF-Token bei der Anmeldung verworfen, Secure-Flag aus app.url statt aus $_SERVER['HTTPS']. - rpId/expectedOrigin nicht mehr aus dem Host-Header. /api/health meldet unter checks.webauthn_anchor, ob der Anker steht. - Notfalltoken laeuft nach 180 Tagen ab, geprueft nach dem Hashvergleich. - config.example.php ohne echtes Passwort, verify_ssl auf true. - Serverdaten.txt.bak mitgesperrt, dc_login_attempts raeumt auf. BELEGT 188 von 188 Testfaellen gruen, darunter drei neue in der Gruppe "haertung". Die Blockade einer veralteten Sitzung liess sich nicht in der Suite pruefen - sie verlangt, das Zehn-Minuten-Fenster wirklich abzuwarten. Dafuer gibt es ein Werkzeug ausserhalb; es belegt, dass der Formularweg auf den Sperrbildschirm fuehrt, die Regel dabei unveraendert bleibt, der JSON-Weg reauth_required meldet und das Passwort allein wieder oeffnet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0c20698fd5
commit
91d8bf353e
+97
-4
@@ -230,6 +230,49 @@ function dc_flash(string $message, string $type = 'success'): void
|
||||
$_SESSION['dc_flash'] = ['message' => $message, 'type' => $type];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verlangt eine frische Authentifizierung fuer Aenderungen an den
|
||||
* Anmeldemitteln selbst.
|
||||
*
|
||||
* WARUM DAS NOETIG IST
|
||||
*
|
||||
* Bis 2.9.2 genuegte fuer jede dieser Aktionen eine lebende Sitzung samt
|
||||
* CSRF-Token. Eine einzige uebernommene Sitzung reichte damit, um sich
|
||||
* dauerhaft einzunisten: einen eigenen Hardwarekey registrieren, einen
|
||||
* Notfalltoken fuer jedes Konto ausstellen, den zweiten Faktor anlagenweit
|
||||
* abschalten. Der Hardwarekey haette gegen genau den Fall nicht geholfen,
|
||||
* gegen den er gedacht ist - er schuetzt die Anmeldung, nicht die Sitzung,
|
||||
* die daraus entsteht.
|
||||
*
|
||||
* Der Aufrufer bricht ab; diese Funktion kehrt bei fehlender frischer
|
||||
* Authentifizierung nicht zurueck.
|
||||
*/
|
||||
function dc_require_reauth(string $rueckkehr = 'sub-system-users'): void
|
||||
{
|
||||
if (Auth::reauthFresh()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Der Hinweis auf die Wiederholung ist kein Zierrat: Die Formulardaten
|
||||
// gehen bei der Umleitung verloren, und ohne diesen Satz sucht man nach
|
||||
// dem Entsperren vergeblich nach der Wirkung.
|
||||
dc_flash(
|
||||
'Diese Aenderung betrifft die Anmeldung selbst. Bitte bestaetigen Sie sie mit '
|
||||
. 'Ihrem Passwort und wiederholen Sie die Aktion danach.',
|
||||
'danger'
|
||||
);
|
||||
|
||||
// Der Sperrbildschirm erledigt genau das - er prueft ein Passwort und
|
||||
// setzt den Zeitstempel. Statt eines zweiten Formulars derselben Art
|
||||
// wird die Sitzung dafuer kurz gesperrt.
|
||||
Auth::lockForReauth();
|
||||
|
||||
if (!headers_sent()) {
|
||||
header('Location: /login.php?step=unlock&weiter=' . rawurlencode('/index.php#' . $rueckkehr));
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie viele handlungsfaehige Administratorkonten gibt es noch?
|
||||
*
|
||||
@@ -1522,6 +1565,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
}
|
||||
|
||||
case 'set_user_password': {
|
||||
dc_require_reauth();
|
||||
|
||||
$userId = (int)($_POST['user_id'] ?? 0);
|
||||
$newPass = (string)($_POST['password'] ?? '');
|
||||
|
||||
@@ -1533,13 +1578,33 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$upd = $pdo->prepare('UPDATE dc_users SET password_hash = :h WHERE id = :id');
|
||||
$upd->execute([':h' => password_hash($newPass, PASSWORD_DEFAULT), ':id' => $userId]);
|
||||
|
||||
$geaendert = $upd->rowCount() > 0;
|
||||
|
||||
if ($geaendert) {
|
||||
// Alle uebrigen Sitzungen dieses Kontos beenden. Ein
|
||||
// Passwort zu aendern, ohne die Sitzungen zu beenden, die
|
||||
// mit dem alten eroeffnet wurden, laesst genau den drin,
|
||||
// dessentwegen man es aendert. Die eigene Sitzung
|
||||
// uebernimmt den neuen Stand und bleibt bestehen.
|
||||
Auth::bumpSessionEpoch($pdo, $userId);
|
||||
|
||||
SecurityLogger::log('password_changed', Http::clientIp(), [
|
||||
'username' => $actor,
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
Logger::info('Passwort geaendert', ['user_id' => $userId, 'actor' => $actor]);
|
||||
|
||||
// Der Hash steht auch in den .htpasswd-Dateien.
|
||||
dc_guard_releases($pdo);
|
||||
|
||||
dc_flash($upd->rowCount() > 0 ? 'Passwort geaendert.' : 'Konto nicht gefunden.',
|
||||
$upd->rowCount() > 0 ? 'success' : 'danger');
|
||||
dc_flash(
|
||||
$geaendert
|
||||
? 'Passwort geaendert. Andere Sitzungen dieses Kontos sind damit beendet.'
|
||||
: 'Konto nicht gefunden.',
|
||||
$geaendert ? 'success' : 'danger'
|
||||
);
|
||||
dc_redirect('#sub-system-users');
|
||||
}
|
||||
|
||||
@@ -1614,6 +1679,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
}
|
||||
|
||||
case 'issue_recovery_token': {
|
||||
dc_require_reauth();
|
||||
|
||||
$userId = (int)($_POST['user_id'] ?? 0);
|
||||
|
||||
$row = $pdo->prepare('SELECT id, username FROM dc_users WHERE id = :id LIMIT 1');
|
||||
@@ -1644,6 +1711,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
}
|
||||
|
||||
case 'save_webauthn_policy': {
|
||||
dc_require_reauth();
|
||||
|
||||
$policy = new HardwarePolicy($pdo);
|
||||
$wanted = (string)($_POST['policy_level'] ?? HardwarePolicy::OFF);
|
||||
$before = $policy->level();
|
||||
@@ -1688,6 +1757,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
}
|
||||
|
||||
case 'delete_credential': {
|
||||
dc_require_reauth();
|
||||
|
||||
$userId = (int)($_POST['user_id'] ?? 0);
|
||||
$credentialId = (int)($_POST['credential_id'] ?? 0);
|
||||
|
||||
@@ -6847,8 +6918,20 @@ Antwort:
|
||||
|
||||
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:1rem;">
|
||||
Notfalltoken für Ihr eigenes Konto:
|
||||
<?php if ($myRecovery['present']): ?>
|
||||
hinterlegt seit <?= e((string)$myRecovery['created_at']) ?>.
|
||||
<?php if (!empty($myRecovery['expired'])): ?>
|
||||
<span style="color:var(--danger);">abgelaufen</span> —
|
||||
hinterlegt am <?= e((string)$myRecovery['created_at']) ?>,
|
||||
also vor <?= (int)$myRecovery['age_days'] ?> Tagen. Er öffnet nichts mehr;
|
||||
bitte einen neuen erzeugen.
|
||||
<?php elseif ($myRecovery['present']): ?>
|
||||
hinterlegt seit <?= e((string)$myRecovery['created_at']) ?><?php
|
||||
if ($myRecovery['age_days'] !== null) {
|
||||
$rest = (int)(CredentialRepo::RECOVERY_MAX_AGE / 86400) - (int)$myRecovery['age_days'];
|
||||
echo $rest <= 30
|
||||
? ', <span style="color:var(--warning);">läuft in ' . $rest . ' Tag(en) ab</span>'
|
||||
: ' (' . $rest . ' Tage gültig)';
|
||||
}
|
||||
?>.
|
||||
<?php elseif (!empty($myRecovery['used_at'])): ?>
|
||||
<span style="color:var(--warning);">verbraucht am <?= e((string)$myRecovery['used_at']) ?>.</span>
|
||||
<?php else: ?>
|
||||
@@ -9237,6 +9320,16 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
// Eine fehlende Passwortbestätigung ist kein Fehler, sondern ein
|
||||
// Zwischenschritt. Sie als roten Text stehen zu lassen, hieße dem
|
||||
// Benutzer eine Sackgasse zu zeigen, aus der er den Ausweg selbst
|
||||
// suchen müsste.
|
||||
const huelle = data && data.error;
|
||||
if (huelle && huelle.code === 'reauth_required' && huelle.reauth_url) {
|
||||
setStatus('Bitte kurz mit dem Passwort bestätigen …');
|
||||
window.location.href = huelle.reauth_url;
|
||||
return new Promise(() => {});
|
||||
}
|
||||
throw new Error(fehlertext(data));
|
||||
}
|
||||
return data;
|
||||
|
||||
Reference in New Issue
Block a user