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
@@ -38,6 +38,7 @@ use Deploymentcenter\Core\Webauthn\Attestation;
|
||||
use Deploymentcenter\Core\Webauthn\CredentialRepo;
|
||||
use Deploymentcenter\Core\Webauthn\HardwarePolicy;
|
||||
use Deploymentcenter\Core\Webauthn\WebauthnServer;
|
||||
use Deploymentcenter\Modules\License\RateLimiter;
|
||||
|
||||
// Kein publicCors: Diese Endpunkte arbeiten mit dem Sitzungscookie. Ein
|
||||
// "Access-Control-Allow-Origin: *" waere hier das Gegenteil dessen, was
|
||||
@@ -56,6 +57,16 @@ $db = Db::init();
|
||||
$repo = new CredentialRepo($db);
|
||||
$policy = new HardwarePolicy($db);
|
||||
|
||||
// Drosselung. login_options liess sich bisher unbegrenzt aufrufen; der
|
||||
// einzige Schutz war die Kontosperre, und die greift erst ab dem fuenften
|
||||
// Fehlversuch. Das Fenster ist weiter gefasst als am Anmeldeformular (30
|
||||
// statt 10), weil eine einzelne Anmeldung hier mehrere Aufrufe braucht -
|
||||
// Vorgaben holen, Antwort pruefen, bei einem Abbruch von vorn.
|
||||
$drossel = new RateLimiter($db, 30, 60, 'webauthn');
|
||||
if (!$drossel->check(Http::clientIp())) {
|
||||
Http::fail(429, 'rate_limited', 'Zu viele Anfragen. Bitte kurz warten.');
|
||||
}
|
||||
|
||||
switch ($action) {
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
@@ -177,6 +188,25 @@ switch ($action) {
|
||||
}
|
||||
Csrf::requireValid();
|
||||
|
||||
// Einen zweiten Faktor aufzunehmen ist die wirksamste Art, sich in
|
||||
// einem Konto einzunisten: Der eigene Schluessel gilt danach
|
||||
// dauerhaft und faellt niemandem auf. Deshalb genuegt hier eine
|
||||
// lebende Sitzung nicht - es braucht eine Authentifizierung aus den
|
||||
// letzten Minuten. Der Browser bekommt einen eigenen Code dafuer,
|
||||
// damit die Oberflaeche zum Sperrbildschirm schicken kann, statt
|
||||
// "verboten" zu melden.
|
||||
if (!Auth::reauthFresh()) {
|
||||
Http::fail(
|
||||
403,
|
||||
'reauth_required',
|
||||
'Diese Aenderung betrifft die Anmeldung selbst. Bitte bestaetigen Sie sie '
|
||||
. 'zuerst mit Ihrem Passwort.',
|
||||
null,
|
||||
['reauth_url' => '/login.php?step=unlock&weiter=' . rawurlencode('/index.php#sub-system-users')]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
$userId = Auth::userId();
|
||||
|
||||
$challenge = WebauthnServer::challenge();
|
||||
@@ -205,6 +235,25 @@ switch ($action) {
|
||||
}
|
||||
Csrf::requireValid();
|
||||
|
||||
// Einen zweiten Faktor aufzunehmen ist die wirksamste Art, sich in
|
||||
// einem Konto einzunisten: Der eigene Schluessel gilt danach
|
||||
// dauerhaft und faellt niemandem auf. Deshalb genuegt hier eine
|
||||
// lebende Sitzung nicht - es braucht eine Authentifizierung aus den
|
||||
// letzten Minuten. Der Browser bekommt einen eigenen Code dafuer,
|
||||
// damit die Oberflaeche zum Sperrbildschirm schicken kann, statt
|
||||
// "verboten" zu melden.
|
||||
if (!Auth::reauthFresh()) {
|
||||
Http::fail(
|
||||
403,
|
||||
'reauth_required',
|
||||
'Diese Aenderung betrifft die Anmeldung selbst. Bitte bestaetigen Sie sie '
|
||||
. 'zuerst mit Ihrem Passwort.',
|
||||
null,
|
||||
['reauth_url' => '/login.php?step=unlock&weiter=' . rawurlencode('/index.php#sub-system-users')]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
$userId = Auth::userId();
|
||||
|
||||
$challenge = Auth::takeChallenge();
|
||||
|
||||
@@ -47,6 +47,31 @@ try {
|
||||
$logDir = DC_VAR . '/log';
|
||||
$checks['log_writable'] = ['ok' => is_dir($logDir) ? is_writable($logDir) : is_writable(DC_ROOT)];
|
||||
|
||||
// --- Ist die eigene Adresse fest hinterlegt? ---
|
||||
// Seit 2.9.3 weigert sich WebauthnServer, die Kennung aus dem Host-Header
|
||||
// abzuleiten: Aus diesem Wert entstehen rpId und expectedOrigin, also die
|
||||
// beiden Anker, gegen die die Anmeldezeremonie geprueft wird. Wer den Header
|
||||
// setzt, bestimmte sonst selbst, wogegen seine Anmeldung geprueft wird.
|
||||
//
|
||||
// Die Folge ist aber, dass eine Anlage ohne app.url den Hardwarekey nicht
|
||||
// mehr annimmt. Das hier ist die Stelle, an der man das VORHER sieht statt
|
||||
// beim naechsten Anmeldeversuch. Gemeldet wird nur, OB ein Wert gesetzt ist -
|
||||
// nicht welcher; eine Gesundheitsauskunft ohne Anmeldung soll keine
|
||||
// Konfiguration ausplaudern.
|
||||
$appUrlGesetzt = trim((string)Config::get('app.url', '')) !== ''
|
||||
|| trim((string)Config::get('security.webauthn_rp_id', '')) !== '';
|
||||
|
||||
$checks['webauthn_anchor'] = [
|
||||
'ok' => $appUrlGesetzt,
|
||||
'hint' => $appUrlGesetzt
|
||||
? null
|
||||
: 'app.url oder security.webauthn_rp_id fehlt - der Hardwarekey wird abgewiesen.',
|
||||
];
|
||||
|
||||
// Bewusst KEIN $healthy = false: Eine Anlage ohne Hardwarekeys laeuft damit
|
||||
// einwandfrei. Erst wer einen registriert, braucht den Anker - und dann sagt
|
||||
// es ihm diese Zeile.
|
||||
|
||||
// --- Schema vorhanden? ---
|
||||
// Dass die Verbindung steht, heisst noch nicht, dass die Migrationen gelaufen
|
||||
// sind: SELECT 1 gelingt auch auf einer leeren Datenbank. Vor der ersten
|
||||
|
||||
+16
-7
@@ -766,15 +766,24 @@ $spec = [
|
||||
'summary' => 'Anmelden und ein kurzlebiges Setup-Token holen',
|
||||
'description' =>
|
||||
"Ohne Token erreichbar - hier entsteht das Token erst.\n\n"
|
||||
. "Gedacht fuer Konten der Rolle `installer`, die sich ihrerseits **nicht** am "
|
||||
. "WebUI anmelden koennen. Die Zugangsdaten werden auf jedem Zielsystem "
|
||||
. "eingegeben; mit einem Administratorkonto verteilte man damit den Zugang zur "
|
||||
. "gesamten Verwaltung.\n\n"
|
||||
. "**Nur fuer Konten der Rolle `installer`.** Sie koennen sich ihrerseits "
|
||||
. "nicht am WebUI anmelden; die Trennung gilt seit 2.9.3 in beide Richtungen. "
|
||||
. "Ein Administratorkonto wird hier mit `403 installer_account_required` "
|
||||
. "abgewiesen. Die Zugangsdaten werden auf jedem Zielsystem eingegeben; mit "
|
||||
. "einem Administratorkonto verteilte man damit den Zugang zur gesamten "
|
||||
. "Verwaltung.\n\n"
|
||||
. "Das Token laeuft nach 30 Minuten ab, traegt nur `setup:install` und wird "
|
||||
. "nicht mitgeschrieben - es ist nach der Ausgabe nirgends mehr abrufbar.\n\n"
|
||||
. "Konten mit hinterlegtem TOTP-Geheimnis werden mit 501 abgewiesen: eine "
|
||||
. "TOTP-Pruefung gibt es noch nicht, und sie stillschweigend zu uebergehen "
|
||||
. "waere ein Rueckschritt.",
|
||||
. "**Zweiter Faktor:** Konten mit hinterlegtem Hardwarekey oder "
|
||||
. "TOTP-Geheimnis werden mit `403 second_factor_required` abgewiesen. "
|
||||
. "Dieser Weg kennt die WebAuthn-Zeremonie nicht, und ein Anmeldeweg, der "
|
||||
. "den zweiten Faktor uebergeht, macht ihn ueberall wertlos. Bis 2.9.2 "
|
||||
. "wurde hier nur auf `totp_secret` geprueft - eine Spalte, die kein "
|
||||
. "Anmeldeweg auswertet.\n\n"
|
||||
. "**Fehlschlaege sind ununterscheidbar:** Falscher Name, falsches "
|
||||
. "Passwort, deaktiviertes und gesperrtes Konto ergeben alle "
|
||||
. "`401 invalid_credentials`. Eine eigene Antwort fuer die Kontosperre "
|
||||
. "verriete, dass es den Namen gibt - gesperrt wird nur, was existiert.",
|
||||
'security' => [],
|
||||
'requestBody' => [
|
||||
'required' => true,
|
||||
|
||||
@@ -18,10 +18,17 @@
|
||||
* dort falsch aufgehoben. Was nach der Installation zurueckbleibt, ist das
|
||||
* Sub-Token der Anwendung selbst mit genau den Rechten, die sie braucht.
|
||||
*
|
||||
* ROLLENTRENNUNG: Anmelden darf sich hier jedes Konto, aber gedacht ist der
|
||||
* Weg fuer Konten der Rolle "installer" - die sich ihrerseits nicht am WebUI
|
||||
* anmelden koennen. Wer auf jedem Zielsystem sein Admin-Passwort eingibt,
|
||||
* verteilt den Zugang zur gesamten Verwaltung.
|
||||
* ROLLENTRENNUNG: Seit 2.9.3 duerfen sich hier NUR Konten der Rolle
|
||||
* "installer" anmelden - die sich ihrerseits nicht am WebUI anmelden koennen.
|
||||
* Bis dahin stand an dieser Stelle "gedacht ist der Weg fuer Konten der Rolle
|
||||
* installer", und erzwungen war nichts; der Satz beschrieb eine Absicht, die
|
||||
* der Code nicht teilte. Wer auf jedem Zielsystem sein Admin-Passwort
|
||||
* eingibt, verteilt den Zugang zur gesamten Verwaltung.
|
||||
*
|
||||
* ZWEITER FAKTOR: Konten mit hinterlegtem Hardwarekey werden hier ebenfalls
|
||||
* abgewiesen. Dieser Weg kennt die WebAuthn-Zeremonie nicht, und ein
|
||||
* Anmeldeweg, der den zweiten Faktor einfach uebergeht, macht ihn ueberall
|
||||
* wertlos.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
@@ -33,6 +40,7 @@ use Deploymentcenter\Core\Auth;
|
||||
use Deploymentcenter\Core\Db;
|
||||
use Deploymentcenter\Core\Http;
|
||||
use Deploymentcenter\Core\Logger;
|
||||
use Deploymentcenter\Core\SecurityLogger;
|
||||
use Deploymentcenter\Core\TokenManager;
|
||||
use Deploymentcenter\Modules\License\RateLimiter;
|
||||
use Deploymentcenter\Modules\Setup\SetupCatalog;
|
||||
@@ -77,44 +85,95 @@ switch (resolveSetupAction()) {
|
||||
), null, ['retry_after_seconds' => $ipLockout]);
|
||||
}
|
||||
|
||||
// Die Kontosperre wird geprueft, aber NICHT als solche gemeldet.
|
||||
// Eine eigene Antwort "account_locked" verriete, dass es den Namen
|
||||
// gibt - gesperrt wird nur, was existiert. Die sorgfaeltige
|
||||
// Laufzeitangleichung ueber dummyHash() in verifyCredentials() waere
|
||||
// damit wieder aufgehoben: Fuenf Versuche pro Name genuegten, um ihn
|
||||
// zu bestaetigen.
|
||||
$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 = $accLockout > 0 ? null : 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]);
|
||||
}
|
||||
// Eine Antwort fuer alle Faelle: falscher Name, falsches Passwort,
|
||||
// deaktiviertes Konto, gesperrtes Konto. Die Wartezeit darf mit -
|
||||
// sie ist ein Hinweis fuer den Berechtigten und sagt einem
|
||||
// Ratenden nichts, was er nicht ohnehin ausprobieren wuerde.
|
||||
$warten = max($accLockout, Auth::accountLockoutSeconds($db, $username));
|
||||
|
||||
// Bewusst dieselbe Meldung fuer falschen Benutzer, falsches
|
||||
// Passwort und deaktiviertes Konto.
|
||||
Http::fail(401, 'invalid_credentials', 'Ungueltige Anmeldedaten.');
|
||||
Http::fail(
|
||||
401,
|
||||
'invalid_credentials',
|
||||
$warten > 0
|
||||
? sprintf(
|
||||
'Anmeldung nicht moeglich. Bitte in %d Minute(n) erneut versuchen.',
|
||||
(int)ceil($warten / 60)
|
||||
)
|
||||
: 'Anmeldung nicht moeglich.',
|
||||
null,
|
||||
$warten > 0 ? ['retry_after_seconds' => $warten] : []
|
||||
);
|
||||
}
|
||||
|
||||
// TOTP ist in dc_users vorgesehen, wird aber noch von keinem
|
||||
// Anmeldeweg geprueft. Ein Konto mit hinterlegtem Geheimnis hier
|
||||
// durchzulassen, ohne den zweiten Faktor zu verlangen, waere ein
|
||||
// stiller Rueckschritt - deshalb wird es ausdruecklich abgelehnt.
|
||||
if (!empty($user['totp_secret'])) {
|
||||
Logger::warning('Setup-Anmeldung mit TOTP-Konto abgelehnt', [
|
||||
// ------------------------------------------------------------------
|
||||
// Zweiter Faktor
|
||||
// ------------------------------------------------------------------
|
||||
// Bis 2.9.2 stand hier nur die Pruefung auf totp_secret - eine Spalte,
|
||||
// die von KEINEM Anmeldeweg ausgewertet wird. Der tatsaechlich
|
||||
// umgesetzte zweite Faktor liegt in dc_user_credentials, und der
|
||||
// wurde hier nicht geprueft.
|
||||
//
|
||||
// Die Folge war eine offene Tuer neben der verschlossenen: Wer ein
|
||||
// Administratorpasswort hatte, bekam hier ohne Hardwarekey ein
|
||||
// gueltiges setup:install-Token und konnte damit dauerhafte Tokens
|
||||
// fuer jedes Projekt ausstellen. Die Aussage "dieses Konto ist durch
|
||||
// einen zweiten Faktor geschuetzt" stimmte damit nicht mehr - und
|
||||
// eine Aussage ueber Sicherheit, die nur fuer einen von zwei Wegen
|
||||
// gilt, ist keine.
|
||||
if (!empty($user['totp_secret']) || Auth::secondFactorRequired($db, (int)$user['id'])) {
|
||||
Logger::warning('Setup-Anmeldung eines Kontos mit zweitem Faktor abgelehnt', [
|
||||
'username' => $username,
|
||||
'ip' => Http::clientIp(),
|
||||
]);
|
||||
SecurityLogger::log('setup_login_second_factor', Http::clientIp(), [
|
||||
'username' => $username,
|
||||
]);
|
||||
Http::fail(
|
||||
501,
|
||||
'totp_not_supported',
|
||||
403,
|
||||
'second_factor_required',
|
||||
'Fuer dieses Konto ist ein zweiter Faktor hinterlegt. Die Setup-Anmeldung '
|
||||
. 'unterstuetzt ihn noch nicht - bitte ein eigenes Installationskonto verwenden.'
|
||||
. 'unterstuetzt ihn nicht - bitte ein eigenes Installationskonto verwenden.'
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Rollentrennung, und zwar symmetrisch
|
||||
// ------------------------------------------------------------------
|
||||
// Der Docblock dieses Endpunkts sagte seit jeher, gedacht sei er fuer
|
||||
// Konten der Rolle "installer" - erzwungen hat das nichts: "Anmelden
|
||||
// darf sich hier jedes Konto."
|
||||
//
|
||||
// startLogin() haelt Installer umgekehrt schon immer vom WebUI fern.
|
||||
// Die Trennung galt also nur in eine Richtung, und die
|
||||
// unbeaufsichtigte Richtung war die gefaehrlichere: Ein Zielsystem,
|
||||
// auf dem Installationsdaten eingegeben werden, ist der Ort, an dem
|
||||
// sie am ehesten abhandenkommen.
|
||||
if (Auth::roleOf($user) !== 'installer') {
|
||||
Logger::warning('Setup-Anmeldung eines Nicht-Installer-Kontos abgelehnt', [
|
||||
'username' => $username,
|
||||
'role' => Auth::roleOf($user),
|
||||
'ip' => Http::clientIp(),
|
||||
]);
|
||||
SecurityLogger::log('setup_login_wrong_role', Http::clientIp(), [
|
||||
'username' => $username,
|
||||
'role' => Auth::roleOf($user),
|
||||
]);
|
||||
Http::fail(
|
||||
403,
|
||||
'installer_account_required',
|
||||
'Die Setup-Anmeldung steht nur Konten der Rolle "installer" offen. '
|
||||
. 'Ein solches Konto legen Sie im WebUI unter System -> Benutzerkonten an.'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user