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:
Deploymentcenter Bot
2026-09-21 14:58:31 +02:00
co-authored by Claude Opus 5
parent 0c20698fd5
commit 91d8bf353e
20 changed files with 1571 additions and 93 deletions
+59 -2
View File
@@ -10,6 +10,24 @@ Options -Indexes
<IfModule mod_rewrite.c> <IfModule mod_rewrite.c>
RewriteEngine On RewriteEngine On
# ------------------------------------------------------------------
# 0. TLS erzwingen - und zwar VOR jeder Route
# ------------------------------------------------------------------
# Die Stelle ist der Punkt. Alle Routen darunter tragen [L]; stuende die
# Umleitung dahinter, wuerde sie fuer genau die Adressen nie erreicht,
# die es betrifft - login.php, index.php, die gesamte API.
#
# Ergaenzt HSTS, ersetzt es nicht: HSTS wirkt erst, nachdem der Browser
# die Anlage einmal ueber TLS gesehen hat. Die erste Anfrage ist genau
# die, die abgefangen wuerde, und die faengt diese Regel ab.
#
# Die zweite Bedingung gilt TLS-terminierenden Zwischenstellen: Dort ist
# %{HTTPS} leer, obwohl der Besucher verschluesselt spricht. Ohne sie
# liefe die Umleitung im Kreis.
RewriteCond %{HTTPS} !=on
RewriteCond %{HTTP:X-Forwarded-Proto} !=https
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
# Bereits umgeschriebene Anfragen nicht erneut anfassen. # Bereits umgeschriebene Anfragen nicht erneut anfassen.
RewriteRule ^public/ - [L] RewriteRule ^public/ - [L]
@@ -74,7 +92,11 @@ Options -Indexes
# 5. Alles Uebrige sperren: Anwendungscode, Konfiguration, Skripte # 5. Alles Uebrige sperren: Anwendungscode, Konfiguration, Skripte
# ------------------------------------------------------------------ # ------------------------------------------------------------------
RewriteRule ^(config|src|sql|scripts|cli|var|client-dotnet|client-php|mcp)(/|$) - [F,L] RewriteRule ^(config|src|sql|scripts|cli|var|client-dotnet|client-php|mcp)(/|$) - [F,L]
RewriteRule ^Serverdaten\.txt$ - [F,L] # Auch die Sicherungskopie. deploy.py schliesst beide aus, die Datei
# sollte also nie hier liegen - aber die zweite Verteidigungslinie muss
# zur ersten passen, sonst deckt sie genau den Fall nicht ab, fuer den es
# sie gibt.
RewriteRule ^Serverdaten\.txt(\..*)?$ - [F,L]
</IfModule> </IfModule>
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
@@ -85,12 +107,47 @@ Options -Indexes
Header always set X-Frame-Options "SAMEORIGIN" Header always set X-Frame-Options "SAMEORIGIN"
Header always set Referrer-Policy "strict-origin-when-cross-origin" Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()" Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
# HSTS: Nach dem ersten Besuch spricht der Browser diese Adresse nur noch
# ueber TLS an - auch dann, wenn jemand einen http-Link unterschiebt. Die
# Umleitung weiter unten schuetzt erst ab der zweiten Anfrage; die erste
# ist genau die, die abgefangen wird.
#
# Ohne preload und ohne includeSubDomains waere es die halbe Wirkung; mit
# includeSubDomains gilt es fuer jede Unteradresse von mhdf.de. Wer hier
# eine Unteradresse ohne TLS betreibt, macht sie damit unerreichbar - das
# ist der Preis und der Grund, warum die Zeile bewusst hier steht und
# nicht in einer Vorlage.
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
# ------------------------------------------------------------------
# Content-Security-Policy - und was sie hier NICHT leistet
# ------------------------------------------------------------------
# 'unsafe-inline' steht in script-src, und das ist keine Nachlaessigkeit,
# sondern eine Tatsache ueber den Bestand: index.php enthaelt 93 Inline-
# Handler (onclick, onsubmit, onchange) und mehrere tausend Zeilen
# Inline-Skript. Ohne 'unsafe-inline' funktioniert die Oberflaeche nicht -
# mit ihr schuetzt die Richtlinie nicht gegen eingeschleustes Skript.
#
# Das ehrlich hinzuschreiben ist wichtiger, als es zu verschweigen: Eine
# CSP im Header laedt sonst dazu ein, XSS fuer erledigt zu halten. Was
# sie hier WIRKLICH leistet:
# - kein Skript von fremden Adressen (Exfiltration ueber <script src>)
# - keine Einbettung in fremde Seiten (frame-ancestors)
# - kein umgebogenes <base> (base-uri)
# - keine Formularziele ausserhalb der Anlage (form-action)
# - keine Plugins (object-src)
#
# Eine Richtlinie mit Nonce, die auch gegen XSS traegt, verlangt das
# Herausloesen aller Inline-Handler. Das ist ein eigenes Vorhaben und
# kein Header.
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'"
</IfModule> </IfModule>
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# Dateischutz auch ohne mod_rewrite # Dateischutz auch ohne mod_rewrite
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
<FilesMatch "^(Serverdaten\.txt|\.env.*|composer\.(json|lock)|deploy_config\.json)$"> <FilesMatch "^(Serverdaten\.txt(\..*)?|\.env.*|composer\.(json|lock)|deploy_config\.json)$">
<IfModule mod_authz_core.c> <IfModule mod_authz_core.c>
Require all denied Require all denied
</IfModule> </IfModule>
+8 -2
View File
@@ -84,10 +84,16 @@ return [
'enabled' => (bool)dc_env('DC_RC_ENABLED', true), 'enabled' => (bool)dc_env('DC_RC_ENABLED', true),
'url' => dc_env('DC_RC_URL', 'https://chat.wh1.mhdf.de'), 'url' => dc_env('DC_RC_URL', 'https://chat.wh1.mhdf.de'),
'username' => dc_env('DC_RC_USER', 'deploymentcenter'), 'username' => dc_env('DC_RC_USER', 'deploymentcenter'),
'password' => dc_env('DC_RC_PASS', 'cNt.m.KcWHrb8_X9Tv8-'), // Leer lassen und ueber die Umgebungsvariable setzen. In dieser
// Datei stand bis 2.9.3 ein echtes Passwort - eine Beispieldatei
// liegt im Repository, und damit lag es dort auch.
'password' => dc_env('DC_RC_PASS', ''),
'status_channel' => dc_env('DC_RC_STATUS_CHANNEL', '#DC-Systemstatus'), 'status_channel' => dc_env('DC_RC_STATUS_CHANNEL', '#DC-Systemstatus'),
'alert_channel' => dc_env('DC_RC_ALERT_CHANNEL', '#DC-Alerts'), 'alert_channel' => dc_env('DC_RC_ALERT_CHANNEL', '#DC-Alerts'),
'verify_ssl' => (bool)dc_env('DC_RC_VERIFY_SSL', false), // Vorgabe true. Eine abgeschaltete Zertifikatspruefung ist in einer
// Beispieldatei die denkbar schlechteste Vorgabe: Sie wird
// uebernommen, ohne dass jemand die Entscheidung trifft.
'verify_ssl' => (bool)dc_env('DC_RC_VERIFY_SSL', true),
'report_interval' => (int)dc_env('DC_RC_REPORT_INTERVAL', 43200), 'report_interval' => (int)dc_env('DC_RC_REPORT_INTERVAL', 43200),
], ],
]; ];
+158 -2
View File
@@ -1,6 +1,6 @@
# Hardwarekey als zweiter Faktor — Konzept und Übernahme # Hardwarekey als zweiter Faktor — Konzept und Übernahme
**Stand: Deploymentcenter 2.9.2, 21. September 2026** **Stand: Deploymentcenter 2.9.3, 21. September 2026**
Dieses Dokument beschreibt die Hardwarekey-Anmeldung so, wie sie im Dieses Dokument beschreibt die Hardwarekey-Anmeldung so, wie sie im
Deploymentcenter läuft, und zwar mit dem Ziel, sie in einem anderen Projekt Deploymentcenter läuft, und zwar mit dem Ziel, sie in einem anderen Projekt
@@ -83,7 +83,20 @@ nichts.
> Kette bis zu einer Wurzel, deren privaten Schlüssel der Hersteller hat.** > Kette bis zu einer Wurzel, deren privaten Schlüssel der Hersteller hat.**
> Alles andere ist abschreibbar. > Alles andere ist abschreibbar.
### 2.4 Und die AAGUID? ### 2.4 Und die Wege NEBEN der Zeremonie
Die drei Fallen oben sind Fallen *innerhalb* der Zeremonie. Eine
Sicherheitsbegutachtung am 21.09.2026 fand die schwerere Klasse: Die Zeremonie
war korrekt, der zweite Faktor trotzdem umgehbar — **daneben**. Ausführlich in
§12; in einem Satz:
> Ein zweiter Faktor ist nur so stark wie der schwächste Weg zum Konto,
> und Sitzungen, die aus ihm entstehen, erben seinen Schutz nicht.
Wer §5 umsetzt und §12 überspringt, hat eine einwandfreie Zeremonie mit einer
Tür daneben.
### 2.5 Und die AAGUID?
Eine AAGUID ist eine **Behauptung im Datenstrom**. Als alleiniges Kriterium Eine AAGUID ist eine **Behauptung im Datenstrom**. Als alleiniges Kriterium
wertlos. Sinnvoll erst *zusätzlich* zur Wurzelprüfung — und dann muss die wertlos. Sinnvoll erst *zusätzlich* zur Wurzelprüfung — und dann muss die
@@ -451,6 +464,24 @@ Eine Umsetzung gilt erst als fertig, wenn **alle** Punkte belegt sind:
Schlüssel wegen UNIQUE dauerhaft verbrannt)* Schlüssel wegen UNIQUE dauerhaft verbrannt)*
- [ ] Fehlermeldungen kommen beim Benutzer **lesbar** an - [ ] Fehlermeldungen kommen beim Benutzer **lesbar** an
Und die Wege daneben (§12) — ohne diese ist die Zeremonie eine Tür neben
einer offenen:
- [ ] **Jeder** Anmeldepfad prüft den zweiten Faktor. Zählen Sie sie auf; was
Sie nicht vollständig aufschreiben können, ist der Befund
- [ ] Kein `catch (\Throwable)` in einem Authentifizierungspfad, der auf
„kein Faktor" oder „nicht gesperrt" zurückfällt
- [ ] Änderungen an den Anmeldemitteln verlangen eine frische
Authentifizierung
- [ ] Ein Passwortwechsel beendet die übrigen Sitzungen des Kontos
- [ ] Leerlauf sperrt (ein Faktor genügt zum Öffnen), Absolutgrenze beendet
- [ ] `rpId`/`origin` stammen aus der Konfiguration, nicht aus dem
Host-Header — und eine Anzeige sagt vorher, ob sie gesetzt sind
- [ ] Der Notfalltoken altert; das Alter wird **nach** dem Hashvergleich
geprüft
- [ ] Anmeldefehler sind ununterscheidbar (falscher Name, falsches Passwort,
gesperrt, deaktiviert)
Der letzte Punkt steht nicht aus Höflichkeit dort: Genau hier hatte diese Der letzte Punkt steht nicht aus Höflichkeit dort: Genau hier hatte diese
Umsetzung einen Fehler. Die API verpackt Fehler als Umsetzung einen Fehler. Die API verpackt Fehler als
`{ error: { code, message } }`, das JavaScript las `data.message || `{ error: { code, message } }`, das JavaScript las `data.message ||
@@ -474,3 +505,128 @@ Begründung der Ablehnung stehen sollte.
| `sql/migrations/019_*.sql`, `021_*.sql` | Datenmodell | | `sql/migrations/019_*.sql`, `021_*.sql` | Datenmodell |
| `tests/e2e/suite_hardware.py` | Fünf Fälle, ohne Krypto-Bibliothek | | `tests/e2e/suite_hardware.py` | Fünf Fälle, ohne Krypto-Bibliothek |
| `docs/UPGRADE.md` §17, §18 | Einführung und Verschärfung im Betrieb | | `docs/UPGRADE.md` §17, §18 | Einführung und Verschärfung im Betrieb |
---
## 12. Die Wege neben der Zeremonie
Alles hier stammt aus einer Begutachtung des fertigen Verfahrens. Die
Kryptographie musste dafür nicht angefasst werden — sie war in Ordnung. Jeder
Punkt ist unabhängig von der Sprache und gilt im Zielprojekt genauso.
### 12.1 Jeder Anmeldeweg muss den Faktor kennen
Das Deploymentcenter hat neben dem WebUI eine zweite Anmeldung für
Erstinstallationen. Sie prüfte ein TOTP-Feld, das **kein** Anmeldeweg
auswertet — und nicht die Tabelle, in der der tatsächlich umgesetzte zweite
Faktor liegt. Wer ein Administratorpasswort hatte, bekam dort ohne
Hardwarekey ein gültiges Token.
```php
if (!empty($user['totp_secret']) || Auth::secondFactorRequired($db, (int)$user['id'])) {
Http::fail(403, 'second_factor_required', '...');
}
if (Auth::roleOf($user) !== 'installer') {
Http::fail(403, 'installer_account_required', '...');
}
```
**Prüffrage fürs Zielprojekt:** Zählen Sie *alle* Stellen auf, an denen ein
Passwort gegen ein Konto geprüft wird. Jede einzelne muss den zweiten Faktor
kennen oder das Konto ablehnen. Eine Liste, die Sie nicht vollständig
aufschreiben können, ist der Befund.
**Und die Trennung symmetrisch:** Der Docblock behauptete seit jeher, dieser
Weg sei „für Installationskonten gedacht" — erzwungen hat das nichts. Der
umgekehrte Fall war dagegen geprüft. Eine Trennung, die nur in eine Richtung
wirkt, ist eine Notiz, keine Trennung.
### 12.2 Authentifizierung muss geschlossen scheitern
```php
} catch (\PDOException $e) {
if ($e->getCode() === '42S02' || $e->getCode() === '42S22') {
return false; // Migration steht aus - bekannt, harmlos
}
throw $e; // alles andere: abbrechen
}
```
Vorher stand dort `catch (\Throwable)` mit `return false`. Begründet war das
mit einer fehlenden Tabelle vor der Migration — gefangen wurden aber auch
Verbindungsabbrüche, Lock-Timeouts, „too many connections" und entzogene
Rechte. Jeder davon schaltete still für **alle** Konten auf Passwort-allein
zurück, und dieselbe Bauart hob bei `lockoutSeconds()` gleichzeitig den
Brute-Force-Schutz auf.
**Die Regel:** In einem Authentifizierungspfad darf ein `catch` nur die
Fehler schlucken, die Sie *benennen* können. Lieber keine Anmeldung als eine
ohne zweiten Faktor.
### 12.3 Die Sitzung erbt den Schutz nicht
Ein Hardwarekey schützt die **Anmeldung**. Er schützt nicht die Sitzung, die
daraus entsteht. Solange eine übernommene Sitzung die Anmeldemittel ändern
darf, ist er gegen genau den Fall wirkungslos, gegen den er gedacht ist:
- eigenen Schlüssel registrieren → dauerhafter Zugang, fällt niemandem auf
- Notfalltoken ausstellen → Ersatzweg in fremder Hand
- Regel abschalten → zweiter Faktor anlagenweit weg
Diese Aktionen brauchen eine Authentifizierung aus den letzten Minuten
(„sudo mode"). Im Deploymentcenter sind das zehn Minuten und **ein**
Bildschirm — derselbe, den auch der Leerlauf benutzt. Zwei Formulare, die
beide ein Passwort prüfen, laufen mit der Zeit auseinander.
### 12.4 Ein Passwortwechsel muss fremde Sitzungen beenden
Sonst lässt er genau den drin, dessentwegen man ihn vornimmt. PHP führt
keinen Index „alle Sitzungen des Kontos X"; eine Zählergeneration löst es mit
einer Spalte:
```sql
ALTER TABLE users ADD COLUMN session_epoch INT NOT NULL DEFAULT 0;
```
Die Sitzung merkt sich den Stand beim Anmelden, jede Anfrage vergleicht, der
Wechsel erhöht. Die eigene Sitzung übernimmt den neuen Stand — wer sein
Passwort ändert, soll sich nicht selbst hinauswerfen. Ein **Zähler**, keine
Uhr: kein Zeitzonen- und kein Gleichstandsproblem.
### 12.5 Leerlauf: sperren, nicht beenden
AAL2 verlangt erneute Authentifizierung nach 30 Minuten Untätigkeit und lässt
zur Reaktivierung **einen** Faktor genügen. Wer stattdessen die volle
Anmeldung erzwingt, erzieht dazu, den Schlüssel steckend liegen zu lassen —
und dann ist der zweite Faktor keiner mehr. Eine Regel, die zur Umgehung
erzieht, ist schlechter als eine mildere, die eingehalten wird.
Eine gesperrte Sitzung darf sich außerdem **nicht selbst verlängern**, sonst
hält ein offener Tab mit Hintergrundabrufen die Sperre ewig auf.
### 12.6 Der Anker darf nicht vom Aufrufer kommen
`rpId` und `expectedOrigin` aus dem Host-Header abzuleiten heißt, den
Maßstab der Prüfung vom Geprüften setzen zu lassen. Fest konfigurieren, und
bei fehlender Konfiguration **werfen** statt raten. Dazu eine Anzeige, die
das sichtbar macht, bevor jemand sich anmeldet — sonst tauschen Sie eine
stille Schwäche gegen eine stille Aussperrung.
### 12.7 Kleinigkeiten mit Hebelwirkung
- **Fehlermeldungen am Login vereinheitlichen.** Gesperrt wird nur, was
existiert; eine eigene Meldung „Konto gesperrt" hebt jede sorgfältige
Laufzeitangleichung wieder auf. Die Wartezeit darf darin stehen, der Grund
nicht. Die IP-Sperre darf benannt werden — sie sagt nichts über ein Konto.
- **Drosselung auf den Anmeldepfaden.** Kontosperren greifen erst nach N
Versuchen; bis dahin darf sonst beliebig schnell geraten werden.
- **`session.use_strict_mode`.** PHP-Vorgabe ist *aus* — der Server übernimmt
jede vom Client erfundene Sitzungskennung.
- **CSRF-Token bei der Anmeldung verwerfen.** Es liegt in der Session und
überlebt `session_regenerate_id()` sonst.
- **Notfalltoken altern lassen.** Ein Zettel im Schrank altert; ein Token
ohne Ablauf nicht. Alter **nach** dem Hashvergleich prüfen, sonst verrät die
Antwortzeit, ob einer existiert.
- **Sperren auch auf der zweiten Stufe.** Sonst ist der Zwischenzustand
zwischen Passwort und Faktor die Stelle, an der unbegrenzt geraten werden
darf — also die schwächste, obwohl sie die stärkere sein soll.
+97
View File
@@ -785,3 +785,100 @@ aushebelbar zu machen.
Schlüssel am eigenen Konto hängt Schlüssel am eigenen Konto hängt
- [ ] Nach `strict`: Anmeldung mit dem Yubikey funktioniert weiter - [ ] Nach `strict`: Anmeldung mit dem Yubikey funktioniert weiter
- [ ] `python tests/e2e/run.py --gruppe hardware`: alles grün - [ ] `python tests/e2e/run.py --gruppe hardware`: alles grün
---
## 19. Umstellung auf 2.9.3 — Ergebnis der Sicherheitsbegutachtung
### 19.1 Was gefunden wurde
Die WebAuthn-Zeremonie selbst war in Ordnung. Der zweite Faktor ließ sich
trotzdem umgehen — **nicht in der Zeremonie, sondern daneben**:
| Weg | Was möglich war |
|---|---|
| `/api/setup/v1/login` | Passwort allein genügte für ein `setup:install`-Token — der Endpunkt prüfte `totp_secret`, eine Spalte, die kein Anmeldeweg auswertet |
| Datenbankfehler | `secondFactorRequired()` fing jedes `Throwable` und antwortete „kein zweiter Faktor" — ein Lock-Timeout schaltete die Anlage still auf Passwort-allein |
| Übernommene Sitzung | Konnte einen eigenen Hardwarekey registrieren, Notfalltokens ausstellen und die Regel abschalten — ohne eine einzige erneute Bestätigung |
Der rote Faden: Ein zweiter Faktor ist nur so stark wie der **schwächste Weg
zum Konto**. Zwei der drei Befunde waren Stellen, an denen ein Kommentar eine
Zusicherung behauptete, die der Code nicht einlöste.
### 19.2 Vor dem Deployment prüfen
> **`app.url` muss in der `config.php` gesetzt sein.**
`rpId()` und `expectedOrigin()` leiten sich nicht mehr aus dem Host-Header ab —
aus diesem Wert entstehen die beiden Anker, gegen die die ganze Zeremonie
geprüft wird. Fehlt `app.url` **und** `security.webauthn_rp_id`, wird der
Hardwarekey abgewiesen.
```bash
curl -s https://dc.mhdf.de/api/health | grep -o '"webauthn_anchor":{[^}]*}'
```
`"ok":true` — alles in Ordnung. `"ok":false` — vor dem Deployment eintragen:
```php
'app' => ['url' => 'https://dc.mhdf.de'],
```
### 19.3 Migration
```bash
php cli/migrate.php # oder System → 🗄️ DB Migration
```
Migration 022 ergänzt `dc_users.session_epoch`. Bestehende Anmeldungen bleiben
gültig — eine Sicherheitsverbesserung, die im Moment ihrer Einführung alle
Angemeldeten hinauswirft, wird beim nächsten Mal abgeschaltet.
### 19.4 Was sich im Arbeitsablauf ändert
- **Nach 30 Minuten Untätigkeit wird die Sitzung gesperrt**, nicht beendet.
Der Sperrbildschirm öffnet mit dem **Passwort allein** — der Hardwarekey
wird erst bei einer neuen Anmeldung wieder verlangt.
- **Fünf Aktionen verlangen eine Bestätigung**, wenn die letzte
Authentifizierung über zehn Minuten zurückliegt: Passwort setzen,
Notfalltoken ausstellen, Hardwarekey aufnehmen oder entfernen, die
Hardwarekey-Regel umstellen. Es erscheint derselbe Sperrbildschirm.
- **Ein Passwortwechsel beendet alle anderen Sitzungen** des Kontos. Die
eigene bleibt bestehen.
- **Die Anmeldung nennt keine Gründe mehr.** Falscher Name, falsches Passwort,
deaktiviertes und gesperrtes Konto ergeben dieselbe Meldung. Die Wartezeit
steht darin, der Grund nicht — gesperrt wird nur, was existiert, und eine
eigene Meldung dafür bestätigte die Existenz des Namens.
### 19.5 Was Sie selbst erledigen müssen
- [ ] **Rocket.Chat-Passwort rotieren.** In `config/config.example.php` stand
ein echt aussehendes Passwort — die Beispieldatei liegt im Repository,
und damit lag es dort auch. Ersetzt durch `''`; der Wert kommt jetzt aus
`DC_RC_PASS`.
- [ ] **`verify_ssl` prüfen.** Die Vorgabe der Beispieldatei stand auf
`false` und steht jetzt auf `true`. Wer den alten Wert übernommen hat,
prüfte Zertifikate nicht.
- [ ] **Installationskonto anlegen**, falls Sie die Setup-API mit einem
Administratorkonto benutzt haben. Rolle `installer`, unter System →
Benutzerkonten.
- [ ] **Notfalltoken erneuern**, wenn er älter als 180 Tage ist. Er läuft
jetzt ab; die Oberfläche zeigt die Restlaufzeit.
### 19.6 Was bewusst offen bleibt
| Punkt | Warum nicht jetzt |
|---|---|
| CSP ohne `'unsafe-inline'` | Verlangt das Herauslösen von 93 Inline-Handlern und mehreren tausend Zeilen Inline-Skript aus `index.php`. Ein eigenes Vorhaben, kein Header. **Bis dahin schützt die CSP nicht gegen XSS** — sie schützt gegen fremde Skriptquellen, Einbettung und umgebogene Formularziele. |
| Rollenmodell im WebUI | `ApiAuth` gibt einer Session jeden Scope ohne Prüfung; jeder WebUI-Benutzer ist Volladministrator. Ein echtes Berechtigungsmodell ist ein Vorhaben für sich. |
| Document Root | Der gesamte Quellbaum inklusive `config/` hängt am Web und wird von `mod_rewrite` plus Verzeichnis-`.htaccess` gehalten. Richtig wäre `public/` als Root — das ist eine Änderung an der Hosting-Konfiguration, nicht am Code. |
### 19.7 Prüfen, ob alles läuft
- [ ] `/api/health` → `checks.webauthn_anchor.ok` ist `true`
- [ ] Anmeldung mit Passwort und Hardwarekey funktioniert
- [ ] `curl -I https://dc.mhdf.de/login.php` zeigt
`Strict-Transport-Security` und `Content-Security-Policy`
- [ ] `curl -I http://dc.mhdf.de/login.php` antwortet mit `301` auf `https://`
- [ ] Setup-API mit einem Administratorkonto → `403 installer_account_required`
- [ ] `python tests/e2e/run.py` gegen die Testinstanz: alles grün
+49
View File
@@ -38,6 +38,7 @@ use Deploymentcenter\Core\Webauthn\Attestation;
use Deploymentcenter\Core\Webauthn\CredentialRepo; use Deploymentcenter\Core\Webauthn\CredentialRepo;
use Deploymentcenter\Core\Webauthn\HardwarePolicy; use Deploymentcenter\Core\Webauthn\HardwarePolicy;
use Deploymentcenter\Core\Webauthn\WebauthnServer; use Deploymentcenter\Core\Webauthn\WebauthnServer;
use Deploymentcenter\Modules\License\RateLimiter;
// Kein publicCors: Diese Endpunkte arbeiten mit dem Sitzungscookie. Ein // Kein publicCors: Diese Endpunkte arbeiten mit dem Sitzungscookie. Ein
// "Access-Control-Allow-Origin: *" waere hier das Gegenteil dessen, was // "Access-Control-Allow-Origin: *" waere hier das Gegenteil dessen, was
@@ -56,6 +57,16 @@ $db = Db::init();
$repo = new CredentialRepo($db); $repo = new CredentialRepo($db);
$policy = new HardwarePolicy($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) { switch ($action) {
// ----------------------------------------------------------------- // -----------------------------------------------------------------
@@ -177,6 +188,25 @@ switch ($action) {
} }
Csrf::requireValid(); 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(); $userId = Auth::userId();
$challenge = WebauthnServer::challenge(); $challenge = WebauthnServer::challenge();
@@ -205,6 +235,25 @@ switch ($action) {
} }
Csrf::requireValid(); 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(); $userId = Auth::userId();
$challenge = Auth::takeChallenge(); $challenge = Auth::takeChallenge();
+25
View File
@@ -47,6 +47,31 @@ try {
$logDir = DC_VAR . '/log'; $logDir = DC_VAR . '/log';
$checks['log_writable'] = ['ok' => is_dir($logDir) ? is_writable($logDir) : is_writable(DC_ROOT)]; $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? --- // --- Schema vorhanden? ---
// Dass die Verbindung steht, heisst noch nicht, dass die Migrationen gelaufen // Dass die Verbindung steht, heisst noch nicht, dass die Migrationen gelaufen
// sind: SELECT 1 gelingt auch auf einer leeren Datenbank. Vor der ersten // sind: SELECT 1 gelingt auch auf einer leeren Datenbank. Vor der ersten
+16 -7
View File
@@ -766,15 +766,24 @@ $spec = [
'summary' => 'Anmelden und ein kurzlebiges Setup-Token holen', 'summary' => 'Anmelden und ein kurzlebiges Setup-Token holen',
'description' => 'description' =>
"Ohne Token erreichbar - hier entsteht das Token erst.\n\n" "Ohne Token erreichbar - hier entsteht das Token erst.\n\n"
. "Gedacht fuer Konten der Rolle `installer`, die sich ihrerseits **nicht** am " . "**Nur fuer Konten der Rolle `installer`.** Sie koennen sich ihrerseits "
. "WebUI anmelden koennen. Die Zugangsdaten werden auf jedem Zielsystem " . "nicht am WebUI anmelden; die Trennung gilt seit 2.9.3 in beide Richtungen. "
. "eingegeben; mit einem Administratorkonto verteilte man damit den Zugang zur " . "Ein Administratorkonto wird hier mit `403 installer_account_required` "
. "gesamten Verwaltung.\n\n" . "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 " . "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" . "nicht mitgeschrieben - es ist nach der Ausgabe nirgends mehr abrufbar.\n\n"
. "Konten mit hinterlegtem TOTP-Geheimnis werden mit 501 abgewiesen: eine " . "**Zweiter Faktor:** Konten mit hinterlegtem Hardwarekey oder "
. "TOTP-Pruefung gibt es noch nicht, und sie stillschweigend zu uebergehen " . "TOTP-Geheimnis werden mit `403 second_factor_required` abgewiesen. "
. "waere ein Rueckschritt.", . "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' => [], 'security' => [],
'requestBody' => [ 'requestBody' => [
'required' => true, 'required' => true,
+89 -30
View File
@@ -18,10 +18,17 @@
* dort falsch aufgehoben. Was nach der Installation zurueckbleibt, ist das * dort falsch aufgehoben. Was nach der Installation zurueckbleibt, ist das
* Sub-Token der Anwendung selbst mit genau den Rechten, die sie braucht. * Sub-Token der Anwendung selbst mit genau den Rechten, die sie braucht.
* *
* ROLLENTRENNUNG: Anmelden darf sich hier jedes Konto, aber gedacht ist der * ROLLENTRENNUNG: Seit 2.9.3 duerfen sich hier NUR Konten der Rolle
* Weg fuer Konten der Rolle "installer" - die sich ihrerseits nicht am WebUI * "installer" anmelden - die sich ihrerseits nicht am WebUI anmelden koennen.
* anmelden koennen. Wer auf jedem Zielsystem sein Admin-Passwort eingibt, * Bis dahin stand an dieser Stelle "gedacht ist der Weg fuer Konten der Rolle
* verteilt den Zugang zur gesamten Verwaltung. * 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); declare(strict_types=1);
@@ -33,6 +40,7 @@ use Deploymentcenter\Core\Auth;
use Deploymentcenter\Core\Db; use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\Http; use Deploymentcenter\Core\Http;
use Deploymentcenter\Core\Logger; use Deploymentcenter\Core\Logger;
use Deploymentcenter\Core\SecurityLogger;
use Deploymentcenter\Core\TokenManager; use Deploymentcenter\Core\TokenManager;
use Deploymentcenter\Modules\License\RateLimiter; use Deploymentcenter\Modules\License\RateLimiter;
use Deploymentcenter\Modules\Setup\SetupCatalog; use Deploymentcenter\Modules\Setup\SetupCatalog;
@@ -77,44 +85,95 @@ switch (resolveSetupAction()) {
), null, ['retry_after_seconds' => $ipLockout]); ), 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); $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) { if ($user === null) {
$postAccLockout = Auth::accountLockoutSeconds($db, $username); // Eine Antwort fuer alle Faelle: falscher Name, falsches Passwort,
if ($postAccLockout > 0) { // deaktiviertes Konto, gesperrtes Konto. Die Wartezeit darf mit -
Http::fail(429, 'account_locked', sprintf( // sie ist ein Hinweis fuer den Berechtigten und sagt einem
'Zu viele Fehlversuche. Dieses Benutzerkonto wurde für %d Minute(n) gesperrt.', // Ratenden nichts, was er nicht ohnehin ausprobieren wuerde.
(int)ceil($postAccLockout / 60) $warten = max($accLockout, Auth::accountLockoutSeconds($db, $username));
), null, ['retry_after_seconds' => $postAccLockout]);
}
// Bewusst dieselbe Meldung fuer falschen Benutzer, falsches Http::fail(
// Passwort und deaktiviertes Konto. 401,
Http::fail(401, 'invalid_credentials', 'Ungueltige Anmeldedaten.'); '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 // Zweiter Faktor
// durchzulassen, ohne den zweiten Faktor zu verlangen, waere ein // ------------------------------------------------------------------
// stiller Rueckschritt - deshalb wird es ausdruecklich abgelehnt. // Bis 2.9.2 stand hier nur die Pruefung auf totp_secret - eine Spalte,
if (!empty($user['totp_secret'])) { // die von KEINEM Anmeldeweg ausgewertet wird. Der tatsaechlich
Logger::warning('Setup-Anmeldung mit TOTP-Konto abgelehnt', [ // 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, 'username' => $username,
'ip' => Http::clientIp(), 'ip' => Http::clientIp(),
]); ]);
SecurityLogger::log('setup_login_second_factor', Http::clientIp(), [
'username' => $username,
]);
Http::fail( Http::fail(
501, 403,
'totp_not_supported', 'second_factor_required',
'Fuer dieses Konto ist ein zweiter Faktor hinterlegt. Die Setup-Anmeldung ' '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.'
); );
} }
+93 -1
View File
@@ -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.", "_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, "schema": 1,
"current": "2.9.2", "current": "2.9.3",
"versions": [ "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", "version": "2.9.2",
"date": "2026-09-21", "date": "2026-09-21",
+97 -4
View File
@@ -230,6 +230,49 @@ function dc_flash(string $message, string $type = 'success'): void
$_SESSION['dc_flash'] = ['message' => $message, 'type' => $type]; $_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? * Wie viele handlungsfaehige Administratorkonten gibt es noch?
* *
@@ -1522,6 +1565,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} }
case 'set_user_password': { case 'set_user_password': {
dc_require_reauth();
$userId = (int)($_POST['user_id'] ?? 0); $userId = (int)($_POST['user_id'] ?? 0);
$newPass = (string)($_POST['password'] ?? ''); $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 = $pdo->prepare('UPDATE dc_users SET password_hash = :h WHERE id = :id');
$upd->execute([':h' => password_hash($newPass, PASSWORD_DEFAULT), ':id' => $userId]); $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]); Logger::info('Passwort geaendert', ['user_id' => $userId, 'actor' => $actor]);
// Der Hash steht auch in den .htpasswd-Dateien. // Der Hash steht auch in den .htpasswd-Dateien.
dc_guard_releases($pdo); dc_guard_releases($pdo);
dc_flash($upd->rowCount() > 0 ? 'Passwort geaendert.' : 'Konto nicht gefunden.', dc_flash(
$upd->rowCount() > 0 ? 'success' : 'danger'); $geaendert
? 'Passwort geaendert. Andere Sitzungen dieses Kontos sind damit beendet.'
: 'Konto nicht gefunden.',
$geaendert ? 'success' : 'danger'
);
dc_redirect('#sub-system-users'); dc_redirect('#sub-system-users');
} }
@@ -1614,6 +1679,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} }
case 'issue_recovery_token': { case 'issue_recovery_token': {
dc_require_reauth();
$userId = (int)($_POST['user_id'] ?? 0); $userId = (int)($_POST['user_id'] ?? 0);
$row = $pdo->prepare('SELECT id, username FROM dc_users WHERE id = :id LIMIT 1'); $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': { case 'save_webauthn_policy': {
dc_require_reauth();
$policy = new HardwarePolicy($pdo); $policy = new HardwarePolicy($pdo);
$wanted = (string)($_POST['policy_level'] ?? HardwarePolicy::OFF); $wanted = (string)($_POST['policy_level'] ?? HardwarePolicy::OFF);
$before = $policy->level(); $before = $policy->level();
@@ -1688,6 +1757,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} }
case 'delete_credential': { case 'delete_credential': {
dc_require_reauth();
$userId = (int)($_POST['user_id'] ?? 0); $userId = (int)($_POST['user_id'] ?? 0);
$credentialId = (int)($_POST['credential_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;"> <p style="color:var(--text-muted); font-size:0.8rem; margin-top:1rem;">
Notfalltoken für Ihr eigenes Konto: Notfalltoken für Ihr eigenes Konto:
<?php if ($myRecovery['present']): ?> <?php if (!empty($myRecovery['expired'])): ?>
hinterlegt seit <?= e((string)$myRecovery['created_at']) ?>. <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'])): ?> <?php elseif (!empty($myRecovery['used_at'])): ?>
<span style="color:var(--warning);">verbraucht am <?= e((string)$myRecovery['used_at']) ?>.</span> <span style="color:var(--warning);">verbraucht am <?= e((string)$myRecovery['used_at']) ?>.</span>
<?php else: ?> <?php else: ?>
@@ -9237,6 +9320,16 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
if (!response.ok) { 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)); throw new Error(fehlertext(data));
} }
return data; return data;
+116 -6
View File
@@ -10,6 +10,7 @@ use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\Http; use Deploymentcenter\Core\Http;
use Deploymentcenter\Core\Logger; use Deploymentcenter\Core\Logger;
use Deploymentcenter\Core\Webauthn\CredentialRepo; use Deploymentcenter\Core\Webauthn\CredentialRepo;
use Deploymentcenter\Modules\License\RateLimiter;
Auth::startSession(); Auth::startSession();
@@ -21,6 +22,61 @@ if (Auth::isLoggedIn()) {
$error = null; $error = null;
$notice = 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 // Zweite Stufe: Notfalltoken und Abbruch
// //
@@ -85,17 +141,35 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $step === '') {
} else { } else {
try { try {
$pdo = Db::init(); $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()); $ipLockout = Auth::lockoutSeconds($pdo, Http::clientIp());
$accLockout = Auth::accountLockoutSeconds($pdo, $username); $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( $error = sprintf(
'Zu viele fehlgeschlagene Anmeldeversuche von diesem Netzwerk. Bitte in %d Minute(n) erneut versuchen.', 'Zu viele fehlgeschlagene Anmeldeversuche von diesem Netzwerk. Bitte in %d Minute(n) erneut versuchen.',
(int)ceil($ipLockout / 60) (int)ceil($ipLockout / 60)
); );
} elseif ($accLockout > 0) { } 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( $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) (int)ceil($accLockout / 60)
); );
} else { } else {
@@ -121,8 +195,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $step === '') {
$newIpLockout = Auth::lockoutSeconds($pdo, Http::clientIp()); $newIpLockout = Auth::lockoutSeconds($pdo, Http::clientIp());
if ($newAccLockout > 0) { if ($newAccLockout > 0) {
// Wieder ohne den Grund: "dieses Benutzerkonto wurde
// gesperrt" ist eine Bestaetigung, dass es das Konto
// gibt.
$error = sprintf( $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) (int)ceil($newAccLockout / 60)
); );
} elseif ($newIpLockout > 0) { } elseif ($newIpLockout > 0) {
@@ -131,7 +208,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $step === '') {
(int)ceil($newIpLockout / 60) (int)ceil($newIpLockout / 60)
); );
} else { } else {
$error = 'Ungueltige Anmeldedaten.'; $error = 'Anmeldung nicht moeglich.';
} }
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
@@ -419,7 +496,11 @@ $pendingUsername = Auth::pendingUsername();
</div> </div>
<h1 class="brand-title">Deploymentcenter</h1> <h1 class="brand-title">Deploymentcenter</h1>
<p class="brand-subtitle"> <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> </p>
</div> </div>
@@ -427,7 +508,36 @@ $pendingUsername = Auth::pendingUsername();
<div class="alert-danger"><?= htmlspecialchars($error) ?></div> <div class="alert-danger"><?= htmlspecialchars($error) ?></div>
<?php endif; ?> <?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"> <div class="step-note">
Passwort erkannt für <strong><?= htmlspecialchars($pendingUsername) ?></strong>. Passwort erkannt für <strong><?= htmlspecialchars($pendingUsername) ?></strong>.
+36
View File
@@ -0,0 +1,36 @@
-- Migration 022: Sitzungsgeneration je Konto
--
-- Additive Migration. Der Migrator toleriert 1050/1060/1061/1062/1091.
--
-- ---------------------------------------------------------------------------
-- Wozu
-- ---------------------------------------------------------------------------
-- Ein Passwortwechsel beendete bisher keine einzige Sitzung. Wer das Passwort
-- aendert, weil er einen fremden Zugriff vermutet, liess damit genau den
-- drin, dessentwegen er es aendert: Dessen Cookie galt unveraendert weiter.
--
-- PHP legt Sitzungen als Dateien ab; es gibt keinen Index "alle Sitzungen des
-- Kontos X", ohne einen eigenen Sitzungsspeicher zu bauen. Eine Generation
-- loest dasselbe Problem mit einer Spalte: Jede Sitzung merkt sich beim
-- Anmelden den Stand, und bei jeder Anfrage wird verglichen. Wird der Stand
-- erhoeht, passen alle uebrigen Sitzungen nicht mehr - ohne dass man sie
-- einzeln finden muesste.
--
-- Die eigene Sitzung uebernimmt den neuen Stand sofort. Wer sein Passwort
-- aendert, soll nicht sich selbst hinauswerfen; das waere die Art von
-- Nebenwirkung, wegen der Leute Passwoerter nicht aendern.
--
-- ---------------------------------------------------------------------------
-- Warum Vorgabe 0 und nicht ein Zeitstempel
-- ---------------------------------------------------------------------------
-- Ein Zaehler ist gegenueber einer Uhr im Vorteil: Er braucht keine
-- Zeitzonen, keine Sekundengenauigkeit und kein Nachdenken darueber, was
-- passiert, wenn zwei Aenderungen in dieselbe Sekunde fallen. Verglichen wird
-- auf Gleichheit, nicht auf "juenger als".
--
-- Sitzungen, die vor dieser Migration eroeffnet wurden, tragen den Wert gar
-- nicht. Sie gelten weiter (siehe Auth::sessionEpochValid): Eine
-- Sicherheitsverbesserung, die im Moment ihrer Einfuehrung alle Angemeldeten
-- hinauswirft, wird beim naechsten Mal abgeschaltet.
ALTER TABLE dc_users
ADD COLUMN session_epoch INT NOT NULL DEFAULT 0 AFTER recovery_used_at;
+446 -25
View File
@@ -21,12 +21,45 @@ use PDO;
*/ */
final class Auth final class Auth
{ {
/** Leerlauf-Timeout in Sekunden (2 Stunden). */ /**
private const IDLE_TIMEOUT = 7200; * Leerlauf bis zur Sperre in Sekunden (30 Minuten).
*
* Bis 2.9.2 standen hier 2 Stunden, und die Sitzung wurde danach
* vernichtet. Beides ist geaendert, und zwar aus zwei verschiedenen
* Gruenden:
*
* Die 30 Minuten kommen aus NIST SP 800-63B: Auf Stufe AAL2 muss nach
* dieser Zeit erneut authentifiziert werden. Zwei Stunden erfuellen das
* nicht, und damit war der ganze Aufwand mit dem Hardwarekey an einer
* Zahl aufgehaengt, die niemand fuer eine Sicherheitsentscheidung hielt.
*
* Vernichtet wird die Sitzung dabei aber nicht mehr, sondern GESPERRT -
* siehe lock(). Derselbe Standard laesst zur Reaktivierung einen Faktor
* genuegen. Wer alle 30 Minuten Passwort UND Hardwarekey vorzeigen muss,
* legt den Schluessel irgendwann steckend liegen, und dann ist der
* zweite Faktor keiner mehr. Eine Regel, die zur Umgehung erzieht, ist
* schlechter als eine mildere, die eingehalten wird.
*/
private const IDLE_TIMEOUT = 1800;
/** Absolutes Session-Maximum in Sekunden (12 Stunden). */ /** Absolutes Session-Maximum in Sekunden (12 Stunden). */
private const ABSOLUTE_TIMEOUT = 43200; private const ABSOLUTE_TIMEOUT = 43200;
/**
* Wie lange eine frische Authentifizierung fuer heikle Aenderungen
* zaehlt (10 Minuten).
*
* Betrifft die Handvoll Aktionen, die die Authentifizierung selbst
* veraendern: Passwort setzen, Notfalltoken ausstellen, Hardwarekey
* aufnehmen oder entfernen, die Hardwarekey-Regel umstellen. Ohne diese
* Huerde genuegt eine einzige uebernommene Sitzung, um sich dauerhaft
* einzunisten - einen eigenen Schluessel zu registrieren, einen
* Notfalltoken fuer jedes Konto auszustellen und den zweiten Faktor
* anlagenweit abzuschalten. Der zweite Faktor waere damit gegen genau
* den Fall wirkungslos, gegen den er gedacht ist.
*/
public const REAUTH_WINDOW = 600;
/** Fehlversuche pro IP, bevor IP-Sperre greift. */ /** Fehlversuche pro IP, bevor IP-Sperre greift. */
public const DEFAULT_MAX_IP_ATTEMPTS = 10; public const DEFAULT_MAX_IP_ATTEMPTS = 10;
@@ -48,6 +81,15 @@ final class Auth
*/ */
public const PENDING_TIMEOUT = 300; public const PENDING_TIMEOUT = 300;
/**
* Fehlversuche auf der zweiten Stufe, bevor der Zwischenzustand faellt.
*
* Die Kontosperre wirkt erst beim naechsten Anmeldeversuch. Dieser
* Zaehler wirkt innerhalb des offenen Zwischenzustands - dort, wo sonst
* unbegrenzt geraten werden duerfte.
*/
public const MAX_PENDING_FAILURES = 5;
/** Rueckgabewerte von startLogin(). */ /** Rueckgabewerte von startLogin(). */
public const LOGIN_FAILED = 'failed'; public const LOGIN_FAILED = 'failed';
public const LOGIN_COMPLETE = 'complete'; public const LOGIN_COMPLETE = 'complete';
@@ -59,7 +101,23 @@ final class Auth
return; return;
} }
$secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; // Ohne strict_mode uebernimmt PHP jede Sitzungskennung, die der
// Client sich ausdenkt - die Grundlage jeder Session-Fixation. Das
// session_regenerate_id(true) in establishSession() entschaerft das
// bereits, aber es entschaerft nur den Fall, den wir bedacht haben.
// Die Zeile hier schliesst die Gattung.
ini_set('session.use_strict_mode', '1');
ini_set('session.use_only_cookies', '1');
// Das Secure-Flag haengt sonst allein an $_SERVER['HTTPS']. Hinter
// einem TLS-terminierenden Proxy ist das leer, und der Cookie ginge
// ohne Secure hinaus - also genau dort, wo man es am wenigsten
// bemerkt. Die konfigurierte app.url ist die verlaessliche Quelle:
// Sie sagt, wie die Anlage erreichbar sein SOLL.
$configuredUrl = (string)Config::get('app.url', '');
$secure = $configuredUrl !== ''
? str_starts_with(strtolower($configuredUrl), 'https://')
: (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
session_set_cookie_params([ session_set_cookie_params([
'lifetime' => 0, 'lifetime' => 0,
@@ -76,10 +134,29 @@ final class Auth
self::enforceTimeouts(); self::enforceTimeouts();
} }
/** Ergebnis der Generationspruefung je Anfrage, damit sie einmal kostet. */
private static ?bool $epochGeprueft = null;
public static function isLoggedIn(): bool public static function isLoggedIn(): bool
{ {
self::startSession(); self::startSession();
return !empty($_SESSION['dc_user_id']) && !empty($_SESSION['dc_username']);
if (empty($_SESSION['dc_user_id']) || empty($_SESSION['dc_username'])) {
return false;
}
// Eine gesperrte Sitzung ist keine Anmeldung. Das ist die ganze
// Wirkung der Sperre: Jede Stelle, die bisher isLoggedIn() gefragt
// hat, bekommt ohne eigenes Zutun die richtige Antwort.
if (!empty($_SESSION['dc_locked'])) {
return false;
}
if (self::$epochGeprueft === null) {
self::$epochGeprueft = self::sessionEpochValid();
}
return self::$epochGeprueft;
} }
public static function requireLogin(): void public static function requireLogin(): void
@@ -89,7 +166,11 @@ final class Auth
} }
if (!headers_sent()) { if (!headers_sent()) {
header('Location: /login.php'); // Eine gesperrte Sitzung fuehrt auf den Sperrbildschirm, nicht
// auf die Anmeldung. Der Unterschied ist keine Kosmetik: Am
// Sperrbildschirm genuegt das Passwort, an der Anmeldung
// braeuchte es wieder den Hardwarekey.
header('Location: ' . (self::isLocked() ? '/login.php?step=unlock' : '/login.php'));
} }
exit; exit;
} }
@@ -133,9 +214,18 @@ final class Auth
$diff = $ts - time(); $diff = $ts - time();
return $diff > 0 ? $diff : 0; return $diff > 0 ? $diff : 0;
} catch (\Throwable $e) { } catch (\PDOException $e) {
// Spalte fehlt eventuell vor Migration 018 // Vor Migration 018 gibt es die Spalte nicht - dann gibt es auch
return 0; // keine Sperren, und 0 ist die richtige Antwort.
if (self::isMissingSchema($e)) {
return 0;
}
// Sonst nicht durchwinken: "return 0" heisst hier "Sperre
// aufgehoben". Bei einer gestoerten Datenbank fiele damit
// ausgerechnet der Brute-Force-Schutz als Erstes weg.
Logger::error('Kontosperr-Pruefung fehlgeschlagen', ['error' => $e->getMessage()]);
throw $e;
} }
} }
@@ -169,9 +259,13 @@ final class Auth
$remaining = $window - (time() - $last); $remaining = $window - (time() - $last);
return $remaining > 0 ? $remaining : 0; return $remaining > 0 ? $remaining : 0;
} catch (\Throwable $e) { } catch (\PDOException $e) {
Logger::warning('IP-Lockout-Pruefung nicht moeglich', ['error' => $e->getMessage()]); if (self::isMissingSchema($e)) {
return 0; return 0;
}
Logger::error('IP-Sperr-Pruefung fehlgeschlagen', ['error' => $e->getMessage()]);
throw $e;
} }
} }
@@ -357,12 +451,22 @@ final class Auth
try { try {
return (new CredentialRepo($db))->countForUser($userId) > 0; return (new CredentialRepo($db))->countForUser($userId) > 0;
} catch (\Throwable $e) { } catch (\PDOException $e) {
// Tabelle fehlt eventuell vor Migration 019. Dann gibt es keine // Nur der eine tolerierbare Fall: Vor Migration 019 gibt es die
// Schluessel, also auch keine Pflicht - und die Anmeldung laeuft // Tabelle nicht, also auch keine Schluessel und keine Pflicht.
// wie zuvor, statt an einer fehlenden Tabelle zu scheitern. if (self::isMissingSchema($e)) {
Logger::warning('Pruefung auf Hardwarekey nicht moeglich', ['error' => $e->getMessage()]); Logger::warning('dc_user_credentials fehlt - Migration 019 ausstehend');
return false; return false;
}
// Alles andere - Verbindungsabbruch, Lock-Timeout, "too many
// connections", entzogene Rechte - hat vorher ebenfalls zu
// "false" gefuehrt und damit im Stillen fuer ALLE Konten auf
// Passwort-allein zurueckgeschaltet. Eine Authentifizierung muss
// geschlossen scheitern: Lieber keine Anmeldung als eine ohne
// zweiten Faktor.
Logger::error('Pruefung auf Hardwarekey fehlgeschlagen', ['error' => $e->getMessage()]);
throw $e;
} }
} }
@@ -415,6 +519,20 @@ final class Auth
return false; return false;
} }
// Eine Sperre, die zwischen erster und zweiter Stufe verhaengt wurde,
// muss hier greifen. Vorher konnte ein Konto, das waehrend des
// Ratens gesperrt wurde, die Anmeldung trotzdem abschliessen - die
// Sperre galt nur fuer den Weg, den der Angreifer gerade nicht ging.
if (self::accountLockoutSeconds($db, (string)$user['username']) > 0) {
SecurityLogger::accountLocked(
Http::clientIp(),
(string)$user['username'],
self::accountLockoutSeconds($db, (string)$user['username'])
);
self::clearPending();
return false;
}
self::clearPending(); self::clearPending();
self::establishSession($user); self::establishSession($user);
@@ -457,8 +575,28 @@ final class Auth
if (is_array($user)) { if (is_array($user)) {
self::handleAccountFailedAttempt($db, $user, $ip, $username); self::handleAccountFailedAttempt($db, $user, $ip, $username);
} }
} catch (\Throwable $e) { } catch (\PDOException $e) {
Logger::warning('Fehlversuch der zweiten Stufe nicht gezaehlt', ['error' => $e->getMessage()]); if (!self::isMissingSchema($e)) {
throw $e;
}
Logger::warning('Fehlversuch der zweiten Stufe nicht gezaehlt - Migration ausstehend');
}
// Zusaetzlich ein Zaehler in der Sitzung. Die Kontosperre oben
// greift erst beim naechsten Anmeldeversuch; INNERHALB des offenen
// Zwischenzustands duerfte sonst beliebig oft geraten werden, und
// genau das versprach der Kommentar ueber dieser Methode zu
// verhindern. Nach der fuenften Fehlmeldung ist der Zwischenzustand
// weg und es geht wieder beim Passwort los.
$fehl = (int)($_SESSION['dc_pending_failures'] ?? 0) + 1;
$_SESSION['dc_pending_failures'] = $fehl;
if ($fehl >= self::MAX_PENDING_FAILURES) {
SecurityLogger::log('second_factor_abandoned', $ip, [
'username' => $username,
'failures' => $fehl,
]);
self::clearPending();
} }
} }
@@ -471,7 +609,8 @@ final class Auth
$_SESSION['dc_pending_username'], $_SESSION['dc_pending_username'],
$_SESSION['dc_pending_at'], $_SESSION['dc_pending_at'],
$_SESSION['dc_webauthn_challenge'], $_SESSION['dc_webauthn_challenge'],
$_SESSION['dc_webauthn_challenge_at'] $_SESSION['dc_webauthn_challenge_at'],
$_SESSION['dc_pending_failures']
); );
} }
@@ -515,6 +654,12 @@ final class Auth
/** Session-Variablen einer erfolgreichen Anmeldung setzen. */ /** Session-Variablen einer erfolgreichen Anmeldung setzen. */
private static function establishSession(array $user): void private static function establishSession(array $user): void
{ {
// Das CSRF-Token vor der Regeneration verwerfen. Es liegt in
// $_SESSION und ueberlebt den Kennungswechsel sonst - ein vor der
// Anmeldung gelerntes Token gaelte danach weiter, und damit haette
// die Regeneration die halbe Wirkung, die sie haben soll.
unset($_SESSION['dc_csrf_token']);
session_regenerate_id(true); session_regenerate_id(true);
$_SESSION['dc_user_id'] = (int)$user['id']; $_SESSION['dc_user_id'] = (int)$user['id'];
@@ -522,6 +667,15 @@ final class Auth
$_SESSION['dc_role'] = self::roleOf($user); $_SESSION['dc_role'] = self::roleOf($user);
$_SESSION['dc_login_at'] = time(); $_SESSION['dc_login_at'] = time();
$_SESSION['dc_last_seen'] = time(); $_SESSION['dc_last_seen'] = time();
$_SESSION['dc_reauth_at'] = time();
unset($_SESSION['dc_locked']);
// Die Generation, unter der diese Sitzung gilt. Ein Passwortwechsel
// erhoeht sie und beendet damit alle uebrigen Sitzungen des Kontos.
if (array_key_exists('session_epoch', $user)) {
$_SESSION['dc_session_epoch'] = (int)$user['session_epoch'];
}
self::$epochGeprueft = null;
Logger::info('Anmeldung erfolgreich', ['username' => $user['username'], 'ip' => Http::clientIp()]); Logger::info('Anmeldung erfolgreich', ['username' => $user['username'], 'ip' => Http::clientIp()]);
} }
@@ -641,8 +795,49 @@ final class Auth
session_destroy(); session_destroy();
} }
/**
* Ein Fehlversuch, der nicht aus verifyCredentials() kommt.
*
* Gebraucht vom Sperrbildschirm: Auch dort wird ein Passwort geprueft,
* und ein Weg, auf dem ungezaehlt geraten werden darf, waere der
* bequemste - er umgeht die Anmeldeseite vollstaendig.
*/
public static function recordFailedAttempt(PDO $db, string $ip, string $username): void
{
self::recordAttempt($db, $ip, $username, false);
SecurityLogger::authFail($ip, $username, 'unlock_failed');
}
private static function recordAttempt(PDO $db, string $ip, string $username, bool $success): void private static function recordAttempt(PDO $db, string $ip, string $username, bool $success): void
{ {
// Gelegentlich aufraeumen. Die Tabelle wuchs bisher unbegrenzt, und
// sie wird bei JEDER Anmeldung gelesen - eine Tabelle, die nur
// waechst und im heissesten Pfad liegt, wird irgendwann zu dem
// Problem, das die Anmeldung langsam macht.
//
// Ein eigener Cron-Eintrag waere sauberer, braeuchte aber eine
// laufende Zeitsteuerung, damit die Sperre funktioniert. Faellt sie
// aus, faellt mit ihr die Aufraeumung, und niemand merkt es. Ein
// Hundertstel der Anmeldungen erledigt es beilaeufig und haengt an
// nichts.
if (random_int(1, 100) === 1) {
try {
// Das Fenster der IP-Sperre ist die Untergrenze; ein Tag
// Puffer, damit eine laufende Auswertung nichts verliert.
$db->exec(
'DELETE FROM dc_login_attempts '
. 'WHERE attempted_at < (UTC_TIMESTAMP() - INTERVAL 7 DAY)'
);
} catch (\Throwable $e) {
// Aufraeumen ist Beiwerk. Es darf eine Anmeldung nicht
// scheitern lassen - anders als die Pruefungen, die es
// beschleunigen soll.
Logger::warning('Aufraeumen von dc_login_attempts fehlgeschlagen', [
'error' => $e->getMessage(),
]);
}
}
try { try {
$stmt = $db->prepare(' $stmt = $db->prepare('
INSERT INTO dc_login_attempts (ip, username, success, attempted_at) INSERT INTO dc_login_attempts (ip, username, success, attempted_at)
@@ -690,16 +885,242 @@ final class Auth
$loginAt = (int)($_SESSION['dc_login_at'] ?? $now); $loginAt = (int)($_SESSION['dc_login_at'] ?? $now);
$lastSeen = (int)($_SESSION['dc_last_seen'] ?? $now); $lastSeen = (int)($_SESSION['dc_last_seen'] ?? $now);
$expired = ($now - $lastSeen) > self::IDLE_TIMEOUT // Das absolute Maximum beendet die Sitzung wirklich. Zwoelf Stunden
|| ($now - $loginAt) > self::ABSOLUTE_TIMEOUT; // sind keine Pause, sondern ein anderer Tag.
if (($now - $loginAt) > self::ABSOLUTE_TIMEOUT) {
if ($expired) {
$_SESSION = []; $_SESSION = [];
session_destroy(); session_destroy();
session_start(); session_start();
// Nach dem Neustart eine frische Kennung. Ohne diese Zeile liefe
// der Benutzer mit derselben Kennung weiter, mit der er
// hereingekommen ist - und die kannte moeglicherweise jemand.
session_regenerate_id(true);
return; return;
} }
$_SESSION['dc_last_seen'] = $now; // Leerlauf sperrt nur. Die Sitzung bleibt bestehen, aber
// isLoggedIn() meldet false, bis das Passwort sie wieder oeffnet.
if (($now - $lastSeen) > self::IDLE_TIMEOUT) {
$_SESSION['dc_locked'] = true;
return;
}
// Eine gesperrte Sitzung verlaengert sich nicht von selbst. Sonst
// hielte ein Hintergrundabruf - ein offener Tab, der alle zwei
// Minuten etwas nachlaedt - die Sperre ewig auf.
if (empty($_SESSION['dc_locked'])) {
$_SESSION['dc_last_seen'] = $now;
}
}
// -----------------------------------------------------------------
// Sperre und erneute Authentifizierung
// -----------------------------------------------------------------
/** Steht eine Sitzung, die nur noch das Passwort braucht? */
public static function isLocked(): bool
{
self::startSession();
return !empty($_SESSION['dc_user_id']) && !empty($_SESSION['dc_locked']);
}
/**
* Sperrt die Sitzung absichtlich, um eine Passwortbestaetigung zu
* erzwingen.
*
* Derselbe Zustand wie nach 30 Minuten Leerlauf, nur willentlich
* herbeigefuehrt. Damit gibt es fuer "bitte nochmal das Passwort" genau
* einen Weg und einen Bildschirm - zwei Formulare, die dasselbe tun,
* laufen mit der Zeit auseinander, und beim Pruefen von Passwoertern ist
* das die teuerste Art von Duplikat.
*/
public static function lockForReauth(): void
{
self::startSession();
if (!empty($_SESSION['dc_user_id'])) {
$_SESSION['dc_locked'] = true;
unset($_SESSION['dc_reauth_at']);
self::$epochGeprueft = null;
}
}
/** Der Name des gesperrten Kontos - fuer die Anzeige am Sperrbildschirm. */
public static function lockedUsername(): string
{
self::startSession();
$name = $_SESSION['dc_username'] ?? null;
return is_string($name) ? $name : '';
}
/**
* Oeffnet eine gesperrte Sitzung wieder - nur mit dem Passwort.
*
* Bewusst ohne zweiten Faktor: AAL2 laesst zur Reaktivierung einen
* Faktor genuegen, und der Hardwarekey wuerde hier nichts hinzufuegen,
* was der Sitzungscookie nicht schon behauptet. Wer den Cookie hat, hat
* das Geraet - nicht aber das Passwort.
*/
public static function unlock(PDO $db, string $password): bool
{
self::startSession();
$userId = (int)($_SESSION['dc_user_id'] ?? 0);
if ($userId <= 0 || empty($_SESSION['dc_locked'])) {
return false;
}
if (!self::verifyPasswordFor($db, $userId, $password)) {
SecurityLogger::log('session_unlock_failed', Http::clientIp(), [
'username' => self::lockedUsername(),
]);
return false;
}
unset($_SESSION['dc_locked']);
$_SESSION['dc_last_seen'] = time();
$_SESSION['dc_reauth_at'] = time();
// Auch das Entsperren ist eine Authentifizierung. Wer sie besteht,
// bekommt eine neue Sitzungskennung.
session_regenerate_id(true);
return true;
}
/**
* Liegt eine Authentifizierung innerhalb des Fensters zurueck?
*
* Das ist die Frage vor jeder Aenderung an den Anmeldemitteln selbst.
*/
public static function reauthFresh(): bool
{
self::startSession();
if (!self::isLoggedIn()) {
return false;
}
$at = (int)($_SESSION['dc_reauth_at'] ?? 0);
return $at > 0 && (time() - $at) <= self::REAUTH_WINDOW;
}
/** Merkt sich, dass gerade authentifiziert wurde. */
public static function touchReauth(): void
{
self::startSession();
$_SESSION['dc_reauth_at'] = time();
}
/** Prueft ein Passwort gegen ein bestimmtes Konto, ohne Sperrlogik. */
public static function verifyPasswordFor(PDO $db, int $userId, string $password): bool
{
if ($userId <= 0 || $password === '') {
return false;
}
$stmt = $db->prepare('SELECT password_hash FROM dc_users WHERE id = :id LIMIT 1');
$stmt->execute([':id' => $userId]);
$hash = $stmt->fetchColumn();
// Auch hier gegen einen Platzhalter rechnen, wenn es das Konto nicht
// gibt: Sonst verriete die Laufzeit den Unterschied.
return password_verify($password, is_string($hash) && $hash !== '' ? $hash : self::dummyHash());
}
// -----------------------------------------------------------------
// Sitzungsgeneration - beendet fremde Sitzungen eines Kontos
// -----------------------------------------------------------------
/**
* Erhoeht die Generation eines Kontos und uebernimmt sie in die eigene
* Sitzung.
*
* Damit enden alle ANDEREN Sitzungen desselben Kontos. Gebraucht wird
* das beim Passwortwechsel: Ein Passwort zu aendern, ohne die Sitzungen
* zu beenden, die mit dem alten Passwort eroeffnet wurden, laesst genau
* den drin, dessentwegen man es aendert.
*/
public static function bumpSessionEpoch(PDO $db, int $userId): void
{
try {
$db->prepare('UPDATE dc_users SET session_epoch = session_epoch + 1 WHERE id = :id')
->execute([':id' => $userId]);
$stmt = $db->prepare('SELECT session_epoch FROM dc_users WHERE id = :id LIMIT 1');
$stmt->execute([':id' => $userId]);
$neu = (int)$stmt->fetchColumn();
if (self::userId() === $userId) {
$_SESSION['dc_session_epoch'] = $neu;
}
} catch (\PDOException $e) {
if (!self::isMissingSchema($e)) {
throw $e;
}
Logger::warning('session_epoch fehlt - Migration 022 ausstehend');
}
}
/**
* Gilt die Sitzung noch, gemessen an der Generation des Kontos?
*
* Wird von isLoggedIn() bei jeder Anfrage mitgeprueft.
*/
private static function sessionEpochValid(): bool
{
$userId = (int)($_SESSION['dc_user_id'] ?? 0);
if ($userId <= 0) {
return false;
}
// Sitzungen aus der Zeit vor Migration 022 tragen den Wert nicht.
// Sie gelten weiter - ein Sicherheitsgewinn, der bestehende
// Anmeldungen wahllos abraeumt, wird beim naechsten Mal abgeschaltet.
if (!isset($_SESSION['dc_session_epoch'])) {
return true;
}
try {
$stmt = Db::init()->prepare('SELECT session_epoch FROM dc_users WHERE id = :id LIMIT 1');
$stmt->execute([':id' => $userId]);
$aktuell = $stmt->fetchColumn();
if ($aktuell === false) {
// Konto geloescht - die Sitzung hat keine Grundlage mehr.
return false;
}
return (int)$aktuell === (int)$_SESSION['dc_session_epoch'];
} catch (\PDOException $e) {
if (self::isMissingSchema($e)) {
return true;
}
// Jeder andere Datenbankfehler: nicht stillschweigend
// durchwinken. Siehe den Kommentar in secondFactorRequired().
throw $e;
}
}
/**
* Unterscheidet "Tabelle/Spalte gibt es nicht" von jedem anderen
* Datenbankfehler.
*
* Diese Unterscheidung ist der Kern der Umstellung auf fail-closed:
* Eine ausstehende Migration ist ein bekannter, harmloser Zustand.
* Ein Verbindungsabbruch, ein Lock-Timeout, "too many connections" oder
* entzogene Tabellenrechte sind es nicht - und alle vier sahen vorher
* genauso aus.
*/
private static function isMissingSchema(\PDOException $e): bool
{
// 42S02 = Tabelle unbekannt, 42S22 = Spalte unbekannt.
$sqlState = (string)$e->getCode();
return $sqlState === '42S02' || $sqlState === '42S22';
} }
} }
+82 -6
View File
@@ -22,6 +22,22 @@ final class CredentialRepo
/** Gruppen zu je vier Zeichen, damit man ihn vorlesen kann. */ /** Gruppen zu je vier Zeichen, damit man ihn vorlesen kann. */
private const TOKEN_GROUPS = 5; private const TOKEN_GROUPS = 5;
/**
* Hoechstalter eines Notfalltokens in Sekunden (180 Tage).
*
* Ein Notfalltoken liegt ausgedruckt in einem Schrank oder in einem
* Passwortverwalter. Beides altert: Der Schrank wechselt den Besitzer,
* der Zettel wird abfotografiert, der Verwalter wird geteilt. Ein
* Ersatzweg fuer den zweiten Faktor, der zeitlich unbegrenzt gilt, ist
* auf Dauer der schwaechste Punkt des Verfahrens - und zwar unbemerkt,
* weil niemand ihn benutzt und deshalb niemand nachsieht.
*
* 180 Tage sind lang genug, dass der Token seinen Zweck erfuellt (ein
* verlorener Schluessel faellt binnen Tagen auf, nicht binnen Monaten),
* und kurz genug, dass halbjaehrlich jemand hinsieht.
*/
public const RECOVERY_MAX_AGE = 180 * 86400;
public function __construct(private PDO $db) public function __construct(private PDO $db)
{ {
} }
@@ -265,9 +281,13 @@ final class CredentialRepo
*/ */
public function consumeRecoveryToken(int $userId, string $presented): bool public function consumeRecoveryToken(int $userId, string $presented): bool
{ {
$stmt = $this->db->prepare('SELECT recovery_hash FROM dc_users WHERE id = :id LIMIT 1'); $stmt = $this->db->prepare(
'SELECT recovery_hash, recovery_created_at FROM dc_users WHERE id = :id LIMIT 1'
);
$stmt->execute([':id' => $userId]); $stmt->execute([':id' => $userId]);
$hash = $stmt->fetchColumn(); $row = $stmt->fetch();
$hash = is_array($row) ? ($row['recovery_hash'] ?? null) : null;
if (!is_string($hash) || $hash === '') { if (!is_string($hash) || $hash === '') {
// Trotzdem einmal rechnen: Ob ein Notfalltoken hinterlegt ist, // Trotzdem einmal rechnen: Ob ein Notfalltoken hinterlegt ist,
@@ -280,6 +300,37 @@ final class CredentialRepo
return false; return false;
} }
// Alter pruefen - NACH dem Vergleich, damit die Antwortzeit nicht
// verraet, ob ein Token existiert.
//
// recovery_created_at wurde seit 2.9.0 geschrieben und nie
// ausgewertet. Ein vor zwei Jahren auf einen Zettel geschriebener
// Token war damit weiterhin ein vollwertiger Ersatz fuer den
// Hardwarekey - also ein zweiter Faktor, der nicht altert, waehrend
// der Zettel es tut.
$erzeugt = is_array($row) ? ($row['recovery_created_at'] ?? null) : null;
if (is_string($erzeugt) && $erzeugt !== '') {
$ts = strtotime($erzeugt . ' UTC');
if ($ts !== false && (time() - $ts) > self::RECOVERY_MAX_AGE) {
Logger::warning('Abgelaufener Notfalltoken vorgezeigt', [
'user_id' => $userId,
'alter_tage' => (int)floor((time() - $ts) / 86400),
]);
// Der abgelaufene Token wird bei dieser Gelegenheit
// ausgetragen. Ihn stehen zu lassen hiesse, dass er beim
// naechsten Versuch wieder dieselbe Pruefung durchlaeuft -
// und in der Oberflaeche weiterhin als "hinterlegt" steht,
// obwohl er nichts mehr oeffnet.
$this->db->prepare('UPDATE dc_users SET recovery_hash = NULL WHERE id = :id')
->execute([':id' => $userId]);
return false;
}
}
$clear = $this->db->prepare(' $clear = $this->db->prepare('
UPDATE dc_users UPDATE dc_users
SET recovery_hash = NULL, recovery_used_at = UTC_TIMESTAMP() SET recovery_hash = NULL, recovery_used_at = UTC_TIMESTAMP()
@@ -295,7 +346,7 @@ final class CredentialRepo
/** /**
* Zustand des Notfalltokens fuer die Anzeige. * Zustand des Notfalltokens fuer die Anzeige.
* *
* @return array{present:bool,created_at:?string,used_at:?string} * @return array{present:bool,created_at:?string,used_at:?string,age_days:?int,expired:bool}
*/ */
public function recoveryState(int $userId): array public function recoveryState(int $userId): array
{ {
@@ -306,14 +357,39 @@ final class CredentialRepo
$stmt->execute([':id' => $userId]); $stmt->execute([':id' => $userId]);
$row = $stmt->fetch(); $row = $stmt->fetch();
$leer = [
'present' => false,
'created_at' => null,
'used_at' => null,
'age_days' => null,
'expired' => false,
];
if (!is_array($row)) { if (!is_array($row)) {
return ['present' => false, 'created_at' => null, 'used_at' => null]; return $leer;
}
$present = is_string($row['recovery_hash'] ?? null) && $row['recovery_hash'] !== '';
$erzeugt = $row['recovery_created_at'] ?? null;
// Das Alter gehoert in die Anzeige, nicht nur in die Pruefung. Ein
// Token, der in drei Wochen verfaellt, soll das sagen, solange man
// noch in Ruhe einen neuen erzeugen kann - nicht erst in dem Moment,
// in dem man ihn braucht.
$alter = null;
if (is_string($erzeugt) && $erzeugt !== '') {
$ts = strtotime($erzeugt . ' UTC');
if ($ts !== false) {
$alter = (int)floor((time() - $ts) / 86400);
}
} }
return [ return [
'present' => is_string($row['recovery_hash'] ?? null) && $row['recovery_hash'] !== '', 'present' => $present,
'created_at' => $row['recovery_created_at'] ?? null, 'created_at' => $erzeugt,
'used_at' => $row['recovery_used_at'] ?? null, 'used_at' => $row['recovery_used_at'] ?? null,
'age_days' => $alter,
'expired' => $present && $alter !== null && $alter > (self::RECOVERY_MAX_AGE / 86400),
]; ];
} }
+39 -4
View File
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Deploymentcenter\Core\Webauthn; namespace Deploymentcenter\Core\Webauthn;
use Deploymentcenter\Core\Config; use Deploymentcenter\Core\Config;
use Deploymentcenter\Core\Http;
/** /**
* Die beiden WebAuthn-Zeremonien: Registrieren und Anmelden. * Die beiden WebAuthn-Zeremonien: Registrieren und Anmelden.
@@ -58,15 +57,51 @@ final class WebauthnServer
return $configured; return $configured;
} }
$host = parse_url(Http::baseUrl(), PHP_URL_HOST); $host = parse_url(self::configuredBaseUrl(), PHP_URL_HOST);
return is_string($host) && $host !== '' ? $host : 'localhost'; if (!is_string($host) || $host === '') {
throw new \RuntimeException(
'Die Kennung fuer WebAuthn laesst sich nicht bestimmen. '
. 'Bitte app.url oder security.webauthn_rp_id in der config.php setzen.'
);
}
return $host;
} }
/** Der Ursprung, den der Browser in clientDataJSON eintragen muss. */ /** Der Ursprung, den der Browser in clientDataJSON eintragen muss. */
public static function expectedOrigin(): string public static function expectedOrigin(): string
{ {
return rtrim(Http::baseUrl(), '/'); return rtrim(self::configuredBaseUrl(), '/');
}
/**
* Die Basisadresse - ohne den Rueckfall auf den Host-Header.
*
* Http::baseUrl() faellt auf $_SERVER['HTTP_HOST'] zurueck, wenn app.url
* leer ist. Fuer die meisten Zwecke ist das bequem und harmlos. Hier
* nicht: Aus diesem Wert entstehen rpId() und expectedOrigin(), also die
* beiden Anker, gegen die die ganze Zeremonie geprueft wird. Ein
* Angreifer, der den Host-Header setzt, bestimmte damit selbst, wogegen
* seine Anmeldung geprueft wird - und die Pruefung ginge auf.
*
* Deshalb hier lieber abbrechen als dem Header glauben. Eine
* Authentifizierung, die sich ihren eigenen Massstab vom Aufrufer geben
* laesst, ist keine.
*/
private static function configuredBaseUrl(): string
{
$configured = trim((string)Config::get('app.url', ''));
if ($configured === '') {
throw new \RuntimeException(
'app.url ist nicht gesetzt. Fuer WebAuthn ist das keine Kleinigkeit: '
. 'Ohne festen Wert wuerde der Host-Header des Aufrufers bestimmen, '
. 'gegen welche Adresse geprueft wird.'
);
}
return rtrim($configured, '/');
} }
public static function challenge(): string public static function challenge(): string
+3
View File
@@ -466,6 +466,9 @@ def run(cfg, gruppen=None, muster=None, reset=True, bericht_datei=None, stoppen=
return 2 return 2
ctx['master_token'] = r.feld('master', 'raw_token') ctx['master_token'] = r.feld('master', 'raw_token')
ctx['admin'] = r.feld('admin') ctx['admin'] = r.feld('admin')
# Das Installationskonto. Seit 2.9.3 nimmt /api/setup/v1/login nur
# noch die Rolle "installer" an; die Setup-Faelle brauchen es.
ctx['installer'] = r.feld('installer')
client.token = ctx['master_token'] client.token = ctx['master_token']
print(" %d Migration(en), %d Tabellen, Master-Token bereit." % ( print(" %d Migration(en), %d Tabellen, Master-Token bereit." % (
len(r.feld('migration', 'applied', default=[])), len(r.feld('migration', 'applied', default=[])),
+1
View File
@@ -30,6 +30,7 @@ Gruppen:
setup Erstinstallation setup Erstinstallation
supervisor Befunde supervisor Befunde
hardware Welche Authenticatoren zugelassen sind hardware Welche Authenticatoren zugelassen sind
haertung Haertung der Anmeldewege
""" """
import argparse import argparse
+37 -3
View File
@@ -418,11 +418,25 @@ def _(ctx):
# Erstinstallation # Erstinstallation
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _installer(ctx):
"""Zugangsdaten des Installationskontos, oder ueberspringen."""
konto = ctx.get('installer')
if not konto or not konto.get('username'):
ueberspringen('Kein Installationskonto - Lauf ohne --reset oder vor Migration 012.')
return konto
@test('setup', 'Anmeldung liefert ein kurzlebiges Token') @test('setup', 'Anmeldung liefert ein kurzlebiges Token')
def _(ctx): def _(ctx):
"""
Bewusst mit dem INSTALLATIONSKONTO, nicht mit dem Administrator. Bis
2.9.3 nahm dieser Endpunkt jedes Konto an, und dieser Testfall bildete
das ab - er pruefte damit einen Weg, den es nicht geben sollte.
"""
konto = _installer(ctx)
r = ctx.client.post('/api/setup/v1/login', token=None, body={ r = ctx.client.post('/api/setup/v1/login', token=None, body={
'username': ctx.cfg['admin']['user'], 'username': konto['username'],
'password': ctx.cfg['admin']['pass'], 'password': konto['password'],
'hostname': MASCHINE, 'hostname': MASCHINE,
}) })
erwarte_erfolg(r) erwarte_erfolg(r)
@@ -445,10 +459,30 @@ def _(ctx):
"Unerwartete Rechte am Setup-Token: %s" % scopes) "Unerwartete Rechte am Setup-Token: %s" % scopes)
@test('setup', 'Ein Administratorkonto kommt hier nicht herein')
def _(ctx):
"""
Die Rollentrennung gilt seit 2.9.3 in beide Richtungen: Installer nicht
ins WebUI, Administratoren nicht in die Setup-API. Wer auf jedem
Zielsystem sein Administratorpasswort eingibt, verteilt den Zugang zur
gesamten Verwaltung - und bis 2.9.3 bekam er dafuer auch noch ein Token,
ohne den hinterlegten Hardwarekey vorzeigen zu muessen.
"""
r = ctx.client.post('/api/setup/v1/login', token=None, body={
'username': ctx.cfg['admin']['user'],
'password': ctx.cfg['admin']['pass'],
'hostname': MASCHINE,
})
erwarte_status(r, 403)
erwarte(r.fehlercode in ('installer_account_required', 'second_factor_required'),
'Erwartet wurde eine Ablehnung wegen Rolle oder zweitem Faktor, kam: %s' % r.kurz(300))
@test('setup', 'Anmeldung mit falschem Passwort abgewiesen') @test('setup', 'Anmeldung mit falschem Passwort abgewiesen')
def _(ctx): def _(ctx):
konto = _installer(ctx)
r = ctx.client.post('/api/setup/v1/login', token=None, body={ r = ctx.client.post('/api/setup/v1/login', token=None, body={
'username': ctx.cfg['admin']['user'], 'password': 'garantiert-falsch', 'username': konto['username'], 'password': 'garantiert-falsch',
}) })
erwarte_fehler(r, http=(401, 429), code=['invalid_credentials', 'locked_out']) erwarte_fehler(r, http=(401, 429), code=['invalid_credentials', 'locked_out'])
+90 -1
View File
@@ -42,7 +42,7 @@ import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from harness import test, erwarte, ueberspringen, Fehlschlag from harness import (test, erwarte, erwarte_status, ueberspringen, Fehlschlag)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -171,6 +171,16 @@ class Sitzung:
except ValueError: except ValueError:
return e.code, {'roh': roh[:400]} return e.code, {'roh': roh[:400]}
def kopfzeilen(self, pfad):
"""Antwortkopf einer Anfrage, Schluessel kleingeschrieben."""
req = urllib.request.Request(self.basis + pfad, headers=self._kopf())
try:
with self.opener.open(req, timeout=45) as r:
roh = r.headers
except urllib.error.HTTPError as e:
roh = e.headers
return {k.lower(): v for k, v in roh.items()}
def anmelden(self, benutzer, passwort): def anmelden(self, benutzer, passwort):
self.hole('/login.php') self.hole('/login.php')
status, _ = self.formular('/login.php', { status, _ = self.formular('/login.php', {
@@ -386,3 +396,82 @@ def t_aufraeumen(ctx):
# stehen, verlangte das Testkonto beim naechsten Lauf einen zweiten # stehen, verlangte das Testkonto beim naechsten Lauf einen zweiten
# Faktor, den niemand vorzeigen kann. # Faktor, den niemand vorzeigen kann.
_schluessel_loeschen(ctx) _schluessel_loeschen(ctx)
# ---------------------------------------------------------------------------
# Haertung der Anmeldewege
#
# Ergebnis einer Sicherheitsbegutachtung. Der rote Faden aller Befunde: Die
# WebAuthn-Zeremonie war in Ordnung, der zweite Faktor trotzdem umgehbar -
# ueber Wege DANEBEN. Was hier geprueft wird, sind genau diese Wege.
# ---------------------------------------------------------------------------
@test('haertung', 'Die Anmeldung verraet nicht, ob es ein Konto gibt')
def t_keine_enumeration(ctx):
"""
Gesperrt und gezaehlt wird nur, was existiert. Unterscheidet die Seite
sichtbar zwischen "Konto gesperrt" und "ungueltige Daten", genuegen
fuenf Versuche pro Name, um seine Existenz zu bestaetigen - und die
sorgfaeltige Laufzeitangleichung ueber dummyHash() waere umsonst.
Ein Fehlversuch gegen das echte Konto ist dabei unvermeidlich; die
Kontosperre greift erst beim fuenften.
"""
s = Sitzung(ctx.cfg)
s.hole('/login.php')
_, unbekannt = s.formular('/login.php', {
'username': 'gibt-es-garantiert-nicht-4711', 'password': 'beliebig'})
s2 = Sitzung(ctx.cfg)
s2.hole('/login.php')
_, vorhanden = s2.formular('/login.php', {
'username': (ctx.cfg.get('admin') or {}).get('user') or 'admin',
'password': 'garantiert-falsch'})
def meldung(seite):
treffer = re.search(r'<div class="alert-danger">(.*?)</div>', seite, re.S)
return re.sub(r'\s+', ' ', treffer.group(1)).strip() if treffer else ''
a, b = meldung(unbekannt), meldung(vorhanden)
erwarte(a != '', 'Kein Fehlertext fuer den unbekannten Namen gefunden.')
erwarte(a == b,
'Die Meldungen unterscheiden sich und verraten damit die Existenz '
'des Kontos:\n unbekannt: %r\n vorhanden: %r' % (a, b))
@test('haertung', 'Die Sicherheits-Header sind gesetzt')
def t_header(ctx):
"""
HSTS und CSP. Zur CSP gehoert die Einschraenkung, dass sie wegen der
Inline-Skripte in index.php 'unsafe-inline' enthaelt und deshalb NICHT
gegen XSS traegt - geprueft wird hier, dass die Richtlinien ueberhaupt
ankommen, nicht dass sie alles loesen.
"""
s = Sitzung(ctx.cfg)
kopf = s.kopfzeilen('/login.php')
hsts = kopf.get('strict-transport-security', '')
erwarte('max-age=' in hsts, 'Kein Strict-Transport-Security: %r' % hsts)
csp = kopf.get('content-security-policy', '')
for pflicht in ("frame-ancestors 'none'", "base-uri 'none'", "form-action 'self'"):
erwarte(pflicht in csp, 'In der CSP fehlt %r: %r' % (pflicht, csp))
@test('haertung', 'Die Gesundheitsauskunft nennt den WebAuthn-Anker')
def t_anker(ctx):
"""
rpId und expectedOrigin stammen nicht mehr aus dem Host-Header. Die
Folge ist, dass eine Anlage ohne app.url den Hardwarekey abweist - und
genau das soll man sehen koennen, BEVOR jemand sich anzumelden versucht.
"""
r = ctx.client.get('/api/health', token=None)
erwarte_status(r, 200)
anker = r.feld('checks', 'webauthn_anchor')
erwarte(isinstance(anker, dict),
'Die Gesundheitsauskunft nennt keinen webauthn_anchor: %s' % r.kurz(300))
erwarte(anker.get('ok') is True,
'Der WebAuthn-Anker ist nicht gesetzt - app.url fehlt: %s' % anker)
+30
View File
@@ -200,6 +200,33 @@ switch ($action) {
// Ohne Rollenspalte ist jeder Benutzer Administrator - nichts zu tun. // Ohne Rollenspalte ist jeder Benutzer Administrator - nichts zu tun.
} }
// ------------------------------------------------------------------
// Installationskonto
// ------------------------------------------------------------------
// Seit 2.9.3 nimmt /api/setup/v1/login nur noch Konten der Rolle
// "installer" an. Die Setup-Testfaelle benutzten bis dahin das
// Administratorkonto - und pruefen damit gerade den Weg, der jetzt
// verschlossen ist. Ein eigenes Konto hier anzulegen ist nicht bloss
// Testkosmetik: Es bildet ab, wie der Weg in der Produktion gedacht
// ist, und die Testfaelle liefen bisher auf einer Abkuerzung.
$installerUser = $adminUser . '-installer';
$installerPass = bin2hex(random_bytes(12));
$db->prepare('DELETE FROM dc_users WHERE username = :u')->execute([':u' => $installerUser]);
$db->prepare('
INSERT INTO dc_users (username, password_hash, created_at)
VALUES (:u, :h, UTC_TIMESTAMP())
')->execute([':u' => $installerUser, ':h' => password_hash($installerPass, PASSWORD_DEFAULT)]);
$installerOk = false;
try {
$db->prepare('UPDATE dc_users SET role = "installer" WHERE username = :u')
->execute([':u' => $installerUser]);
$installerOk = true;
} catch (Throwable $e) {
// Ohne Rollenspalte (vor Migration 012) gibt es keine Installer.
}
$manager = new TokenManager($db); $manager = new TokenManager($db);
$master = $manager->createMasterToken( $master = $manager->createMasterToken(
'Testlauf (automatisch)', 'Testlauf (automatisch)',
@@ -215,6 +242,9 @@ switch ($action) {
'dropped' => $dropped, 'dropped' => $dropped,
'migration' => $result, 'migration' => $result,
'admin' => ['username' => $adminUser, 'password' => $adminPass], 'admin' => ['username' => $adminUser, 'password' => $adminPass],
'installer' => $installerOk
? ['username' => $installerUser, 'password' => $installerPass]
: null,
'master' => ['token_id' => $master['token_id'], 'raw_token' => $master['raw_token']], 'master' => ['token_id' => $master['token_id'], 'raw_token' => $master['raw_token']],
'state' => $status(), 'state' => $status(),
]); ]);