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.'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,100 @@
|
||||
{
|
||||
"_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.9.2",
|
||||
"current": "2.9.3",
|
||||
"versions": [
|
||||
{
|
||||
"version": "2.9.3",
|
||||
"date": "2026-09-21",
|
||||
"summary": "Ergebnis einer Sicherheitsbegutachtung. Die WebAuthn-Zeremonie selbst war in Ordnung - der zweite Faktor liess sich aber DANEBEN umgehen: ueber einen Anmeldeweg, der ihn nicht kannte, ueber eine Pruefung, die bei Datenbankfehlern stillschweigend auf Passwort-allein zurueckfiel, und ueber eine uebernommene Sitzung, die die Anmeldemittel ohne erneute Bestaetigung aendern durfte.",
|
||||
"actionRequired": true,
|
||||
"changes": [
|
||||
{
|
||||
"area": "security",
|
||||
"breaking": true,
|
||||
"title": "Die Setup-API kannte den zweiten Faktor nicht",
|
||||
"text": "POST /api/setup/v1/login pruefte bisher auf totp_secret - eine Spalte, die KEIN Anmeldeweg auswertet. Auf dc_user_credentials, also den tatsaechlich umgesetzten zweiten Faktor, wurde nicht geprueft. Wer ein Administratorpasswort hatte, bekam dort ohne Hardwarekey ein gueltiges setup:install-Token und konnte damit dauerhafte Tokens fuer jedes Projekt ausstellen und Monitore ueberschreiben. Neu werden Konten mit hinterlegtem Hardwarekey abgewiesen, und die Rollentrennung gilt symmetrisch: Installer kommen nicht ins WebUI (das galt schon), Administratoren nicht mehr in die Setup-API. Der Docblock behauptete diese Trennung seit jeher - erzwungen hat sie nichts.",
|
||||
"action": "Wer die Setup-API mit einem Administratorkonto benutzt hat, legt vorher ein Konto der Rolle \"installer\" an (System -> Benutzerkonten)."
|
||||
},
|
||||
{
|
||||
"area": "security",
|
||||
"breaking": false,
|
||||
"title": "Authentifizierung scheitert jetzt geschlossen",
|
||||
"text": "secondFactorRequired(), accountLockoutSeconds() und lockoutSeconds() fingen jedes Throwable und antworteten mit \"kein zweiter Faktor\" bzw. \"nicht gesperrt\". Begruendet war das mit einer fehlenden Tabelle vor Migration 019 - gefangen wurden aber auch Verbindungsabbrueche, Lock-Timeouts, \"too many connections\" und entzogene Tabellenrechte. Jeder dieser Faelle schaltete im Stillen fuer ALLE Konten auf Passwort-allein zurueck und hob gleichzeitig den Brute-Force-Schutz auf. Toleriert werden jetzt nur noch die SQL-Zustaende 42S02 und 42S22 (Tabelle bzw. Spalte unbekannt); alles andere bricht ab. Lieber keine Anmeldung als eine ohne zweiten Faktor.",
|
||||
"action": null
|
||||
},
|
||||
{
|
||||
"area": "security",
|
||||
"breaking": false,
|
||||
"title": "Aenderungen an den Anmeldemitteln verlangen das Passwort",
|
||||
"text": "Passwort setzen, Notfalltoken ausstellen, Hardwarekey aufnehmen oder entfernen, die Hardwarekey-Regel umstellen: Fuer all das genuegte eine lebende Sitzung samt CSRF-Token. Eine einzige uebernommene Sitzung reichte damit, um sich dauerhaft einzunisten - einen eigenen Schluessel zu registrieren, einen Notfalltoken fuer jedes Konto auszustellen und den zweiten Faktor anlagenweit abzuschalten. Der Hardwarekey haette gegen genau den Fall nicht geholfen, gegen den er gedacht ist: Er schuetzt die Anmeldung, nicht die Sitzung, die daraus entsteht. Diese fuenf Aktionen verlangen jetzt eine Authentifizierung aus den letzten zehn Minuten.",
|
||||
"action": null
|
||||
},
|
||||
{
|
||||
"area": "security",
|
||||
"breaking": true,
|
||||
"title": "Leerlauf 30 Minuten - gesperrt statt beendet",
|
||||
"text": "Bisher zwei Stunden, danach wurde die Sitzung vernichtet. NIST SP 800-63B verlangt auf Stufe AAL2 eine erneute Authentifizierung nach 30 Minuten Untaetigkeit; der ganze Aufwand mit dem Hardwarekey hing damit an einer Zahl, die niemand fuer eine Sicherheitsentscheidung hielt. Statt zu vernichten wird jetzt gesperrt: Der Sperrbildschirm oeffnet mit dem PASSWORT allein, derselbe Standard laesst zur Reaktivierung einen Faktor genuegen. Wer alle 30 Minuten Passwort UND Hardwarekey vorzeigen muss, legt den Schluessel irgendwann steckend liegen - eine Regel, die zur Umgehung erzieht, ist schlechter als eine mildere, die eingehalten wird. Das absolute Maximum von 12 Stunden beendet die Sitzung weiterhin wirklich.",
|
||||
"action": null
|
||||
},
|
||||
{
|
||||
"area": "security",
|
||||
"breaking": false,
|
||||
"title": "Ein Passwortwechsel beendet die uebrigen Sitzungen",
|
||||
"text": "Bisher beendete er keine einzige: Wer das Passwort aendert, weil er einen fremden Zugriff vermutet, liess damit genau den drin, dessentwegen er es aendert. PHP legt Sitzungen als Dateien ab, es gibt keinen Index \"alle Sitzungen des Kontos X\". Migration 022 loest das mit einer Spalte session_epoch: Jede Sitzung merkt sich beim Anmelden den Stand, ein Passwortwechsel erhoeht ihn, alle uebrigen passen nicht mehr. Die eigene Sitzung uebernimmt den neuen Stand - wer sein Passwort aendert, soll sich nicht selbst hinauswerfen.",
|
||||
"action": "Migration 022 ausfuehren."
|
||||
},
|
||||
{
|
||||
"area": "security",
|
||||
"breaking": false,
|
||||
"title": "Sperren greifen jetzt auch auf der zweiten Stufe",
|
||||
"text": "pendingUserId() pruefte nur das 5-Minuten-Fenster, completeSecondFactor() nur disabled. Ein waehrend des Ratens gesperrtes Konto konnte die Anmeldung trotzdem abschliessen, und innerhalb des offenen Zwischenzustands durfte unbegrenzt geraten werden - obwohl der Kommentar an recordSecondFactorFailure genau das zu verhindern versprach. Neu: Kontosperre wird beim Abschluss geprueft, und nach fuenf Fehlversuchen faellt der Zwischenzustand. Praktisch war das folgenlos (der Notfalltoken hat rund 100 Bit und ist bcrypt-gehasht), die Kontrolle fehlte aber.",
|
||||
"action": null
|
||||
},
|
||||
{
|
||||
"area": "security",
|
||||
"breaking": false,
|
||||
"title": "Drosselung und keine Namens-Enumeration am Login",
|
||||
"text": "Der RateLimiter war im Haus und auf acht Endpunkten im Einsatz - ausgerechnet login.php und /api/auth/v1/ waren ohne. Neu ein Eimer webui_login mit 10 Versuchen pro Minute. Ausserdem meldete die Anmeldung \"Dieses Benutzerkonto ist gesperrt\" gegen \"Ungueltige Anmeldedaten\" - gesperrt wird aber nur, was existiert, also verrieten fuenf Versuche pro Name, ob es ihn gibt. Die sorgfaeltige Laufzeitangleichung ueber dummyHash() war damit wieder aufgehoben. Jetzt eine Meldung fuer alle Faelle; die Wartezeit darf darin stehen, der Grund nicht. Die IP-Sperre wird weiterhin benannt - sie sagt etwas ueber das Netzwerk aus, nicht ueber ein Konto.",
|
||||
"action": null
|
||||
},
|
||||
{
|
||||
"area": "security",
|
||||
"breaking": false,
|
||||
"title": "HSTS, TLS-Zwang und eine ehrliche CSP",
|
||||
"text": "Neu: Strict-Transport-Security, eine Umleitung von http auf https VOR allen Routen (dahinter waere sie fuer login.php und die gesamte API nie erreicht worden, weil alle Routen [L] tragen), und eine Content-Security-Policy. Die CSP enthaelt 'unsafe-inline' fuer script-src, und das ist keine Nachlaessigkeit, sondern eine Tatsache ueber den Bestand: index.php hat 93 Inline-Handler und mehrere tausend Zeilen Inline-Skript. Sie schuetzt deshalb NICHT gegen XSS, sondern gegen Skript von fremden Adressen, Einbettung, umgebogenes <base>, fremde Formularziele und Plugins. Das hinzuschreiben ist wichtiger, als es zu verschweigen - ein CSP-Header laedt sonst dazu ein, XSS fuer erledigt zu halten.",
|
||||
"action": null
|
||||
},
|
||||
{
|
||||
"area": "security",
|
||||
"breaking": false,
|
||||
"title": "Sitzungshaertung und der WebAuthn-Anker",
|
||||
"text": "session.use_strict_mode ist gesetzt (PHP-Vorgabe ist aus - der Server uebernahm jede vom Client erfundene Sitzungskennung). Das CSRF-Token wird bei der Anmeldung verworfen, statt die Regeneration zu ueberleben. Das Secure-Flag des Cookies haengt nicht mehr an $_SERVER['HTTPS'], das hinter einem TLS-terminierenden Proxy leer ist, sondern an app.url. Und rpId()/expectedOrigin() leiten sich nicht mehr aus dem Host-Header ab: Aus diesem Wert entstehen die beiden Anker, gegen die die ganze Zeremonie geprueft wird - wer den Header setzt, bestimmte sonst selbst, wogegen seine Anmeldung geprueft wird.",
|
||||
"action": "app.url MUSS in der config.php gesetzt sein, sonst wird der Hardwarekey abgewiesen. /api/health meldet das unter checks.webauthn_anchor."
|
||||
},
|
||||
{
|
||||
"area": "security",
|
||||
"breaking": false,
|
||||
"title": "Notfalltoken laeuft nach 180 Tagen ab",
|
||||
"text": "recovery_created_at wurde seit 2.9.0 geschrieben und nie ausgewertet. Ein vor zwei Jahren auf einen Zettel geschriebener Token war weiterhin ein vollwertiger Ersatz fuer den Hardwarekey - ein zweiter Faktor, der nicht altert, waehrend der Zettel es tut. Geprueft wird NACH dem Hashvergleich, damit die Antwortzeit nicht verraet, ob ein Token existiert. Die Oberflaeche zeigt jetzt die Restlaufzeit und warnt 30 Tage vorher.",
|
||||
"action": "Notfalltoken, die aelter als 180 Tage sind, neu erzeugen."
|
||||
},
|
||||
{
|
||||
"area": "security",
|
||||
"breaking": false,
|
||||
"title": "Kleinkram mit Folgen",
|
||||
"text": "config/config.example.php enthielt ein echt aussehendes Rocket.Chat-Passwort und verify_ssl => false als Vorgabe - eine Beispieldatei liegt im Repository, und damit lag das Passwort dort auch. Beides bereinigt. Die .htaccess sperrte Serverdaten.txt, aber nicht Serverdaten.txt.bak; deploy.py schliesst beide aus, die zweite Verteidigungslinie muss aber zur ersten passen. dc_login_attempts wurde nie aufgeraeumt - eine Tabelle, die nur waechst und bei JEDER Anmeldung gelesen wird, wird irgendwann zu dem Problem, das die Anmeldung langsam macht; jetzt raeumt ein Hundertstel der Anmeldungen beilaeufig auf.",
|
||||
"action": "Das Rocket.Chat-Passwort rotieren - es stand im Repository."
|
||||
},
|
||||
{
|
||||
"area": "tests",
|
||||
"breaking": false,
|
||||
"title": "Die Setup-Testfaelle pruefen jetzt die Trennung",
|
||||
"text": "Sie meldeten sich bisher mit dem Administratorkonto an - sie bildeten also gerade den Weg ab, den es nicht geben sollte. testctl legt beim Zuruecksetzen ein Installationskonto an, die Faelle benutzen es, und ein neuer Fall prueft ausdruecklich, dass ein Administratorkonto dort abgewiesen wird. 185 von 185 gruen.",
|
||||
"action": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.9.2",
|
||||
"date": "2026-09-21",
|
||||
|
||||
+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;
|
||||
|
||||
+116
-6
@@ -10,6 +10,7 @@ use Deploymentcenter\Core\Db;
|
||||
use Deploymentcenter\Core\Http;
|
||||
use Deploymentcenter\Core\Logger;
|
||||
use Deploymentcenter\Core\Webauthn\CredentialRepo;
|
||||
use Deploymentcenter\Modules\License\RateLimiter;
|
||||
|
||||
Auth::startSession();
|
||||
|
||||
@@ -21,6 +22,61 @@ if (Auth::isLoggedIn()) {
|
||||
$error = null;
|
||||
$notice = null;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Sperrbildschirm
|
||||
//
|
||||
// Nach 30 Minuten Leerlauf wird die Sitzung gesperrt, nicht vernichtet
|
||||
// (Auth::IDLE_TIMEOUT). Geoeffnet wird sie mit dem Passwort allein - NIST
|
||||
// SP 800-63B laesst auf Stufe AAL2 zur Reaktivierung einen Faktor genuegen,
|
||||
// und der Hardwarekey fuegte hier nichts hinzu, was der Sitzungscookie nicht
|
||||
// schon behauptet: Wer den Cookie hat, hat das Geraet. Das Passwort hat er
|
||||
// damit nicht.
|
||||
// -----------------------------------------------------------------------
|
||||
$gesperrt = Auth::isLocked();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string)($_POST['step'] ?? '') === 'unlock') {
|
||||
if (!Csrf::isValid(is_string($_POST['csrf_token'] ?? null) ? $_POST['csrf_token'] : null)) {
|
||||
$error = 'Sitzung abgelaufen. Bitte erneut versuchen.';
|
||||
} elseif (!$gesperrt) {
|
||||
header('Location: /login.php');
|
||||
exit;
|
||||
} else {
|
||||
try {
|
||||
$pdo = Db::init();
|
||||
|
||||
// Die IP-Sperre gilt auch hier. Der Sperrbildschirm waere sonst
|
||||
// die Stelle, an der ein Passwort ungezaehlt geraten werden darf.
|
||||
$ipSperre = Auth::lockoutSeconds($pdo, Http::clientIp());
|
||||
|
||||
if ($ipSperre > 0) {
|
||||
$error = sprintf(
|
||||
'Zu viele fehlgeschlagene Versuche von diesem Netzwerk. Bitte in %d Minute(n) erneut versuchen.',
|
||||
(int)ceil($ipSperre / 60)
|
||||
);
|
||||
} elseif (Auth::unlock($pdo, (string)($_POST['password'] ?? ''))) {
|
||||
$ziel = (string)($_POST['return_to'] ?? '');
|
||||
header('Location: ' . (preg_match('#^/[A-Za-z0-9_./#?=-]*$#', $ziel) ? $ziel : '/index.php'));
|
||||
exit;
|
||||
} else {
|
||||
Auth::recordFailedAttempt($pdo, Http::clientIp(), Auth::lockedUsername());
|
||||
$error = 'Das Passwort stimmt nicht.';
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Logger::error('Entsperren fehlgeschlagen', ['error' => $e->getMessage()]);
|
||||
$error = 'Entsperren derzeit nicht moeglich. Bitte spaeter erneut versuchen.';
|
||||
}
|
||||
}
|
||||
|
||||
$gesperrt = Auth::isLocked();
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string)($_POST['step'] ?? '') === 'abandon') {
|
||||
// Bewusst abmelden statt entsperren.
|
||||
Auth::logout();
|
||||
header('Location: /login.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Zweite Stufe: Notfalltoken und Abbruch
|
||||
//
|
||||
@@ -85,17 +141,35 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $step === '') {
|
||||
} else {
|
||||
try {
|
||||
$pdo = Db::init();
|
||||
|
||||
// Drosselung vor allem anderen. Die Kontosperre greift erst ab
|
||||
// dem fuenften, die IP-Sperre ab dem zehnten Fehlversuch - bis
|
||||
// dahin durfte bisher beliebig schnell geraten werden. Der
|
||||
// RateLimiter ist im Haus und auf acht anderen Endpunkten im
|
||||
// Einsatz; ausgerechnet die Anmeldung war ohne.
|
||||
$drossel = new RateLimiter($pdo, 10, 60, 'webui_login');
|
||||
|
||||
$ipLockout = Auth::lockoutSeconds($pdo, Http::clientIp());
|
||||
$accLockout = Auth::accountLockoutSeconds($pdo, $username);
|
||||
|
||||
if ($ipLockout > 0) {
|
||||
if (!$drossel->check(Http::clientIp())) {
|
||||
$error = 'Zu viele Anmeldeversuche. Bitte eine Minute warten.';
|
||||
} elseif ($ipLockout > 0) {
|
||||
// Die IP-Sperre darf beim Namen genannt werden: Sie sagt
|
||||
// etwas ueber das Netzwerk aus, nicht darueber, ob es ein
|
||||
// bestimmtes Konto gibt.
|
||||
$error = sprintf(
|
||||
'Zu viele fehlgeschlagene Anmeldeversuche von diesem Netzwerk. Bitte in %d Minute(n) erneut versuchen.',
|
||||
(int)ceil($ipLockout / 60)
|
||||
);
|
||||
} elseif ($accLockout > 0) {
|
||||
// Die KONTOsperre dagegen nicht. Gesperrt wird nur, was
|
||||
// existiert - eine eigene Meldung dafuer verriete also die
|
||||
// Existenz des Namens und haette die Laufzeitangleichung
|
||||
// ueber dummyHash() in verifyCredentials() wieder aufgehoben.
|
||||
// Die Wartezeit darf stehen, der Grund nicht.
|
||||
$error = sprintf(
|
||||
'Dieses Benutzerkonto ist aufgrund zu vieler Fehlversuche vorübergehend gesperrt. Bitte in %d Minute(n) erneut versuchen.',
|
||||
'Anmeldung nicht moeglich. Bitte in %d Minute(n) erneut versuchen.',
|
||||
(int)ceil($accLockout / 60)
|
||||
);
|
||||
} else {
|
||||
@@ -121,8 +195,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $step === '') {
|
||||
$newIpLockout = Auth::lockoutSeconds($pdo, Http::clientIp());
|
||||
|
||||
if ($newAccLockout > 0) {
|
||||
// Wieder ohne den Grund: "dieses Benutzerkonto wurde
|
||||
// gesperrt" ist eine Bestaetigung, dass es das Konto
|
||||
// gibt.
|
||||
$error = sprintf(
|
||||
'Zu viele Fehlversuche. Dieses Benutzerkonto wurde für %d Minute(n) gesperrt.',
|
||||
'Anmeldung nicht moeglich. Bitte in %d Minute(n) erneut versuchen.',
|
||||
(int)ceil($newAccLockout / 60)
|
||||
);
|
||||
} elseif ($newIpLockout > 0) {
|
||||
@@ -131,7 +208,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $step === '') {
|
||||
(int)ceil($newIpLockout / 60)
|
||||
);
|
||||
} else {
|
||||
$error = 'Ungueltige Anmeldedaten.';
|
||||
$error = 'Anmeldung nicht moeglich.';
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
@@ -419,7 +496,11 @@ $pendingUsername = Auth::pendingUsername();
|
||||
</div>
|
||||
<h1 class="brand-title">Deploymentcenter</h1>
|
||||
<p class="brand-subtitle">
|
||||
<?= $pendingUsername !== '' ? 'Zweiter Schritt: Hardwarekey' : 'Bitte melden Sie sich an, um fortzufahren' ?>
|
||||
<?php if ($gesperrt): ?>
|
||||
Sitzung gesperrt
|
||||
<?php else: ?>
|
||||
<?= $pendingUsername !== '' ? 'Zweiter Schritt: Hardwarekey' : 'Bitte melden Sie sich an, um fortzufahren' ?>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -427,7 +508,36 @@ $pendingUsername = Auth::pendingUsername();
|
||||
<div class="alert-danger"><?= htmlspecialchars($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($pendingUsername !== ''): ?>
|
||||
<?php if ($gesperrt): ?>
|
||||
|
||||
<div class="step-note">
|
||||
Die Sitzung von <strong><?= htmlspecialchars(Auth::lockedUsername()) ?></strong> wurde
|
||||
nach 30 Minuten ohne Aktivität gesperrt. Zum Weiterarbeiten genügt das Passwort —
|
||||
der Hardwarekey wird erst bei einer neuen Anmeldung wieder verlangt.
|
||||
</div>
|
||||
|
||||
<form method="POST" action="login.php">
|
||||
<?= Csrf::field() ?>
|
||||
<input type="hidden" name="step" value="unlock">
|
||||
<input type="hidden" name="return_to"
|
||||
value="<?= htmlspecialchars((string)($_GET['weiter'] ?? '/index.php'), ENT_QUOTES) ?>">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="unlockPassword">Passwort</label>
|
||||
<input type="password" id="unlockPassword" name="password"
|
||||
class="form-control" required autofocus placeholder="••••••••">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-login">Weiterarbeiten</button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="login.php" class="cancel-form">
|
||||
<?= Csrf::field() ?>
|
||||
<input type="hidden" name="step" value="abandon">
|
||||
<button type="submit" class="btn-cancel">Stattdessen abmelden</button>
|
||||
</form>
|
||||
|
||||
<?php elseif ($pendingUsername !== ''): ?>
|
||||
|
||||
<div class="step-note">
|
||||
Passwort erkannt für <strong><?= htmlspecialchars($pendingUsername) ?></strong>.
|
||||
|
||||
Reference in New Issue
Block a user