From f0ed67ad29a83733def9ee51b15ddca477f40549 Mon Sep 17 00:00:00 2001 From: Deploymentcenter Bot Date: Sun, 20 Sep 2026 18:16:51 +0200 Subject: [PATCH] feat(2.9.0): Hardwarekey an der WebUI-Anmeldung und die Roadmap als Tafel Zwei angeforderte Funktionen, dazu zwei Befunde, die ihnen im Weg lagen. == Zweite Stufe der Anmeldung (#666) == Nach korrektem Passwort verlangt das WebUI einen registrierten FIDO2-Schluessel. Die Pflicht haengt je Konto daran, OB ein Schluessel registriert ist - nicht an einem Schalter. Ein Schalter, den man vor der Registrierung umlegt, sperrt den Betreiber aus seiner eigenen Verwaltung aus. Ohne Fremdbibliothek gebaut (das Projekt hat bewusst kein Composer): src/Core/Webauthn/ mit CBOR-Leser, COSE-nach-DER-Wandlung und Pruefung ueber openssl_verify (ES256, RS256). Auth::login() heisst jetzt Auth::startLogin() und liefert drei Zustaende statt eines bool - aus "wahr heisst drin" ist "wahr heisst vielleicht drin" geworden, und ein uebersehener Aufrufer soll an einem fehlenden Methodennamen scheitern statt an einer truthy Zeichenkette. Die Setup-API der Zielsysteme geht ueber verifyCredentials() daran vorbei und bleibt unberuehrt. Notfalltoken als Ersatzweg: nur als Hash in der Datenbank, Klartext einmalig angezeigt, Einmalgebrauch. Laeuft als gewoehnliches Formular ohne JavaScript - ein Ersatzweg mit denselben Voraussetzungen wie der Hauptweg waere keiner. Fehlversuche auf der zweiten Stufe laufen in dieselbe Konto- und IP-Sperre wie das Passwort; zuvor zaehlte nur die erste Stufe. Benutzerverwaltung kann Konten umbenennen. Beim Loeschen eines Kontos und beim Zuruecksetzen der Testinstanz werden seine Schluessel mitgenommen: credential_id ist geraeteweit eindeutig, eine verwaiste Zeile haette denselben Yubikey dauerhaft unbrauchbar gemacht. == Roadmap-Tafel (#664) == Neuer Unterreiter Roadmap -> Tafel. Der Server liefert die fertige Gliederung (RoadmapBoard, ?action=board), die Seite rendert nur - sonst schneidet das WebUI anders als die API und ein spaeterer Export ein drittes Mal anders. Phasen stehen nicht im Code: gepflegt in der neuen Tabelle roadmap_phases, sonst an den groessten Luecken der Rangfolge geschnitten und sichtbar als geraten gekennzeichnet. Links die gekappte Spalte des Erledigten, rechts "Wird ueberlegt". Nachpruefung ist ein eigener Zustand und wird nicht als erledigt gezeichnet. Klick oeffnet ein Seitenpanel: Volltext, Verlauf, Kommentieren mit action_taken, Aendern von Bereich, Rang, Zielfassung, Status und Ausloeser - ohne Seitenwechsel. Das war der eigentliche Gewinn: Wer etwas bemerkte, musste sich bisher die Nummer merken und im Bugtracker danach suchen. Tiefe Verweise #roadmap//item/. Ziehen zwischen Phasen ueber ?action=rank_between. Fremdtext wird ausschliesslich ueber textContent gesetzt, nie ueber innerHTML. == Befund: roadmap_rank war projektweit und bereichslokal zugleich (#177) == Entschieden: projektweit. move() sucht den Nachbarn ohne Bereichsfilter, reorder() schreibt keinen ganzen Bereich mehr auf 10/20/30 um, sondern vertauscht nur die Raenge, die die Eintraege ohnehin halten. rank_between rechnet die Mitte zwischen zwei Nachbarn und nummeriert nur dann einmal neu - unter Erhalt der Reihenfolge -, wenn dort kein Platz mehr ist; die Antwort sagt es dann. == Befund: CSRF an sitzungsauthentifizierten API-Schreibzugriffen == ApiAuth akzeptiert die WebUI-Sitzung, die Endpunkte prueften dabei kein CSRF-Token. Der Kommentar in Http::beginJson - "ausschliesslich per Token-Header, niemals per Cookie" - traf auf die API nie zu. Schreibende Anfragen im Sitzungsweg verlangen jetzt csrf_token bzw. X-CSRF-Token. Token-Clients sind nicht betroffen und wurden gegengeprueft. == Geprueft == tests/e2e/suite_tafel.py neu (16 Faelle), Suite gesamt 172 von 172 gegen die Testinstanz. Die WebAuthn-Zeremonien zusaetzlich gegen einen Software- Authenticator mit echtem P-256 und echtem CBOR: Registrieren, Anmelden, verfaelschte Signatur, veraltete Herausforderung und Zaehler-Rueckfall als Klonverdacht werden je mit 403 abgewiesen. Co-Authored-By: Claude Opus 5 --- .htaccess | 1 + docs/ROADMAP_MIGRATION.md | 10 + docs/UPGRADE.md | 86 + mcp/deploymentcenter-mcp.mjs | 2 +- public/api/auth/v1/index.php | 248 +++ public/api/bugtracker/v1/roadmap.php | 81 +- public/api/openapi.php | 99 +- public/docs/changelog.json | 73 +- public/index.php | 1450 ++++++++++++++++- public/login.php | 326 +++- .../019_webauthn_and_roadmap_board.sql | 101 ++ src/Core/ApiAuth.php | 16 + src/Core/Auth.php | 223 ++- src/Core/Webauthn/Cbor.php | 199 +++ src/Core/Webauthn/CoseKey.php | 256 +++ src/Core/Webauthn/CredentialRepo.php | 303 ++++ src/Core/Webauthn/WebauthnServer.php | 369 +++++ src/Modules/Bugtracker/Roadmap.php | 308 +++- src/Modules/Bugtracker/RoadmapBoard.php | 563 +++++++ tests/e2e/run.py | 1 + tests/e2e/suite_tafel.py | 336 ++++ tests/server/testctl.php | 15 + 22 files changed, 4996 insertions(+), 70 deletions(-) create mode 100644 public/api/auth/v1/index.php create mode 100644 sql/migrations/019_webauthn_and_roadmap_board.sql create mode 100644 src/Core/Webauthn/Cbor.php create mode 100644 src/Core/Webauthn/CoseKey.php create mode 100644 src/Core/Webauthn/CredentialRepo.php create mode 100644 src/Core/Webauthn/WebauthnServer.php create mode 100644 src/Modules/Bugtracker/RoadmapBoard.php create mode 100644 tests/e2e/suite_tafel.py diff --git a/.htaccess b/.htaccess index ad6cf1e..fa44a65 100644 --- a/.htaccess +++ b/.htaccess @@ -36,6 +36,7 @@ Options -Indexes RewriteRule ^api/tokens/v1/provision/?$ public/api/tokens/v1/provision.php [L,QSA] RewriteRule ^api/tokens/v1/whoami/?$ public/api/tokens/v1/whoami.php [L,QSA] RewriteRule ^api/setup/v1(?:/(.*))?$ public/api/setup/v1/index.php [L,QSA] + RewriteRule ^api/auth/v1(?:/(.*))?$ public/api/auth/v1/index.php [L,QSA] RewriteRule ^api/supervisor/v1(?:/(.*))?$ public/api/supervisor/v1/index.php [L,QSA] # Fallback fuer direkt adressierte Dateien unterhalb von /api/ diff --git a/docs/ROADMAP_MIGRATION.md b/docs/ROADMAP_MIGRATION.md index e6aef3e..bed8cde 100644 --- a/docs/ROADMAP_MIGRATION.md +++ b/docs/ROADMAP_MIGRATION.md @@ -179,6 +179,16 @@ Zur Reihenfolge: **Zehnerschritte.** Die Lücken sind Absicht — ein Eintrag lässt sich später auf Rang 15 dazwischenschieben, ohne dass alle folgenden neu nummeriert werden müssen. +**Der Rang gilt projektweit, nicht innerhalb des Bereichs.** Hier stand +einmal „Reihenfolge im Bereich“, und das war die Hälfte eines Widerspruchs +(#177): `dc_next_task` und die Roadmap-Tafel lesen den Rang ohne jede +Gruppierung nach `epic`. Wer je Bereich bei 10 anfängt, setzt damit das erste +Vorhaben **jedes** Bereichs gleichauf an die Spitze — und dann entscheidet der +Schweregrad die Reihenfolge, also ausgerechnet die Größe, von der hier steht, +sie sei keine Priorität. Eine durchgehende Liste über mehrere Bereiche hinweg +ist deshalb genau richtig: `A` kann auf 10 stehen, `C` auf 20, `A` wieder auf +30. + Zurückgestelltes braucht den Auslöser, sonst wird der Aufruf abgelehnt: ``` diff --git a/docs/UPGRADE.md b/docs/UPGRADE.md index 58c9aa8..9d11e5c 100644 --- a/docs/UPGRADE.md +++ b/docs/UPGRADE.md @@ -588,3 +588,89 @@ Rückgabewert 2. Zum Abschalten die erzeugten `.htaccess`-Dateien in den Produktverzeichnissen löschen. Sie entstehen beim nächsten Auslöser neu. + +--- + +## 17. Umstellung auf 2.9 + +Zwei Dinge kommen dazu: ein Hardwarekey als zweite Stufe der WebUI-Anmeldung +und die Roadmap-Tafel. Nur das Erste braucht Aufmerksamkeit — beim Zweiten +kann nichts schiefgehen, was sich nicht durch Neuladen beheben ließe. + +### 17.1 Die Reihenfolge, und warum sie zählt + +``` +1. Deployment +2. Migration 019 ausführen +3. Anmelden, Hardwarekey registrieren +4. Notfalltoken erzeugen und ausdrucken +``` + +**Die Pflicht zum zweiten Faktor hängt daran, ob ein Schlüssel registriert +ist — nicht an einem Schalter.** Das ist Absicht und der Grund, warum diese +Reihenfolge ungefährlich ist: Solange kein Schlüssel hinterlegt ist, läuft die +Anmeldung genau wie vorher. Ein Schalter, den man vor der Registrierung +umlegt, hätte die entgegengesetzte Eigenschaft. + +Nach Schritt 3 ist das Passwort allein nicht mehr genug. Deshalb gehört +Schritt 4 unmittelbar dahinter und nicht auf die Liste für nächste Woche. + +### 17.2 Der Schlüssel gilt nur für eine Adresse + +Ein auf `dc.mhdf.de` registrierter Yubikey funktioniert auf +`dc-dev.mhdf.de` **nicht**, und umgekehrt. Das ist keine Einschränkung der +Umsetzung, sondern der Kern des Verfahrens: Genau deshalb läuft ein +Anmeldeversuch auf einer nachgebauten Seite ins Leere. Auf beiden Instanzen +also je einmal registrieren. + +Registriert wird unter **System → 👤 Benutzer → Meine Hardwarekeys**. +Registrieren Sie zwei, wenn Sie zwei haben — der Notfalltoken ist der +Ersatzweg, nicht der zweite Schlüssel. + +### 17.3 Der Notfalltoken + +Fünf Gruppen zu vier Zeichen, **einmalig** bei der Erzeugung angezeigt. In der +Datenbank liegt nur ein Hash; wer sie liest, hält damit keinen Zweitschlüssel +in der Hand. Er gilt für **genau eine** Anmeldung und ist danach verbraucht — +die Meldezeile sagt es Ihnen. + +Beim Eintippen zählen nur Buchstaben und Ziffern. Bindestriche und +Groß-/Kleinschreibung sind gleichgültig. + +### 17.4 Wenn Schlüssel *und* Notfalltoken weg sind + +Dann hilft nur die Datenbank. Der Weg zurück ist, die Schlüssel des Kontos zu +entfernen — danach genügt wieder das Passwort allein: + +```sql +DELETE FROM dc_user_credentials +WHERE user_id = (SELECT id FROM dc_users WHERE username = ''); +``` + +### 17.5 Konto umbenennen + +**System → 👤 Benutzer** kann jetzt auch den Anmeldenamen ändern. Registrierte +Hardwarekeys hängen an der Konto-ID und überstehen das; offene Sperrfenster des +alten Namens werden dabei aufgeräumt. + +### 17.6 Was sich an der API ändert + +- Sitzungsauthentifizierte **Schreibzugriffe** auf die API verlangen jetzt ein + CSRF-Token (`csrf_token` im Rumpf oder der Kopf `X-CSRF-Token`). + Token-Clients — Agenten, MCP-Adapter, `scripts/dc.py` — sind **nicht** + betroffen: Sie schicken keinen Cookie. Betroffen sind nur eigene Skripte, + die sich auf eine WebUI-Sitzung stützen. +- `roadmap_rank` gilt **projektweit**, nicht je Bereich (#177). `move` sucht + den Nachbarn entsprechend ohne Bereichsfilter, und `reorder` zählt nicht mehr + je Bereich bei 10 neu durch, sondern vertauscht nur die Ränge, die die + genannten Einträge ohnehin halten. Wer bisher je Bereich ab 10 vergeben hat, + sollte die Reihenfolge einmal durchsehen. + +### 17.7 Prüfen, ob alles läuft + +- [ ] Anmeldung mit Passwort führt auf die zweite Stufe +- [ ] Hardwarekey schließt sie ab +- [ ] Notfalltoken schließt sie ebenfalls ab und ist danach verbraucht +- [ ] Roadmap → 🧭 Tafel zeigt Spalten, Spuren und Kärtchen +- [ ] Ein Klick auf ein Kärtchen öffnet das Panel, Kommentieren funktioniert +- [ ] `python tests/e2e/run.py` gegen die Testinstanz: alles grün diff --git a/mcp/deploymentcenter-mcp.mjs b/mcp/deploymentcenter-mcp.mjs index b567249..a4bbfb7 100644 --- a/mcp/deploymentcenter-mcp.mjs +++ b/mcp/deploymentcenter-mcp.mjs @@ -465,7 +465,7 @@ const TOOLS = [ properties: { id: { type: 'integer', description: 'Item-ID.' }, epic: { type: 'string', description: 'Themenbereich; leer nimmt es von der Roadmap.' }, - roadmap_rank: { type: 'integer', description: 'Reihenfolge im Bereich, Zehnerschritte.' }, + roadmap_rank: { type: 'integer', description: 'Reihenfolge im PROJEKT, Zehnerschritte. Nicht im Bereich: dc_next_task und die Roadmap-Tafel lesen den Rang ohne Gruppierung nach epic (siehe #177).' }, target_build: { type: 'string', description: 'Geplante Zielfassung, z. B. "2.7.0".' }, status: { type: 'string', enum: STATUSES }, defer_trigger: { diff --git a/public/api/auth/v1/index.php b/public/api/auth/v1/index.php new file mode 100644 index 0000000..fd148c9 --- /dev/null +++ b/public/api/auth/v1/index.php @@ -0,0 +1,248 @@ +rawIdsForUser($userId); + if ($ids === []) { + Http::fail(409, 'no_credentials', 'Fuer dieses Konto ist kein Hardwarekey hinterlegt.'); + } + + $challenge = WebauthnServer::challenge(); + Auth::storeChallenge($challenge); + + Http::ok(['publicKey' => WebauthnServer::assertionOptions($challenge, $ids)]); + break; + } + + case 'login_verify': { + $userId = Auth::pendingUserId(); + if ($userId <= 0) { + Http::fail(401, 'no_pending_login', 'Es steht keine Anmeldung offen. Bitte von vorn beginnen.'); + } + + $challenge = Auth::takeChallenge(); + if ($challenge === '') { + Http::fail(400, 'challenge_expired', 'Die Anfrage ist abgelaufen. Bitte erneut versuchen.'); + } + + $credentialId = WebauthnServer::b64uDecode((string)Http::str('id', '')); + $clientDataJson = WebauthnServer::b64uDecode((string)Http::str('clientDataJSON', '')); + $authenticatorRaw = WebauthnServer::b64uDecode((string)Http::str('authenticatorData', '')); + $signature = WebauthnServer::b64uDecode((string)Http::str('signature', '')); + + if ($credentialId === '' || $clientDataJson === '' || $authenticatorRaw === '' || $signature === '') { + Http::fail(400, 'incomplete', 'Die Antwort des Browsers ist unvollstaendig.'); + } + + $credential = $repo->find($userId, $credentialId); + + if ($credential === null) { + // Der Schluessel gehoert nicht zu diesem Konto. Das ist kein + // Bedienfehler, das ist ein Fehlversuch - und zaehlt als solcher. + Auth::recordSecondFactorFailure($db, 'unknown_credential'); + Http::fail(403, 'unknown_credential', 'Dieser Hardwarekey ist fuer das Konto nicht hinterlegt.'); + } + + try { + $result = WebauthnServer::verifyAssertion( + $clientDataJson, + $authenticatorRaw, + $signature, + $challenge, + (string)$credential['public_key'], + (int)$credential['alg'], + (int)$credential['sign_count'] + ); + } catch (\Throwable $e) { + Auth::recordSecondFactorFailure($db, 'assertion_rejected'); + Logger::warning('Hardwarekey abgelehnt', [ + 'user_id' => $userId, + 'error' => $e->getMessage(), + ]); + Http::fail(403, 'assertion_rejected', $e->getMessage()); + } + + $repo->touch((int)$credential['id'], $result['sign_count']); + + if (!Auth::completeSecondFactor($db, 'hardware_key')) { + Http::fail(403, 'login_failed', 'Die Anmeldung konnte nicht abgeschlossen werden.'); + } + + Http::ok(['redirect' => '/index.php']); + break; + } + + // ----------------------------------------------------------------- + // Registrieren - nur aus einer bestehenden Anmeldung heraus + // ----------------------------------------------------------------- + case 'register_options': { + if (!Auth::isLoggedIn()) { + Http::fail(401, 'unauthorized', 'Anmeldung erforderlich.'); + } + Csrf::requireValid(); + + $userId = Auth::userId(); + + $challenge = WebauthnServer::challenge(); + Auth::storeChallenge($challenge); + + Http::ok([ + 'publicKey' => WebauthnServer::registrationOptions( + $challenge, + $userId, + Auth::username(), + $repo->rawIdsForUser($userId) + ), + ]); + break; + } + + case 'register_verify': { + if (!Auth::isLoggedIn()) { + Http::fail(401, 'unauthorized', 'Anmeldung erforderlich.'); + } + Csrf::requireValid(); + + $userId = Auth::userId(); + + $challenge = Auth::takeChallenge(); + if ($challenge === '') { + Http::fail(400, 'challenge_expired', 'Die Anfrage ist abgelaufen. Bitte erneut versuchen.'); + } + + $clientDataJson = WebauthnServer::b64uDecode((string)Http::str('clientDataJSON', '')); + $attestationObject = WebauthnServer::b64uDecode((string)Http::str('attestationObject', '')); + + if ($clientDataJson === '' || $attestationObject === '') { + Http::fail(400, 'incomplete', 'Die Antwort des Browsers ist unvollstaendig.'); + } + + $label = Http::str('label') ?? 'Hardwarekey'; + $label = mb_substr(trim($label), 0, 64); + if ($label === '') { + $label = 'Hardwarekey'; + } + + $transports = Http::input('transports'); + $transports = is_array($transports) + ? mb_substr(implode(',', array_map('strval', $transports)), 0, 128) + : null; + + try { + $registration = WebauthnServer::verifyRegistration($clientDataJson, $attestationObject, $challenge); + } catch (\Throwable $e) { + Logger::warning('Registrierung eines Hardwarekeys abgelehnt', [ + 'user_id' => $userId, + 'error' => $e->getMessage(), + ]); + Http::fail(400, 'registration_rejected', $e->getMessage()); + } + + if ($repo->find($userId, $registration['credential_id']) !== null) { + Http::fail(409, 'already_registered', 'Dieser Hardwarekey ist bereits hinterlegt.'); + } + + try { + $repo->add( + $userId, + $registration['credential_id'], + $registration['public_key'], + $registration['alg'], + $registration['sign_count'], + $registration['aaguid'], + $label, + $transports + ); + } catch (\PDOException $e) { + // Eindeutiger Schluessel verletzt: derselbe Key an einem anderen + // Konto. Die Kennung ist geraeteweit eindeutig, deshalb faellt + // das hier und nicht in der Suche oben auf. + if ((string)$e->getCode() === '23000') { + Http::fail(409, 'already_registered', 'Dieser Hardwarekey ist bereits einem Konto zugeordnet.'); + } + throw $e; + } + + // Ein neuer zweiter Faktor am eigenen Konto gehoert ins + // Sicherheitsprotokoll - es ist die Aenderung, die im Ernstfall die + // Frage beantwortet, seit wann ein fremder Schluessel mitliest. + SecurityLogger::log('credential_registered', Http::clientIp(), [ + 'username' => Auth::username(), + 'label' => $label, + ]); + + Http::ok([ + 'label' => $label, + 'total' => $repo->countForUser($userId), + 'message' => 'Hardwarekey registriert.', + ]); + break; + } + + default: + Http::fail(404, 'unknown_action', sprintf('Unbekannte Aktion "%s".', $action), null, [ + 'available' => ['login_options', 'login_verify', 'register_options', 'register_verify'], + ]); +} diff --git a/public/api/bugtracker/v1/roadmap.php b/public/api/bugtracker/v1/roadmap.php index e500c68..c29ca91 100644 --- a/public/api/bugtracker/v1/roadmap.php +++ b/public/api/bugtracker/v1/roadmap.php @@ -31,11 +31,12 @@ use Deploymentcenter\Core\Db; use Deploymentcenter\Core\Http; use Deploymentcenter\Modules\Bugtracker\BugRepo; use Deploymentcenter\Modules\Bugtracker\Roadmap; +use Deploymentcenter\Modules\Bugtracker\RoadmapBoard; Http::beginJson(['GET', 'POST', 'OPTIONS'], true); -const READ_ACTIONS = ['list', 'epics', 'next', 'active', 'render', 'check']; -const WRITE_ACTIONS = ['set', 'move', 'reorder', 'epic']; +const READ_ACTIONS = ['list', 'epics', 'next', 'active', 'render', 'check', 'board']; +const WRITE_ACTIONS = ['set', 'move', 'reorder', 'epic', 'rank_between']; $action = strtolower(Http::str('action') ?? 'list'); @@ -98,6 +99,20 @@ switch ($action) { 'items' => $roadmap->activeWork($scope), ]); + case 'board': { + // Die fertige Gliederung der Tafel: Spalten, Spuren, Kaertchen. + // + // Ein Projekt, nicht "alle": Die Phasen werden aus roadmap_rank + // geschnitten, und der Rang gilt je Projekt. Zwei Projekte + // uebereinanderzulegen ergaebe Spalten, die in keinem von beiden + // stimmen. + $project = requireProject($scope, $context); + + Http::ok((new RoadmapBoard($db))->build($project, [ + 'done_limit' => Http::int('done_limit', RoadmapBoard::DONE_LIMIT), + ])); + } + case 'render': { $project = requireProject($scope, $context); Http::ok([ @@ -245,6 +260,60 @@ switch ($action) { Http::ok(['message' => sprintf('#%d verschoben.', $itemId)]); } + case 'rank_between': { + // Das Ziehen auf der Tafel. Der Aufrufer sagt, zwischen welche + // beiden Nachbarn der Eintrag gehoert; den Rang rechnet der Server + // aus. Andersherum - der Client schickt einen fertigen Rang - waere + // die Rangvergabe an zwei Stellen umgesetzt, und die zweite wuesste + // nichts von #177. + $itemId = Http::int('id', 0) ?: 0; + if ($itemId < 1) { + Http::fail(400, 'missing_id', 'Das Feld "id" fehlt.'); + } + + assertOwnProject($repo, $writeProject, $itemId); + + $prevId = Http::input('prev_id') !== null ? Http::int('prev_id', 0) : null; + $nextId = Http::input('next_id') !== null ? Http::int('next_id', 0) : null; + $minRank = Http::input('min_rank') !== null ? Http::int('min_rank', 0) : null; + $maxRank = Http::input('max_rank') !== null ? Http::int('max_rank', 0) : null; + + try { + $result = $roadmap->rankBetween( + $itemId, + $prevId !== null && $prevId > 0 ? $prevId : null, + $nextId !== null && $nextId > 0 ? $nextId : null, + $minRank, + $maxRank + ); + } catch (\RuntimeException $e) { + Http::fail(409, 'cannot_rank', $e->getMessage()); + } + + $repo->addComment( + $itemId, + 'user:' . $author, + sprintf( + 'Reihenfolge auf der Tafel geaendert: Rang %d.%s', + $result['rank'], + $result['renumbered'] + ? ' Dafuer wurden die Raenge des Projekts einmal neu vergeben (Zehnerschritte) -' + . ' die Reihenfolge blieb dabei erhalten.' + : '' + ), + 'commented' + ); + + Http::ok([ + 'id' => $itemId, + 'rank' => $result['rank'], + 'renumbered' => $result['renumbered'], + 'message' => $result['renumbered'] + ? sprintf('#%d eingeordnet (Rang %d). Die Raenge des Projekts wurden dabei neu vergeben.', $itemId, $result['rank']) + : sprintf('#%d eingeordnet (Rang %d).', $itemId, $result['rank']), + ]); + } + case 'reorder': { $project = requireProject($writeProject ?? $angefragt, $context); $epic = Http::str('epic'); @@ -271,12 +340,18 @@ switch ($action) { Http::fail(400, 'missing_fields', 'Es werden "epic" und "title" erwartet.'); } + // Nicht mitgeschickte Felder bleiben unveraendert - ein Aufruf, der + // nur den Titel richtigstellt, soll nicht die Farbe loeschen. + $highlight = Http::input('highlight'); + $roadmap->saveEpic( $project, $epic, $title, Http::str('summary'), - Http::int('sort_order', 100) ?: 100 + Http::int('sort_order', 100) ?: 100, + Http::input('color') !== null ? (string)Http::str('color', '') : null, + $highlight !== null ? (bool)$highlight : null ); Http::ok(['message' => sprintf('Themenbereich "%s" gespeichert.', $epic)]); diff --git a/public/api/openapi.php b/public/api/openapi.php index c65cc23..7c54752 100644 --- a/public/api/openapi.php +++ b/public/api/openapi.php @@ -422,12 +422,24 @@ $spec = [ . "- `action=active` wer haelt gerade welches Item\n" . "- `action=render` der erzeugte Markdown-Block fuer die Projektdatei\n" . "- `action=check` Abgleich einer mitgeschickten Datei (`content`) gegen " - . "die Items\n\n" + . "die Items\n" + . "- `action=board` die fertige Gliederung der Roadmap-Tafel\n\n" + . "`board` liefert `{phases[], lanes[], items[], stats, meta}` fuer genau " + . "EIN Projekt. Die Spalten werden aus `roadmap_rank` geschnitten - " + . "entweder nach den in `roadmap_phases` gepflegten Grenzen oder, wenn " + . "dort nichts steht, an den groessten Luecken der Rangfolge; dann ist " + . "`meta.phases_guessed` wahr und die Grenzen gehoeren als geraten " + . "gekennzeichnet. `done_limit` kappt die Spalte der erledigten Vorhaben " + . "(0 = alle).\n\n" + . "Titel, Beschreibungen und Kommentare in `items` sind Fremdtext von " + . "fremden Maschinen. Sie kommen unveraendert und gehoeren beim Rendern " + . "escaped - niemals als HTML eingesetzt.\n\n" . "`status` nimmt eine Lage statt eines ENUM-Werts: `aktiv`, `offen`, " . "`arbeit`, `erledigt`, `zurueckgestellt`, `verworfen`. Einzelstatus " . "funktionieren weiterhin.", 'parameters' => [ - ['name' => 'action', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => ['list', 'epics', 'next', 'active', 'render', 'check'], 'default' => 'list']], + ['name' => 'action', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => ['list', 'epics', 'next', 'active', 'render', 'check', 'board'], 'default' => 'list']], + ['name' => 'done_limit', 'in' => 'query', 'schema' => ['type' => 'integer', 'default' => 12], 'description' => 'Nur bei action=board: wie viele erledigte Vorhaben die Fundamentspalte zeigt. 0 = alle.'], ['name' => 'project_slug', 'in' => 'query', 'schema' => ['type' => 'string']], ['name' => 'epic', 'in' => 'query', 'schema' => ['type' => 'string'], 'description' => 'Themenbereich, z. B. "A"'], ['name' => 'status', 'in' => 'query', 'schema' => ['type' => 'string'], 'description' => 'Lage oder kommagetrennte Einzelstatus'], @@ -447,13 +459,26 @@ $spec = [ . "- `action=set&id=` setzt `epic`, `roadmap_rank`, `target_build`, " . "`defer_trigger`, `doc_anchor` und optional `status`\n" . "- `action=move&id=` schiebt einen Eintrag eine Position (`direction=up|down`)\n" - . "- `action=reorder` vergibt die Reihenfolge eines Bereichs neu (`epic`, `ids[]`)\n" - . "- `action=epic` legt einen Themenbereich an oder aendert ihn\n\n" + . "- `action=reorder` ordnet die Eintraege eines Bereichs untereinander neu (`epic`, `ids[]`)\n" + . "- `action=rank_between&id=` sortiert einen Eintrag zwischen zwei Nachbarn ein\n" + . "- `action=epic` legt einen Themenbereich an oder aendert ihn " + . "(`color`, `highlight` steuern die Spur auf der Tafel; nicht " + . "mitgeschickte Felder bleiben unveraendert)\n\n" + . "**`roadmap_rank` gilt PROJEKTWEIT, nicht je Bereich.** `action=next` " + . "sortiert danach ohne Gruppierung, und die Tafel schneidet ihre Phasen " + . "daraus. `move` sucht den Nachbarn deshalb projektweit, und `reorder` " + . "vertauscht nur die Raenge, die die genannten Eintraege ohnehin halten - " + . "es zaehlt nicht mehr je Bereich bei 10 neu durch.\n\n" + . "`rank_between` nimmt `prev_id` und `next_id` (beide optional) und " + . "setzt den Eintrag auf die Mitte dazwischen; fuer eine leere Zielspalte " + . "tun es `min_rank`/`max_rank`. Ist zwischen den Nachbarn kein Platz " + . "mehr, wird das Projekt EINMAL neu durchnummeriert - die Reihenfolge " + . "bleibt dabei erhalten, und die Antwort sagt es mit `renumbered: true`.\n\n" . "**Zurueckstellen verlangt einen Ausloeser.** `status=deferred` ohne " . "`defer_trigger` wird mit 400 abgelehnt: Ohne benannten Anlass ist ein " . "Vorhaben nicht zurueckgestellt, sondern vergessen.", 'parameters' => [ - ['name' => 'action', 'in' => 'query', 'required' => true, 'schema' => ['type' => 'string', 'enum' => ['set', 'move', 'reorder', 'epic']]], + ['name' => 'action', 'in' => 'query', 'required' => true, 'schema' => ['type' => 'string', 'enum' => ['set', 'move', 'reorder', 'rank_between', 'epic']]], ['name' => 'id', 'in' => 'query', 'schema' => ['type' => 'integer']], ], 'responses' => [ @@ -784,6 +809,70 @@ $spec = [ ], ], + '/api/auth/v1/' => [ + 'post' => [ + 'tags' => ['Setup'], + 'summary' => 'WebAuthn-Zeremonien der WebUI-Anmeldung', + 'description' => + "Die zweite Stufe der Anmeldung am WebUI: ein Hardwarekey (FIDO2/WebAuthn).\n\n" + . "**Dieser Endpunkt ist nichts fuer Agenten und Clients.** Er arbeitet mit dem " + . "Sitzungscookie des Browsers, nicht mit einem Token, und ist nur deshalb hier " + . "beschrieben, weil `navigator.credentials` sich nicht aus einem Formular " + . "heraus bedienen laesst.\n\n" + . "Vier Aktionen ueber `?action=`:\n" + . "- `login_options` / `login_verify` - laufen im Zwischenzustand zwischen " + . "Passwort und zweitem Faktor. Noch ist niemand angemeldet.\n" + . "- `register_options` / `register_verify` - setzen eine bestehende Anmeldung " + . "voraus und verlangen zusaetzlich ein CSRF-Token im Kopf `X-CSRF-Token`.\n\n" + . "Ein registrierter Schluessel gilt nur fuer die Adresse, unter der er " + . "registriert wurde. Ein Schluessel von `dc.mhdf.de` ist auf der Testinstanz " + . "wertlos und umgekehrt - das ist der Kern des Verfahrens und kein Mangel.\n\n" + . "Der Ersatzweg fuer einen fehlenden Schluessel ist der Notfalltoken; der " + . "laeuft als gewoehnliches Formular ueber `/login.php` und braucht kein " + . "JavaScript.", + 'security' => [], + 'parameters' => [ + ['name' => 'action', 'in' => 'query', 'required' => true, + 'schema' => ['type' => 'string', 'enum' => [ + 'login_options', 'login_verify', 'register_options', 'register_verify', + ]]], + ], + 'requestBody' => [ + 'required' => false, + 'content' => ['application/json' => ['schema' => [ + 'type' => 'object', + 'description' => 'Je nach Aktion. Alle Bytefelder sind base64url ohne Auffuellzeichen.', + 'properties' => [ + 'id' => ['type' => 'string', 'description' => 'login_verify: Kennung des benutzten Schluessels.'], + 'clientDataJSON' => ['type' => 'string'], + 'authenticatorData' => ['type' => 'string', 'description' => 'nur login_verify'], + 'signature' => ['type' => 'string', 'description' => 'nur login_verify'], + 'attestationObject' => ['type' => 'string', 'description' => 'nur register_verify'], + 'label' => ['type' => 'string', 'description' => 'register_verify: Bezeichnung des Schluessels.'], + 'transports' => ['type' => 'array', 'items' => ['type' => 'string']], + ], + ]]], + ], + 'responses' => [ + '200' => [ + 'description' => 'Vorgaben fuer den Browser, oder die bestaetigte Zeremonie', + 'content' => ['application/json' => ['schema' => [ + 'type' => 'object', + 'properties' => [ + 'publicKey' => ['type' => 'object', 'description' => 'Bei den *_options-Aktionen: direkt an navigator.credentials weiterzureichen.'], + 'redirect' => ['type' => 'string', 'description' => 'Bei login_verify: wohin der Browser danach gehoert.'], + ], + ]]], + ], + '400' => $errorResponse, + '401' => $errorResponse, + '403' => $errorResponse, + '409' => $errorResponse, + '419' => $errorResponse, + ], + ], + ], + '/api/setup/v1/catalog' => [ 'get' => [ 'tags' => ['Setup'], diff --git a/public/docs/changelog.json b/public/docs/changelog.json index 4159974..e365ba4 100644 --- a/public/docs/changelog.json +++ b/public/docs/changelog.json @@ -1,8 +1,79 @@ { "_comment": "Einzige Quelle der Wahrheit fuer den Changelog. Wird von /docs/changelog.php gerendert und von GET /api/updateservice/v1/changelog?since=X ausgeliefert. Neue Eintraege oben einfuegen.", "schema": 1, - "current": "2.8.2", + "current": "2.9.0", "versions": [ + { + "version": "2.9.0", + "date": "2026-09-20", + "summary": "Hardwarekey (WebAuthn) als zweite Stufe der WebUI-Anmeldung mit Notfalltoken als Ersatzweg, Konten lassen sich umbenennen, und die Roadmap gibt es jetzt als Tafel: Zeitstrahl nach Prioritaet mit Seitenpanel, Kommentieren und Ziehen.", + "actionRequired": true, + "changes": [ + { + "area": "auth", + "breaking": false, + "title": "Hardwarekey als zweite Stufe der WebUI-Anmeldung", + "text": "Nach korrektem Passwort verlangt das WebUI einen registrierten FIDO2-Schluessel (Yubikey und Gleichartiges). Die Pflicht haengt je Konto daran, OB ein Schluessel registriert ist - nicht an einem Schalter. Der Unterschied zeigt sich beim Einspielen: Ein Schalter, der vor der Registrierung umgelegt wird, sperrt den Betreiber aus seiner eigenen Verwaltung aus. Umgesetzt ohne Fremdbibliothek (CBOR-Leser, COSE-nach-PEM, Pruefung ueber openssl_verify, ES256 und RS256). Die Setup-API fuer Zielsysteme ist nicht betroffen - sie laeuft an Auth::login() vorbei. Registriert wird unter System -> Benutzer; ein Schluessel gilt nur fuer die Adresse, unter der er registriert wurde, auf der Testinstanz also eigens.", + "action": "Migration 019 ausfuehren, dann unter System -> Benutzer mindestens einen Hardwarekey registrieren - moeglichst zwei, oder einen Notfalltoken dazu." + }, + { + "area": "auth", + "breaking": false, + "title": "Notfalltoken als Ersatzweg", + "text": "Fuenf Gruppen zu vier Zeichen, einmalig bei der Erzeugung angezeigt; in der Datenbank liegt nur ein Hash. Einmalgebrauch: nach der Anmeldung damit ist er verbraucht, und die Meldezeile sagt es. Beim Eintippen zaehlen nur Buchstaben und Ziffern - Bindestriche und Gross-/Kleinschreibung sind gleichgueltig. Laeuft als gewoehnliches Formular ohne JavaScript; ein Ersatzweg mit denselben Voraussetzungen wie der Hauptweg waere keiner.", + "action": null + }, + { + "area": "auth", + "breaking": true, + "title": "Auth::login() heisst jetzt Auth::startLogin()", + "text": "Die Methode liefert keinen bool mehr, sondern einen von drei Zustaenden (LOGIN_FAILED, LOGIN_COMPLETE, LOGIN_SECOND_FACTOR). Die Umbenennung ist Absicht: Aus \"wahr heisst drin\" ist \"wahr heisst vielleicht drin\" geworden, und ein Aufrufer, der die Umstellung nicht mitbekommen hat, soll an einem fehlenden Methodennamen scheitern statt an einer Zeichenkette, die als truthy durchgeht. Im Projekt gab es genau einen Aufrufer (public/login.php). Fehlversuche auf der zweiten Stufe laufen in dieselbe Konto- und IP-Sperre wie ein falsches Passwort.", + "action": null + }, + { + "area": "webui", + "breaking": false, + "title": "Benutzerkonten lassen sich umbenennen", + "text": "System -> Benutzer kann jetzt auch den Anmeldenamen aendern, mit Kollisionspruefung und Protokolleintrag. Registrierte Hardwarekeys haengen an der Konto-ID und ueberstehen die Umbenennung; offene Sperrfenster des alten Namens werden aufgeraeumt, damit sie nicht spaeter einen gleichnamigen Versuch treffen. Beim Loeschen eines Kontos werden seine Schluessel mitgenommen - eine verwaiste Zeile haette den betroffenen Yubikey dauerhaft unbrauchbar gemacht, weil die Schluesselkennung geraeteweit eindeutig ist.", + "action": null + }, + { + "area": "security", + "breaking": false, + "title": "CSRF-Pruefung fuer sitzungsauthentifizierte API-Schreibzugriffe", + "text": "ApiAuth akzeptiert die WebUI-Sitzung als Authentifizierung; bisher prueften die API-Endpunkte dabei kein CSRF-Token. Der Kommentar in Http::beginJson - diese Endpunkte authentifizierten \"ausschliesslich per Token-Header, niemals per Cookie\" - traf auf die API nie zu. Schreibende Anfragen im Sitzungsweg verlangen jetzt csrf_token bzw. den Kopf X-CSRF-Token. Token-Clients (Agenten, MCP-Adapter, CLI) sind nicht betroffen: Sie schicken keinen Cookie und kommen nie in diesen Zweig.", + "action": "Eigene Skripte, die sich auf die WebUI-Sitzung statt auf ein Token stuetzen, muessen das CSRF-Token mitschicken." + }, + { + "area": "roadmap", + "breaking": false, + "title": "Roadmap-Tafel: Zeitstrahl nach Prioritaet", + "text": "Neue Unteransicht Roadmap -> Tafel. Spalten sind Phasen entlang von roadmap_rank, Zeilen sind Themenbereiche, Eintraege sind Kaertchen. Die Phasengrenzen stehen nicht im Code: gepflegt werden sie in der neuen Tabelle roadmap_phases, und ist dort nichts hinterlegt, schneidet die Tafel an den groessten Luecken der Rangfolge und weist die Grenzen sichtbar als geraten aus. Links die Spalte der erledigten Vorhaben (gekappt), rechts \"Wird ueberlegt\" mit Zurueckgestelltem, Ideen und allem ohne Rang. Nachpruefung (awaiting_verification, in_verification, fix_insufficient) ist ein eigener Zustand und wird nicht als erledigt gezeichnet.", + "action": null + }, + { + "area": "roadmap", + "breaking": false, + "title": "Seitenpanel: bemerken und anmerken ohne Umweg", + "text": "Ein Klick auf ein Kaertchen oeffnet ein Panel mit Volltext, Verlauf, betroffenen Projekten und der Einordnung - ohne Seitenwechsel. Von dort laesst sich direkt kommentieren (mit action_taken) und Bereich, Rang, Zielfassung, Status und Ausloeser aendern. Das war der eigentliche Wunsch: Wer in der Uebersicht etwas bemerkte, musste sich bisher die Nummer merken und im Bugtracker-Tab danach suchen. Tiefe Verweise der Form #roadmap//item/ zeigen auf ein einzelnes Kaertchen, sodass ein Agent in einem Kommentar auf die Stelle zeigen kann.", + "action": null + }, + { + "area": "roadmap", + "breaking": true, + "title": "roadmap_rank gilt projektweit - move und reorder ziehen nach (#177)", + "text": "Der Rang wurde an drei Stellen widerspruechlich behandelt: nextUp() sortierte projektweit, reorder() zaehlte je Bereich bei 10 neu durch, und die Werkzeugbeschreibung sagte \"Reihenfolge im Bereich\". Folge war, dass eine Geste innerhalb eines Bereichs die Position seiner Eintraege gegenueber allen anderen Bereichen still verschob. Jetzt gilt projektweit: move() sucht den Nachbarn ohne Bereichsfilter, und reorder() vertauscht nur noch die Raenge, die die genannten Eintraege ohnehin halten. Neu ist action=rank_between fuer das Ziehen auf der Tafel - es setzt den bewegten Eintrag auf die Mitte zwischen seinen neuen Nachbarn und nummeriert das Projekt nur dann einmal neu (unter Erhalt der Reihenfolge), wenn dort kein Platz mehr ist; die Antwort sagt es dann mit renumbered: true.", + "action": "Wer roadmap_rank bisher je Bereich ab 10 vergeben hat, sollte die Reihenfolge einmal durchsehen - sie war projektweit gelesen schon vorher mehrdeutig." + }, + { + "area": "api", + "breaking": false, + "title": "Neue Endpunkte und Felder", + "text": "GET /api/bugtracker/v1/roadmap?action=board liefert die fertige Gliederung der Tafel ({phases[], lanes[], items[], stats, meta}) - damit koennen WebUI, CLI und ein spaeterer Export dieselbe Quelle benutzen, statt jeder fuer sich zu schneiden. POST ?action=rank_between sortiert ein Vorhaben zwischen zwei Nachbarn ein. ?action=epic nimmt zusaetzlich color und highlight; das Kennzeichen heisst \"hervorheben\" und nicht \"Sicherheit\", weil andere Projekte ihren wichtigen Bereich anders nennen. Nicht mitgeschickte Felder bleiben dabei unveraendert. POST /api/auth/v1/ traegt die vier WebAuthn-Zeremonien des Browsers.", + "action": null + } + ] + }, { "version": "2.8.2", "date": "2026-09-08", diff --git a/public/index.php b/public/index.php index d1c8e86..cdacd70 100644 --- a/public/index.php +++ b/public/index.php @@ -28,6 +28,7 @@ use Deploymentcenter\Core\Db; use Deploymentcenter\Core\Http; use Deploymentcenter\Core\Logger; use Deploymentcenter\Core\TokenManager as CoreTokenManager; +use Deploymentcenter\Core\Webauthn\CredentialRepo; use Deploymentcenter\Modules\Bugtracker\BugRepo; use Deploymentcenter\Modules\Bugtracker\IgnoreRules; use Deploymentcenter\Modules\Bugtracker\Roadmap; @@ -1540,6 +1541,134 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { dc_redirect('#sub-system-users'); } + case 'rename_user': { + $userId = (int)($_POST['user_id'] ?? 0); + $newName = trim((string)($_POST['username'] ?? '')); + + if (preg_match('/^[A-Za-z0-9._-]{3,64}$/', $newName) !== 1) { + dc_flash('Der Benutzername muss 3 bis 64 Zeichen lang sein und darf Buchstaben, Ziffern, Punkt, Bindestrich und Unterstrich enthalten.', 'danger'); + dc_redirect('#sub-system-users'); + } + + $row = $pdo->prepare('SELECT id, username, role FROM dc_users WHERE id = :id LIMIT 1'); + $row->execute([':id' => $userId]); + $target = $row->fetch(); + + if (!is_array($target)) { + dc_flash('Konto nicht gefunden.', 'danger'); + dc_redirect('#sub-system-users'); + } + + if ($target['username'] === $newName) { + dc_flash('Der Name ist unveraendert.', 'danger'); + dc_redirect('#sub-system-users'); + } + + $exists = $pdo->prepare('SELECT id FROM dc_users WHERE username = :u AND id <> :id LIMIT 1'); + $exists->execute([':u' => $newName, ':id' => $userId]); + if ($exists->fetch() !== false) { + dc_flash(sprintf('Der Benutzername "%s" ist bereits vergeben.', e($newName)), 'danger'); + dc_redirect('#sub-system-users'); + } + + $oldName = (string)$target['username']; + + $upd = $pdo->prepare('UPDATE dc_users SET username = :u WHERE id = :id'); + $upd->execute([':u' => $newName, ':id' => $userId]); + + Logger::info('Benutzerkonto umbenannt', [ + 'user_id' => $userId, + 'from' => $oldName, + 'to' => $newName, + 'actor' => $actor, + ]); + + // Registrierte Hardwarekeys haengen an der Konto-ID, nicht am + // Namen - sie ueberstehen die Umbenennung. Die Sperrzaehlung + // in dc_login_attempts haengt dagegen am Namen; ein unter dem + // alten Namen laufendes Sperrfenster wuerde also weiterlaufen + // und einen gleichnamigen kuenftigen Versuch treffen. Deshalb + // hier aufraeumen. + try { + $pdo->prepare('DELETE FROM dc_login_attempts WHERE username = :u AND success = 0') + ->execute([':u' => $oldName]); + } catch (\Throwable $e) { + Logger::warning('Fehlversuche des alten Namens nicht entfernt', ['error' => $e->getMessage()]); + } + + // Der Name steht auch in den .htpasswd-Dateien des Zugangsschutzes. + dc_guard_releases($pdo); + + // Wer sich selbst umbenennt, traegt den alten Namen noch in + // der Sitzung. Ohne diese Zeile stuende oben rechts weiter + // der alte Name, und der naechste Protokolleintrag traege ihn + // als Urheber. + if ($userId === Auth::userId()) { + $_SESSION['dc_username'] = $newName; + } + + dc_flash(sprintf('Konto "%s" heisst jetzt "%s".', e($oldName), e($newName))); + dc_redirect('#sub-system-users'); + } + + case 'issue_recovery_token': { + $userId = (int)($_POST['user_id'] ?? 0); + + $row = $pdo->prepare('SELECT id, username FROM dc_users WHERE id = :id LIMIT 1'); + $row->execute([':id' => $userId]); + $target = $row->fetch(); + + if (!is_array($target)) { + dc_flash('Konto nicht gefunden.', 'danger'); + dc_redirect('#sub-system-users'); + } + + $token = (new CredentialRepo($pdo))->issueRecoveryToken($userId); + + Logger::info('Notfalltoken erzeugt', [ + 'user_id' => $userId, + 'actor' => $actor, + ]); + + // Ueber die Sitzung und nicht ueber die Meldung: Der Klartext + // soll einmal gross und lesbar dastehen, nicht in einer Zeile + // verschwinden, die nach drei Sekunden weggeklickt ist. + $_SESSION['dc_fresh_recovery_token'] = [ + 'username' => (string)$target['username'], + 'token' => $token, + ]; + + dc_redirect('#sub-system-users'); + } + + case 'delete_credential': { + $userId = (int)($_POST['user_id'] ?? 0); + $credentialId = (int)($_POST['credential_id'] ?? 0); + + $repo = new CredentialRepo($pdo); + + // Den letzten Schluessel eines Kontos zu entfernen ist + // erlaubt - es ist der Weg zurueck, wenn der Key kaputt ist. + // Aber es schaltet die zweite Stufe fuer dieses Konto ab, und + // das soll niemand versehentlich tun, ohne es zu lesen. + $remaining = max(0, $repo->countForUser($userId) - 1); + + if ($repo->delete($userId, $credentialId)) { + Logger::info('Hardwarekey entfernt', [ + 'user_id' => $userId, + 'actor' => $actor, + ]); + + dc_flash($remaining === 0 + ? 'Hardwarekey entfernt. Fuer dieses Konto ist damit kein zweiter Faktor mehr hinterlegt - die Anmeldung laeuft wieder nur ueber das Passwort.' + : sprintf('Hardwarekey entfernt. Es sind noch %d hinterlegt.', $remaining)); + } else { + dc_flash('Hardwarekey nicht gefunden.', 'danger'); + } + + dc_redirect('#sub-system-users'); + } + case 'toggle_user_disabled': { $userId = (int)($_POST['user_id'] ?? 0); @@ -1599,6 +1728,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { dc_redirect('#sub-system-users'); } + // Erst die Schluessel, dann das Konto. Bliebe eine Zeile in + // dc_user_credentials stehen, waere der betroffene Hardwarekey + // danach unbrauchbar: credential_id ist geraeteweit eindeutig, + // und die verwaiste Zeile haelt sie besetzt. + try { + $pdo->prepare('DELETE FROM dc_user_credentials WHERE user_id = :id') + ->execute([':id' => $userId]); + } catch (\Throwable $e) { + Logger::warning('Hardwarekeys des geloeschten Kontos nicht entfernt', ['error' => $e->getMessage()]); + } + $del = $pdo->prepare('DELETE FROM dc_users WHERE id = :id'); $del->execute([':id' => $userId]); @@ -1895,6 +2035,61 @@ try { } $usersSchemaReady = $users === [] || array_key_exists('role', $users[0]); +// Zweiter Faktor je Konto: wie viele Hardwarekeys hinterlegt sind und wie es +// um den Notfalltoken steht. In einer Abfrage und nicht je Zeile - es sind +// zwar nie viele Konten, aber eine Schleife mit zwei Abfragen pro Durchgang +// gewoehnt man sich besser gar nicht erst an. +$credentialRepo = new CredentialRepo($pdo); +$credentialsByUser = []; +$recoveryByUser = []; +$twoFactorReady = true; + +try { + $rows = $pdo->query(' + SELECT user_id, COUNT(*) AS keys_count + FROM dc_user_credentials GROUP BY user_id + ')->fetchAll() ?: []; + + foreach ($rows as $row) { + $credentialsByUser[(int)$row['user_id']] = (int)$row['keys_count']; + } + + $rows = $pdo->query(' + SELECT id, recovery_hash IS NOT NULL AS has_token, recovery_created_at, recovery_used_at + FROM dc_users + ')->fetchAll() ?: []; + + foreach ($rows as $row) { + $recoveryByUser[(int)$row['id']] = [ + 'present' => (int)$row['has_token'] === 1, + 'created_at' => $row['recovery_created_at'] ?? null, + 'used_at' => $row['recovery_used_at'] ?? null, + ]; + } +} catch (Throwable $e) { + // Vor Migration 019 gibt es weder Tabelle noch Spalten. Die Oberflaeche + // zeigt dann einen Hinweis statt leerer Spalten - "keine Schluessel" und + // "Migration fehlt" sehen sonst gleich aus. + $twoFactorReady = false; +} + +// Die Schluessel des eigenen Kontos - nur die lassen sich hier verwalten. +$myCredentials = []; +$myRecovery = ['present' => false, 'created_at' => null, 'used_at' => null]; + +if ($twoFactorReady) { + try { + $myCredentials = $credentialRepo->forUser(Auth::userId()); + $myRecovery = $credentialRepo->recoveryState(Auth::userId()); + } catch (Throwable $e) { + $twoFactorReady = false; + } +} + +// Einmalige Anzeigen, die die Sitzung von der vorigen Anfrage mitbringt. +$freshRecoveryToken = $_SESSION['dc_fresh_recovery_token'] ?? null; +unset($_SESSION['dc_fresh_recovery_token']); + // Zustand des Zugangsschutzes der Release-Verzeichnisse. $releaseGuard = ReleaseGuard::status($pdo); @@ -2606,6 +2801,158 @@ $csrfField = Csrf::field(); } .filter-hinweis { font-size: 0.7rem; opacity: 0.5; margin-top: 0.4rem; } .tab-content.active, .subtab-content.active { display: block; } + + /* ================= Roadmap-Tafel ================= */ + + .tafel-scroller { overflow-x: auto; padding-bottom: 0.75rem; -webkit-overflow-scrolling: touch; } + + .tafel-track { + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(250px, 1fr); + gap: 0.9rem; + align-items: start; + min-width: min-content; + } + + .tafel-spalte { + background: var(--bg-card); + border: 1px solid var(--border-glass); + border-radius: 12px; + padding: 0.85rem 0.7rem 1rem; + display: flex; + flex-direction: column; + gap: 0.85rem; + } + .tafel-spalte.kind-done { background: transparent; } + .tafel-spalte.kind-think { background: transparent; border-style: dashed; } + .tafel-spalte.drop-ziel { border-color: var(--primary); box-shadow: 0 0 0 2px var(--primary-glow); } + + .tafel-kopf { border-bottom: 1px solid var(--border-soft); padding-bottom: 0.6rem; } + .tafel-kopf h3 { margin: 0; font-size: 1rem; font-weight: 700; letter-spacing: -0.01em; } + .tafel-kopf p { margin: 0.2rem 0 0; font-size: 0.76rem; color: var(--text-muted); } + .tafel-kopf .rang { + display: block; margin-top: 0.3rem; + font-family: 'Roboto Mono', monospace; font-size: 0.68rem; color: var(--text-muted); opacity: 0.75; + } + .tafel-spalte.kind-now .tafel-kopf h3 { color: var(--primary); } + + .tafel-spur { display: flex; flex-direction: column; gap: 0.4rem; } + .tafel-spur > h4 { + margin: 0 0 0.1rem; + font-family: 'Roboto Mono', monospace; + font-size: 0.66rem; letter-spacing: 0.07em; text-transform: uppercase; + color: var(--text-muted); + display: flex; align-items: center; gap: 0.35rem; + } + + .tafel-karte { + position: relative; + background: var(--bg-card-elev); + border: 1px solid var(--border-glass); + border-left: 3px solid var(--border-glass); + border-radius: 8px; + padding: 0.5rem 0.6rem 0.55rem; + cursor: pointer; + display: flex; flex-direction: column; gap: 0.2rem; + transition: transform 0.12s ease, border-color 0.12s ease; + } + .tafel-karte:hover { transform: translateY(-1px); border-color: var(--primary); } + .tafel-karte:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } + .tafel-karte.dragging { opacity: 0.4; } + .tafel-karte .kopfzeile { display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; } + .tafel-karte .nummer { font-family: 'Roboto Mono', monospace; font-size: 0.68rem; color: var(--text-muted); } + .tafel-karte .titel { font-size: 0.82rem; line-height: 1.3; font-weight: 600; } + .tafel-karte .zusatz { font-size: 0.72rem; line-height: 1.35; color: var(--text-muted); } + + .tafel-karte.state-done { + background: rgba(18, 212, 138, 0.07); + border-color: transparent; border-left-color: var(--success); + } + .tafel-karte.state-done .titel { font-weight: 400; color: var(--text-muted); } + .tafel-karte.state-working { border-left-color: var(--warning); } + .tafel-karte.state-verification { border-left-color: #c084fc; } + .tafel-karte.state-deferred { background: transparent; border-style: dashed; } + .tafel-karte.state-idea { background: transparent; border-style: dotted; } + .tafel-karte.hervorgehoben { background: rgba(246, 70, 93, 0.09); } + + .tafel-marke { + font-family: 'Roboto Mono', monospace; + font-size: 0.6rem; font-weight: 700; letter-spacing: 0.03em; + padding: 0.12rem 0.3rem; border-radius: 3px; + background: rgba(255,255,255,0.1); color: var(--text-muted); + white-space: nowrap; + } + .tafel-marke.warn { background: rgba(245, 166, 35, 0.18); color: var(--warning); } + .tafel-marke.pruef { background: rgba(192, 132, 252, 0.18); color: #d8b4fe; } + .tafel-marke.hoch { background: rgba(246, 70, 93, 0.18); color: var(--danger); } + .tafel-marke.neu { background: rgba(91, 157, 255, 0.2); color: var(--primary); } + + .tafel-luecke { height: 0.35rem; border-radius: 3px; transition: background 0.1s ease; } + .tafel-luecke.aktiv { background: var(--primary); } + + /* --- Seitenpanel --- */ + .panel-schatten { + position: fixed; inset: 0; background: rgba(4, 7, 12, 0.6); + backdrop-filter: blur(2px); z-index: 900; display: none; + } + .panel-schatten.offen { display: block; } + + .tafel-panel { + position: fixed; top: 0; right: 0; bottom: 0; + width: min(560px, 100vw); + background: #10141d; + border-left: 1px solid var(--border-glass); + z-index: 901; + transform: translateX(100%); + transition: transform 0.18s ease; + display: flex; flex-direction: column; + } + .tafel-panel.offen { transform: translateX(0); } + .tafel-panel header { + padding: 1rem 1.1rem 0.85rem; + border-bottom: 1px solid var(--border-glass); + display: flex; align-items: flex-start; gap: 0.75rem; + } + .tafel-panel header h2 { margin: 0; font-size: 1rem; line-height: 1.35; flex: 1; } + .tafel-panel .panel-inhalt { overflow-y: auto; padding: 1rem 1.1rem 2rem; flex: 1; } + .tafel-panel .panel-block { margin-bottom: 1.35rem; } + .tafel-panel .panel-block > h3 { + margin: 0 0 0.5rem; font-size: 0.72rem; letter-spacing: 0.07em; + text-transform: uppercase; color: var(--text-muted); + font-family: 'Roboto Mono', monospace; + } + .tafel-panel .beschreibung { + white-space: pre-wrap; font-size: 0.82rem; line-height: 1.5; + color: var(--text-muted); max-height: 22rem; overflow-y: auto; + } + .tafel-panel .kommentar { + border-left: 2px solid var(--border-glass); + padding: 0.35rem 0 0.35rem 0.65rem; margin-bottom: 0.7rem; + } + .tafel-panel .kommentar .meta { + font-family: 'Roboto Mono', monospace; font-size: 0.68rem; color: var(--text-muted); + display: flex; gap: 0.4rem; flex-wrap: wrap; margin-bottom: 0.2rem; + } + .tafel-panel .kommentar .text { font-size: 0.8rem; line-height: 1.45; white-space: pre-wrap; } + .tafel-panel .felder { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.6rem; } + .panel-schliessen { + background: none; border: none; color: var(--text-muted); + font-size: 1.4rem; line-height: 1; cursor: pointer; padding: 0; + } + .panel-schliessen:hover { color: var(--text-main); } + + @media (max-width: 760px) { + .tafel-scroller { overflow: visible; } + .tafel-track { grid-auto-flow: row; grid-auto-columns: auto; min-width: 0; } + } + + @media print { + .sidebar, .topbar, .tafel-panel, .panel-schatten, #tafelNeuLaden { display: none !important; } + .tafel-scroller { overflow: visible; } + .tafel-track { grid-auto-flow: column; } + .tafel-karte { break-inside: avoid; } + } @@ -4163,8 +4510,58 @@ chmod +x update-agent -->
+ + +
+ +
+
+ + + + + + + +
+
+ +
+ +
+ +
+ +
+
+
Tafel wird geladen …
+
+
+
+ -
+
@@ -6264,6 +6661,107 @@ Antwort:
+ + +
+

🔐 Neuer Notfalltoken für

+

+ Jetzt notieren. In der Datenbank liegt nur ein Hash — + dieser Klartext wird nicht wieder angezeigt, auch nicht beim Neuladen + dieser Seite. Ausdrucken und dorthin legen, wo der Ersatzschlüssel liegt. +

+
+ +
+

+ Beim Eintippen zählen nur Buchstaben und Ziffern — Bindestriche und + Groß-/Kleinschreibung sind gleichgültig. Der Token gilt für genau eine + Anmeldung. +

+
+ + +
+

🔑 Meine Hardwarekeys

+ + +
+ Migration 019 fehlt. Hardwarekeys und Notfalltoken stehen + erst danach zur Verfügung. Bis dahin genügt zur Anmeldung das Passwort. + Die Migration läuft unter System → 🗄️ DB Migration. +
+ +

+ Sobald hier ein Schlüssel steht, verlangt die Anmeldung ihn — das Passwort + allein genügt dann nicht mehr. Ein Schlüssel gilt nur für + ; + auf der Testinstanz muss er eigens registriert werden. + Registrieren Sie zwei — oder legen Sie sich einen + Notfalltoken an, bevor Sie sich auf einen einzigen verlassen. +

+ + +

+ Noch kein Schlüssel registriert. Ihre Anmeldung läuft derzeit nur über das Passwort. +

+ + + + + + + + + + + + + + + + + + + + +
BezeichnungRegistriertZuletzt benutztAktion
+ + +
+ + + + + + +
+
+ + +
+ + +
+
+ +

+ Notfalltoken für Ihr eigenes Konto: + + hinterlegt seit . + + verbraucht am . + + keiner hinterlegt. + + Erzeugen lässt er sich in der Tabelle darunter. +

+ +
+

👤 Benutzerkonten

@@ -6290,6 +6788,7 @@ Antwort: Benutzer Rolle + Zweiter Faktor Status Letzte Anmeldung Angelegt @@ -6316,6 +6815,39 @@ Antwort: + + false, 'used_at' => null]; + ?> + + — + + + entfällt + + 0): ?> + 🔑 + + + nur Passwort + + + + +
+ + Notfalltoken hinterlegt + + Notfalltoken verbraucht + + kein Notfalltoken + +
+ + GESPERRT (noch m) @@ -6348,6 +6880,32 @@ Antwort: +
+ + + + + + +
+ + +
+ + + + + +
+ +
@@ -6512,7 +7070,8 @@ SYSTEM { id: 'sub-update-publish', label: '➕ Release Veröffentlichen' } ], 'roadmap': [ - { id: 'sub-roadmap-board', label: '🗺️ Übersicht', active: true }, + { id: 'sub-roadmap-tafel', label: '🧭 Tafel', active: true }, + { id: 'sub-roadmap-board', label: '📊 Übersicht' }, { id: 'sub-roadmap-list', label: '📋 Alle Vorhaben' }, { id: 'sub-roadmap-intake', label: '➕ Aufnahmeliste' }, { id: 'sub-roadmap-epics', label: '🗂️ Themenbereiche' } @@ -7324,6 +7883,17 @@ SYSTEM function restoreViewFromHash() { let hash = location.hash.replace('#', ''); + // Tiefer Verweis auf ein Kaertchen der Tafel: + // #roadmap//item/ + // Damit kann ein Agent in einem Kommentar auf die Stelle in der + // Tafel zeigen, statt "steht in der Roadmap, such mal" zu sagen. + const tiefer = hash.match(/^roadmap\/([a-z0-9._-]{1,64})\/item\/(\d{1,9})$/i); + if (tiefer) { + switchMainTab('roadmap', null, 'sub-roadmap-tafel', false); + tafelOeffneItem(tiefer[1], parseInt(tiefer[2], 10)); + return; + } + // Ohne Anker: der Wert aus dem Query-String, den dcRememberView() // an GET-Formulare haengt. Nur eigene Ansichtskennungen zulassen - // der Wert steuert, was angezeigt wird. @@ -7572,5 +8142,881 @@ SYSTEM
+ + +
+ + + + + diff --git a/public/login.php b/public/login.php index e455069..5d45b58 100644 --- a/public/login.php +++ b/public/login.php @@ -9,6 +9,7 @@ use Deploymentcenter\Core\Csrf; use Deploymentcenter\Core\Db; use Deploymentcenter\Core\Http; use Deploymentcenter\Core\Logger; +use Deploymentcenter\Core\Webauthn\CredentialRepo; Auth::startSession(); @@ -18,8 +19,61 @@ if (Auth::isLoggedIn()) { } $error = null; +$notice = null; -if ($_SERVER['REQUEST_METHOD'] === 'POST') { +// ----------------------------------------------------------------------- +// Zweite Stufe: Notfalltoken und Abbruch +// +// Beides laeuft als gewoehnliches Formular und nicht ueber die JSON-API. +// Der Hardwarekey braucht JavaScript, weil navigator.credentials sich +// anders nicht bedienen laesst - der Ersatzweg soll diese Voraussetzung +// gerade nicht teilen. +// ----------------------------------------------------------------------- +$step = (string)($_POST['step'] ?? ''); + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && $step === 'cancel') { + Auth::clearPending(); + header('Location: /login.php'); + exit; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && $step === 'recovery') { + if (!Csrf::isValid(is_string($_POST['csrf_token'] ?? null) ? $_POST['csrf_token'] : null)) { + $error = 'Sitzung abgelaufen. Bitte erneut versuchen.'; + } elseif (Auth::pendingUserId() <= 0) { + $error = 'Die Anmeldung ist abgelaufen. Bitte von vorn beginnen.'; + } else { + try { + $pdo = Db::init(); + $repo = new CredentialRepo($pdo); + + if ($repo->consumeRecoveryToken(Auth::pendingUserId(), (string)($_POST['recovery_token'] ?? ''))) { + Auth::completeSecondFactor($pdo, 'recovery_token'); + + // Der Token ist jetzt verbraucht. Das muss der Betreiber + // erfahren, und zwar dort, wo er nach dem Anmelden landet - + // also ueber die allgemeine Meldezeile und nicht in einem + // Unterreiter, den er vielleicht wochenlang nicht aufschlaegt. + $_SESSION['dc_flash'] = [ + 'type' => 'danger', + 'message' => 'Sie haben sich mit dem Notfalltoken angemeldet. ' + . 'Er ist damit verbraucht. Unter System → Benutzer einen neuen erzeugen, ' + . 'sonst bleibt nur noch der Hardwarekey.', + ]; + header('Location: /index.php'); + exit; + } + + Auth::recordSecondFactorFailure($pdo, 'recovery_token_invalid'); + $error = 'Der Notfalltoken stimmt nicht.'; + } catch (\Throwable $e) { + Logger::error('Notfalltoken-Pruefung fehlgeschlagen', ['error' => $e->getMessage()]); + $error = 'Anmeldung derzeit nicht moeglich. Bitte spaeter erneut versuchen.'; + } + } +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && $step === '') { $username = trim((string)($_POST['username'] ?? '')); $password = (string)($_POST['password'] ?? ''); @@ -44,10 +98,24 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { 'Dieses Benutzerkonto ist aufgrund zu vieler Fehlversuche vorübergehend gesperrt. Bitte in %d Minute(n) erneut versuchen.', (int)ceil($accLockout / 60) ); - } elseif (Auth::login($pdo, $username, $password)) { - header('Location: /index.php'); - exit; } else { + $result = Auth::startLogin($pdo, $username, $password); + + if ($result === Auth::LOGIN_COMPLETE) { + header('Location: /index.php'); + exit; + } + + if ($result === Auth::LOGIN_SECOND_FACTOR) { + // Nicht weiterleiten: Die zweite Stufe wird unten + // gerendert. Ein Redirect wuerde nur eine Runde kosten + // und der Adresszeile ansehen lassen, wie weit jemand + // gekommen ist. + $error = null; + } + } + + if ($error === null && Auth::pendingUserId() <= 0 && !Auth::isLoggedIn()) { // Pruefen, ob das Konto oder die IP durch diesen Fehlversuch soeben gesperrt wurde $newAccLockout = Auth::accountLockoutSeconds($pdo, $username); $newIpLockout = Auth::lockoutSeconds($pdo, Http::clientIp()); @@ -74,6 +142,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } } } + +// Erst hier abfragen, nicht weiter oben: Der Zwischenzustand entsteht unter +// Umstaenden erst durch die Auswertung darueber. +$pendingUsername = Auth::pendingUsername(); ?> @@ -244,6 +316,97 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { color: var(--text-muted); margin-top: 2rem; } + + /* --- Zweite Stufe: Hardwarekey --- */ + + .step-note { + font-size: 0.875rem; + color: var(--text-muted); + line-height: 1.5; + margin-bottom: 1.25rem; + } + + .step-note strong { color: var(--text-main); } + + .key-status { + background: rgba(15, 23, 42, 0.6); + border: 1px solid var(--card-border); + border-radius: 10px; + padding: 0.875rem 1rem; + font-size: 0.875rem; + color: var(--text-muted); + margin-bottom: 1rem; + min-height: 3rem; + display: flex; + align-items: center; + } + + .key-status.is-error { + background: var(--danger-bg); + border-color: var(--danger-border); + color: var(--danger-text); + } + + .divider { + display: flex; + align-items: center; + gap: 0.75rem; + margin: 1.5rem 0 1rem; + color: var(--text-muted); + font-size: 0.75rem; + } + + .divider::before, + .divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--card-border); + } + + .recovery summary { + cursor: pointer; + font-size: 0.875rem; + color: var(--text-muted); + padding: 0.5rem 0; + } + + .recovery summary:hover { color: var(--text-main); } + + .recovery[open] summary { margin-bottom: 0.75rem; } + + .hint { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 0.4rem; + line-height: 1.45; + } + + .btn-quiet { + background: rgba(255, 255, 255, 0.06); + box-shadow: none; + font-weight: 500; + } + + .btn-quiet:hover { + background: rgba(255, 255, 255, 0.1); + box-shadow: none; + } + + .cancel-form { text-align: center; margin-top: 1.25rem; } + + .link-button { + background: none; + border: none; + color: var(--text-muted); + font-size: 0.8rem; + font-family: inherit; + cursor: pointer; + text-decoration: underline; + padding: 0; + } + + .link-button:hover { color: var(--text-main); } @@ -255,31 +418,162 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {

Deploymentcenter

-

Bitte melden Sie sich an, um fortzufahren

+

+ +

-
- -
- - + + +
+ Passwort erkannt für . + Bitte jetzt den Hardwarekey anstecken und berühren.
-
- - -
+
Warte auf den Hardwarekey …
- - + + +
oder
+ +
> + Hardwarekey nicht zur Hand? +
+ + +
+ + +

+ Wird dabei verbraucht. Nach der Anmeldung unter + System → Benutzer einen neuen erzeugen. +

+
+ +
+
+ +
+ + + +
+ + + +
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ + + + diff --git a/sql/migrations/019_webauthn_and_roadmap_board.sql b/sql/migrations/019_webauthn_and_roadmap_board.sql new file mode 100644 index 0000000..e5feae5 --- /dev/null +++ b/sql/migrations/019_webauthn_and_roadmap_board.sql @@ -0,0 +1,101 @@ +-- Migration 019: Hardwarekey an der WebUI-Anmeldung, Notfalltoken, Roadmap-Tafel +-- +-- Additive Migration. Der Migrator toleriert 1050/1060/1061/1062/1091 ("existiert bereits"). + +-- --------------------------------------------------------------------------- +-- 1. Registrierte Hardwarekeys (WebAuthn/FIDO2) +-- --------------------------------------------------------------------------- +-- Ein Konto darf mehrere Keys haben, und das ist keine Bequemlichkeit: Wer nur +-- einen Schluessel registriert, hat beim Verlust genau einen Weg zurueck - den +-- Notfalltoken. Ein zweiter Key im Schrank ist der billigere Ersatzweg. +-- +-- credential_id kommt roh vom Authenticator (bis 1023 Byte erlaubt, in der +-- Praxis 16-128). VARBINARY(255) deckt jeden real vorkommenden Fall ab und +-- bleibt als eindeutiger Schluessel unterhalb der InnoDB-Grenze. +-- +-- public_key ist das PEM der oeffentlichen Haelfte. Bewusst PEM und nicht das +-- rohe COSE: Geprueft wird spaeter mit openssl_verify(), und das will ein PEM. +-- Die Wandlung einmal bei der Registrierung zu bezahlen ist billiger, als sie +-- bei jeder Anmeldung zu wiederholen. +-- +-- sign_count ist der Zaehler des Authenticators. Faellt er bei einer Anmeldung +-- nicht hoeher aus als der gespeicherte Wert, ist das der Hinweis auf einen +-- geklonten Schluessel. Yubikeys, die den Zaehler gar nicht fuehren, melden +-- konstant 0 - dieser Fall ist erlaubt und darf nicht als Klon gelten. +CREATE TABLE IF NOT EXISTS dc_user_credentials ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + credential_id VARBINARY(255) NOT NULL, + public_key TEXT NOT NULL, + -- Das COSE-Verfahren (-7 = ES256, -257 = RS256). Liesse sich zwar aus dem + -- PEM herleiten, steht aber hier: Das Pruefen soll nicht davon abhaengen, + -- dass das Herleiten richtig geraten hat. + alg INT NOT NULL DEFAULT -7, + sign_count BIGINT UNSIGNED NOT NULL DEFAULT 0, + aaguid VARBINARY(16) NULL, + transports VARCHAR(128) NULL, + label VARCHAR(64) NOT NULL DEFAULT 'Hardwarekey', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_used_at DATETIME NULL, + UNIQUE KEY ux_credential_id (credential_id), + KEY ix_credential_user (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------------------- +-- 2. Notfalltoken je Konto +-- --------------------------------------------------------------------------- +-- Nur der Hash liegt hier. Der Klartext wird bei der Erzeugung ein einziges +-- Mal angezeigt und danach nicht wieder - wer die Datenbank liest, haelt +-- deshalb keinen Zweitschluessel in der Hand. +-- +-- recovery_used_at bleibt nach dem Gebrauch stehen, obwohl der Hash dabei +-- geleert wird. Ohne diese Spur stuende in der Oberflaeche nur "kein +-- Notfalltoken hinterlegt" - und das sieht genauso aus wie "es gab nie einen", +-- waehrend es in Wahrheit heisst: jemand hat ihn benutzt. +ALTER TABLE dc_users + ADD COLUMN recovery_hash VARCHAR(255) NULL AFTER totp_secret; + +ALTER TABLE dc_users + ADD COLUMN recovery_created_at DATETIME NULL AFTER recovery_hash; + +ALTER TABLE dc_users + ADD COLUMN recovery_used_at DATETIME NULL AFTER recovery_created_at; + +-- --------------------------------------------------------------------------- +-- 3. Phasen der Roadmap-Tafel +-- --------------------------------------------------------------------------- +-- Die Tafel schneidet die Vorhaben in Spalten entlang von roadmap_rank. Die +-- Grenzen duerfen nicht im Code stehen: Der Rang ist projektweit und frei +-- vergeben, und jedes Projekt benutzt andere Abstaende. Was fuer das +-- Deploymentcenter "Rang 5-40" ist, ist fuer Predictalytics eine einzige +-- Spalte. +-- +-- Ist fuer ein Projekt nichts gepflegt, schneidet die Tafel selbst - an den +-- groessten Luecken der Rangfolge - und sagt in der Ansicht dazu, dass die +-- Grenzen geraten sind. Diese Tabelle ist der Weg, das Geratene zu ersetzen. +CREATE TABLE IF NOT EXISTS roadmap_phases ( + id INT AUTO_INCREMENT PRIMARY KEY, + project_slug VARCHAR(64) NOT NULL, + sort_order INT NOT NULL DEFAULT 100, + rank_from INT NOT NULL, + rank_to INT NOT NULL, + label VARCHAR(80) NOT NULL, + subtitle VARCHAR(160) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY ux_roadmap_phase (project_slug, sort_order), + KEY ix_roadmap_phase_range (project_slug, rank_from, rank_to) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------------------- +-- 4. Spuren der Tafel: Farbe und Hervorhebung je Themenbereich +-- --------------------------------------------------------------------------- +-- "Sicherheit faellt ins Auge" war in der Vorlage ein fest verdrahteter +-- Bereichsname. Das traegt genau ein Projekt weit: Andere Projekte nennen +-- ihren wichtigen Bereich anders. Deshalb ein Kennzeichen am Bereich statt +-- eines Namens im Code. +ALTER TABLE roadmap_epics + ADD COLUMN color VARCHAR(16) NULL AFTER summary; + +ALTER TABLE roadmap_epics + ADD COLUMN highlight TINYINT(1) NOT NULL DEFAULT 0 AFTER color; diff --git a/src/Core/ApiAuth.php b/src/Core/ApiAuth.php index eed857e..d625926 100644 --- a/src/Core/ApiAuth.php +++ b/src/Core/ApiAuth.php @@ -81,6 +81,22 @@ final class ApiAuth // 2. WebUI-Session if ($allowSession && Auth::isLoggedIn()) { + // Ein sitzungsauthentifizierter Schreibzugriff braucht zusaetzlich + // ein CSRF-Token. Der Kommentar in Http::beginJson - diese + // Endpunkte authentifizierten "ausschliesslich per Token-Header, + // niemals per Cookie" - traf auf die API nie zu: Genau hier wird + // der Cookie akzeptiert. Bisher war das nur deshalb harmlos, weil + // SameSite=Lax den Cookie bei fremden POST-Anfragen zurueckhaelt; + // das ist eine Eigenschaft des Browsers und kein Schutz, den der + // Server selbst in der Hand haette. + // + // Token-Clients (Agenten, MCP-Adapter, CLI) sind nicht betroffen: + // Sie kommen nie in diesen Zweig, weil sie keinen Cookie + // mitschicken. + if (!in_array(Http::method(), ['GET', 'HEAD', 'OPTIONS'], true)) { + Csrf::requireValid(); + } + return [ 'method' => 'session', 'actor' => Auth::username(), diff --git a/src/Core/Auth.php b/src/Core/Auth.php index f1c4faf..6ed7ac9 100644 --- a/src/Core/Auth.php +++ b/src/Core/Auth.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Deploymentcenter\Core; +use Deploymentcenter\Core\Webauthn\CredentialRepo; use Deploymentcenter\Modules\Notify\WebhookDispatcher; use PDO; @@ -38,6 +39,20 @@ final class Auth /** Kontosperrdauer in Sekunden (15 Minuten). */ public const DEFAULT_ACCOUNT_LOCKOUT_SECONDS = 900; + /** + * Wie lange zwischen Passwort und zweitem Faktor liegen darf (5 Minuten). + * + * Der Zwischenzustand ist keine Anmeldung, aber er ist auch nicht nichts: + * Wer ihn erreicht hat, kennt das Passwort. Er soll nicht ueber Nacht + * offen stehen bleiben. + */ + public const PENDING_TIMEOUT = 300; + + /** Rueckgabewerte von startLogin(). */ + public const LOGIN_FAILED = 'failed'; + public const LOGIN_COMPLETE = 'complete'; + public const LOGIN_SECOND_FACTOR = 'second_factor'; + public static function startSession(): void { if (session_status() !== PHP_SESSION_NONE) { @@ -278,16 +293,25 @@ final class Auth } /** - * Prueft die Zugangsdaten und startet bei Erfolg eine frische Session. + * Erste Stufe: Zugangsdaten pruefen. + * + * Liefert LOGIN_COMPLETE, wenn die Anmeldung damit fertig ist (Konto ohne + * registrierten Hardwarekey), LOGIN_SECOND_FACTOR, wenn noch ein + * Schluessel oder der Notfalltoken fehlt, und LOGIN_FAILED sonst. + * + * Bewusst nicht mehr `login(): bool`: Aus "wahr heisst drin" ist "wahr + * heisst vielleicht drin" geworden, und ein Aufrufer, der die Umstellung + * nicht mitbekommen hat, soll an einem fehlenden Methodennamen scheitern + * und nicht an einer Zeichenkette, die als truthy durchgeht. */ - public static function login(PDO $db, string $username, string $password): bool + public static function startLogin(PDO $db, string $username, string $password): string { self::startSession(); $user = self::verifyCredentials($db, $username, $password); if ($user === null) { - return false; + return self::LOGIN_FAILED; } // Ein Installationskonto hat in der Verwaltungsoberflaeche nichts zu suchen @@ -297,9 +321,200 @@ final class Auth 'username' => $username, 'ip' => Http::clientIp(), ]); + return self::LOGIN_FAILED; + } + + if (!self::secondFactorRequired($db, (int)$user['id'])) { + self::establishSession($user); + return self::LOGIN_COMPLETE; + } + + // Zwischenzustand. Ausdruecklich keine Anmeldung: dc_user_id bleibt + // leer, isLoggedIn() ist falsch, und jede geschuetzte Seite weist + // weiterhin ab. + self::clearPending(); + $_SESSION['dc_pending_user_id'] = (int)$user['id']; + $_SESSION['dc_pending_username'] = (string)$user['username']; + $_SESSION['dc_pending_at'] = time(); + + return self::LOGIN_SECOND_FACTOR; + } + + /** + * Verlangt dieses Konto einen zweiten Faktor? + * + * Die Pflicht haengt daran, ob ein Schluessel registriert ist, und nicht + * an einem Schalter. Der Unterschied zeigt sich beim Einspielen: Ein + * Schalter, der vor der Registrierung umgelegt wird, sperrt den Betreiber + * aus seiner eigenen Verwaltung aus. So herum kann das nicht passieren - + * wer einen Schluessel registriert hat, hat ihn auch. + */ + public static function secondFactorRequired(PDO $db, int $userId): bool + { + if ($userId <= 0) { return false; } + try { + return (new CredentialRepo($db))->countForUser($userId) > 0; + } catch (\Throwable $e) { + // Tabelle fehlt eventuell vor Migration 019. Dann gibt es keine + // Schluessel, also auch keine Pflicht - und die Anmeldung laeuft + // wie zuvor, statt an einer fehlenden Tabelle zu scheitern. + Logger::warning('Pruefung auf Hardwarekey nicht moeglich', ['error' => $e->getMessage()]); + return false; + } + } + + /** Das Konto, das gerade zwischen erster und zweiter Stufe steht (0 = keines). */ + public static function pendingUserId(): int + { + self::startSession(); + + $userId = (int)($_SESSION['dc_pending_user_id'] ?? 0); + if ($userId <= 0) { + return 0; + } + + $since = (int)($_SESSION['dc_pending_at'] ?? 0); + if ($since <= 0 || (time() - $since) > self::PENDING_TIMEOUT) { + self::clearPending(); + return 0; + } + + return $userId; + } + + public static function pendingUsername(): string + { + self::startSession(); + + return self::pendingUserId() > 0 + ? (string)($_SESSION['dc_pending_username'] ?? '') + : ''; + } + + /** + * Zweite Stufe bestanden: jetzt wird angemeldet. + * + * @param string $method 'hardware_key' oder 'recovery_token' - nur fuers Protokoll + */ + public static function completeSecondFactor(PDO $db, string $method): bool + { + $userId = self::pendingUserId(); + if ($userId <= 0) { + return false; + } + + $stmt = $db->prepare('SELECT * FROM dc_users WHERE id = :id LIMIT 1'); + $stmt->execute([':id' => $userId]); + $user = $stmt->fetch(); + + if (!is_array($user) || (int)($user['disabled'] ?? 0) === 1) { + self::clearPending(); + return false; + } + + self::clearPending(); + self::establishSession($user); + + Logger::info('Zweite Stufe bestanden', [ + 'username' => $user['username'], + 'method' => $method, + 'ip' => Http::clientIp(), + ]); + + return true; + } + + /** + * Fehlversuch auf der zweiten Stufe. + * + * Muss in dieselbe Sperrlogik laufen wie ein falsches Passwort. Sonst + * waere die zweite Stufe die Stelle, an der beliebig oft geraten werden + * darf - und damit die schwaechste, obwohl sie die staerkere sein soll. + */ + public static function recordSecondFactorFailure(PDO $db, string $reason): void + { + self::startSession(); + + $userId = (int)($_SESSION['dc_pending_user_id'] ?? 0); + $username = (string)($_SESSION['dc_pending_username'] ?? ''); + $ip = Http::clientIp(); + + if ($username === '') { + return; + } + + self::recordAttempt($db, $ip, $username, false); + SecurityLogger::authFail($ip, $username, 'second_factor_' . $reason); + + try { + $stmt = $db->prepare('SELECT * FROM dc_users WHERE id = :id LIMIT 1'); + $stmt->execute([':id' => $userId]); + $user = $stmt->fetch(); + + if (is_array($user)) { + self::handleAccountFailedAttempt($db, $user, $ip, $username); + } + } catch (\Throwable $e) { + Logger::warning('Fehlversuch der zweiten Stufe nicht gezaehlt', ['error' => $e->getMessage()]); + } + } + + public static function clearPending(): void + { + self::startSession(); + + unset( + $_SESSION['dc_pending_user_id'], + $_SESSION['dc_pending_username'], + $_SESSION['dc_pending_at'], + $_SESSION['dc_webauthn_challenge'], + $_SESSION['dc_webauthn_challenge_at'] + ); + } + + /** + * Legt die Herausforderung ab, die der Browser gleich unterschreiben soll. + * + * In der Sitzung und nicht in der Datenbank: Sie lebt zwei Minuten, gilt + * fuer genau diesen Browser und waere als Tabellenzeile nur Ballast, den + * jemand wieder aufraeumen muesste. + */ + public static function storeChallenge(string $challenge): void + { + self::startSession(); + + $_SESSION['dc_webauthn_challenge'] = $challenge; + $_SESSION['dc_webauthn_challenge_at'] = time(); + } + + /** + * Holt die Herausforderung und verbraucht sie dabei. + * + * Verbrauchen ist der Punkt: Eine Herausforderung, die liegen bleibt, + * laesst sich ein zweites Mal beantworten. + */ + public static function takeChallenge(): string + { + self::startSession(); + + $challenge = (string)($_SESSION['dc_webauthn_challenge'] ?? ''); + $at = (int)($_SESSION['dc_webauthn_challenge_at'] ?? 0); + + unset($_SESSION['dc_webauthn_challenge'], $_SESSION['dc_webauthn_challenge_at']); + + if ($challenge === '' || $at <= 0 || (time() - $at) > Webauthn\WebauthnServer::CHALLENGE_TTL) { + return ''; + } + + return $challenge; + } + + /** Session-Variablen einer erfolgreichen Anmeldung setzen. */ + private static function establishSession(array $user): void + { session_regenerate_id(true); $_SESSION['dc_user_id'] = (int)$user['id']; @@ -309,8 +524,6 @@ final class Auth $_SESSION['dc_last_seen'] = time(); Logger::info('Anmeldung erfolgreich', ['username' => $user['username'], 'ip' => Http::clientIp()]); - - return true; } private static function handleAccountFailedAttempt(PDO $db, array $user, string $ip, string $username): void diff --git a/src/Core/Webauthn/Cbor.php b/src/Core/Webauthn/Cbor.php new file mode 100644 index 0000000..b183cae --- /dev/null +++ b/src/Core/Webauthn/Cbor.php @@ -0,0 +1,199 @@ +> 5; + $minor = $first & 0x1F; + + if ($minor === 31) { + throw new \RuntimeException('CBOR: unbestimmte Laenge wird nicht unterstuetzt.'); + } + + // Major 7 traegt im Zusatzfeld keine Laenge, sondern den Wert selbst. + if ($major === self::MAJOR_SIMPLE) { + return self::simpleValue($minor); + } + + $value = self::readLength($data, $offset, $minor); + + switch ($major) { + case self::MAJOR_UINT: + return $value; + + case self::MAJOR_NINT: + return -1 - $value; + + case self::MAJOR_BYTES: + case self::MAJOR_TEXT: + return self::readChunk($data, $offset, $value); + + case self::MAJOR_ARRAY: + $out = []; + for ($i = 0; $i < $value; $i++) { + $out[] = self::decode($data, $offset); + } + return $out; + + case self::MAJOR_MAP: + $out = []; + for ($i = 0; $i < $value; $i++) { + $key = self::decode($data, $offset); + if (!is_int($key) && !is_string($key)) { + throw new \RuntimeException('CBOR: Schluessel ist weder Zahl noch Zeichenkette.'); + } + // Ganzzahlige Schluessel bleiben ganzzahlig: In einem + // COSE-Schluessel heisst -2 die x-Koordinate und "-2" gar + // nichts. PHP wuerde negative Zahlen als Array-Schluessel + // zwar behalten, der Unterschied zu "-2" ginge aber beim + // Nachschlagen verloren. + $out[$key] = self::decode($data, $offset); + } + return $out; + + case self::MAJOR_TAG: + // Der Inhalt zaehlt, die Etikettierung nicht. WebAuthn + // benutzt keine Tags, aber ein uebersprungenes Tag ist + // harmloser als ein Abbruch. + return self::decode($data, $offset); + } + + throw new \RuntimeException(sprintf('CBOR: unbekannter Haupttyp %d.', $major)); + } + + /** + * Wie decode(), verlangt aber, dass nichts uebrig bleibt. + * + * Fuer clientDataJSON-nahe Faelle, in denen angehaengte Bytes ein Zeichen + * dafuer waeren, dass jemand etwas untergeschoben hat. + * + * @return mixed + */ + public static function decodeComplete(string $data) + { + $offset = 0; + $value = self::decode($data, $offset); + + if ($offset !== strlen($data)) { + throw new \RuntimeException(sprintf( + 'CBOR: %d Byte hinter dem Wert uebrig.', + strlen($data) - $offset + )); + } + + return $value; + } + + /** Wert aus dem Zusatzfeld von Haupttyp 7. */ + private static function simpleValue(int $minor) + { + switch ($minor) { + case 20: return false; + case 21: return true; + case 22: return null; + case 23: return null; // undefined - fuer uns dasselbe wie null + } + + throw new \RuntimeException(sprintf('CBOR: Zusatzwert %d wird nicht unterstuetzt.', $minor)); + } + + /** + * Die Laenge bzw. den Zahlwert aus dem Zusatzfeld lesen. + */ + private static function readLength(string $data, int &$offset, int $minor): int + { + if ($minor < 24) { + return $minor; + } + + switch ($minor) { + case 24: $bytes = 1; break; + case 25: $bytes = 2; break; + case 26: $bytes = 4; break; + case 27: $bytes = 8; break; + default: + throw new \RuntimeException(sprintf('CBOR: ungueltiges Zusatzfeld %d.', $minor)); + } + + $chunk = self::readChunk($data, $offset, $bytes); + + $value = 0; + for ($i = 0; $i < $bytes; $i++) { + $value = ($value << 8) | ord($chunk[$i]); + } + + // Auf einer 64-Bit-Maschine kippt erst das oberste Bit ins Negative. + // Eine Laengenangabe, die das tut, ist keine Laenge. + if ($value < 0) { + throw new \RuntimeException('CBOR: Laengenangabe ausserhalb des darstellbaren Bereichs.'); + } + + return $value; + } + + private static function readChunk(string $data, int &$offset, int $length): string + { + if ($length < 0 || $offset + $length > strlen($data)) { + throw new \RuntimeException('CBOR: Daten enden vorzeitig.'); + } + + $chunk = substr($data, $offset, $length); + $offset += $length; + + return $chunk; + } + + private static function byteAt(string $data, int $offset): int + { + if ($offset < 0 || $offset >= strlen($data)) { + throw new \RuntimeException('CBOR: Daten enden vorzeitig.'); + } + + return ord($data[$offset]); + } +} diff --git a/src/Core/Webauthn/CoseKey.php b/src/Core/Webauthn/CoseKey.php new file mode 100644 index 0000000..6b78dfa --- /dev/null +++ b/src/Core/Webauthn/CoseKey.php @@ -0,0 +1,256 @@ + $cose Die entschluesselte COSE-Abbildung + * @return array{pem:string,alg:int} + */ + public static function toPem(array $cose): array + { + $kty = self::intAt($cose, self::LABEL_KTY, 'kty'); + $alg = self::intAt($cose, self::LABEL_ALG, 'alg'); + + if ($alg === self::EDDSA || $kty === self::KTY_OKP) { + throw new \RuntimeException( + 'Dieser Schluessel bietet EdDSA an. Das Deploymentcenter prueft derzeit ES256 und RS256.' + ); + } + + if ($kty === self::KTY_EC2) { + if ($alg !== self::ES256) { + throw new \RuntimeException(sprintf('Unerwartetes EC-Verfahren (alg=%d), erwartet ES256.', $alg)); + } + return ['pem' => self::ecPem($cose), 'alg' => $alg]; + } + + if ($kty === self::KTY_RSA) { + if ($alg !== self::RS256) { + throw new \RuntimeException(sprintf('Unerwartetes RSA-Verfahren (alg=%d), erwartet RS256.', $alg)); + } + return ['pem' => self::rsaPem($cose), 'alg' => $alg]; + } + + throw new \RuntimeException(sprintf('Unbekannter Schluesseltyp (kty=%d).', $kty)); + } + + /** Das Hashverfahren, mit dem zu diesem alg geprueft wird. */ + public static function digestFor(int $alg): int + { + if ($alg === self::ES256 || $alg === self::RS256) { + return OPENSSL_ALGO_SHA256; + } + + throw new \RuntimeException(sprintf('Kein Hashverfahren fuer alg=%d hinterlegt.', $alg)); + } + + // ----------------------------------------------------------------- + // EC2 / P-256 + // ----------------------------------------------------------------- + + /** + * SubjectPublicKeyInfo fuer einen P-256-Schluessel. + * + * SEQUENCE { + * SEQUENCE { OID id-ecPublicKey, OID prime256v1 } + * BIT STRING { 0x04 || X || Y } + * } + * + * Das fuehrende 0x04 kennzeichnet den unkomprimierten Punkt. + */ + private static function ecPem(array $cose): string + { + $crv = self::intAt($cose, self::LABEL_CRV, 'crv'); + if ($crv !== self::CRV_P256) { + throw new \RuntimeException(sprintf('Nur die Kurve P-256 wird unterstuetzt (crv=%d).', $crv)); + } + + $x = self::bytesAt($cose, self::LABEL_X, 'x'); + $y = self::bytesAt($cose, self::LABEL_Y, 'y'); + + // Beide Koordinaten sind bei P-256 genau 32 Byte. Ein Authenticator, + // der fuehrende Nullen wegkuerzt, wuerde sonst einen Punkt erzeugen, + // der sich nie verifizieren laesst - und der Fehler faende sich erst + // bei der ersten Anmeldung, nicht bei der Registrierung. + $x = self::padLeft($x, 32, 'x'); + $y = self::padLeft($y, 32, 'y'); + + $algorithm = self::sequence( + self::oid("\x2A\x86\x48\xCE\x3D\x02\x01") // 1.2.840.10045.2.1 + . self::oid("\x2A\x86\x48\xCE\x3D\x03\x01\x07") // 1.2.840.10045.3.1.7 + ); + + $point = self::bitString("\x04" . $x . $y); + + return self::pem(self::sequence($algorithm . $point)); + } + + // ----------------------------------------------------------------- + // RSA + // ----------------------------------------------------------------- + + /** + * SubjectPublicKeyInfo fuer einen RSA-Schluessel. + * + * SEQUENCE { + * SEQUENCE { OID rsaEncryption, NULL } + * BIT STRING { SEQUENCE { INTEGER n, INTEGER e } } + * } + */ + private static function rsaPem(array $cose): string + { + $n = self::bytesAt($cose, self::LABEL_N, 'n'); + $e = self::bytesAt($cose, self::LABEL_E, 'e'); + + $algorithm = self::sequence( + self::oid("\x2A\x86\x48\x86\xF7\x0D\x01\x01\x01") // 1.2.840.113549.1.1.1 + . "\x05\x00" // NULL + ); + + $key = self::bitString(self::sequence(self::integer($n) . self::integer($e))); + + return self::pem(self::sequence($algorithm . $key)); + } + + // ----------------------------------------------------------------- + // DER-Bausteine + // ----------------------------------------------------------------- + + private static function sequence(string $contents): string + { + return "\x30" . self::length($contents) . $contents; + } + + private static function oid(string $raw): string + { + return "\x06" . self::length($raw) . $raw; + } + + private static function bitString(string $raw): string + { + // Das fuehrende 0x00 sagt: keine ungenutzten Bits im letzten Byte. + $body = "\x00" . $raw; + return "\x03" . self::length($body) . $body; + } + + /** + * DER-INTEGER aus einer vorzeichenlosen Zahl. + * + * DER liest Ganzzahlen vorzeichenbehaftet. Ein Modulus, dessen oberstes + * Bit gesetzt ist - und das ist bei einem RSA-Modulus immer der Fall - + * waere ohne die vorangestellte Null eine negative Zahl. + */ + private static function integer(string $raw): string + { + $raw = ltrim($raw, "\x00"); + + if ($raw === '') { + $raw = "\x00"; + } elseif ((ord($raw[0]) & 0x80) !== 0) { + $raw = "\x00" . $raw; + } + + return "\x02" . self::length($raw) . $raw; + } + + private static function length(string $contents): string + { + $length = strlen($contents); + + if ($length < 0x80) { + return chr($length); + } + + $bytes = ''; + while ($length > 0) { + $bytes = chr($length & 0xFF) . $bytes; + $length >>= 8; + } + + return chr(0x80 | strlen($bytes)) . $bytes; + } + + private static function pem(string $der): string + { + return "-----BEGIN PUBLIC KEY-----\n" + . chunk_split(base64_encode($der), 64, "\n") + . "-----END PUBLIC KEY-----\n"; + } + + // ----------------------------------------------------------------- + // Zugriff auf die COSE-Abbildung + // ----------------------------------------------------------------- + + private static function intAt(array $cose, int $label, string $name): int + { + if (!array_key_exists($label, $cose) || !is_int($cose[$label])) { + throw new \RuntimeException(sprintf('COSE-Schluessel: Feld "%s" fehlt oder ist keine Zahl.', $name)); + } + + return $cose[$label]; + } + + private static function bytesAt(array $cose, int $label, string $name): string + { + if (!array_key_exists($label, $cose) || !is_string($cose[$label]) || $cose[$label] === '') { + throw new \RuntimeException(sprintf('COSE-Schluessel: Feld "%s" fehlt oder ist leer.', $name)); + } + + return $cose[$label]; + } + + private static function padLeft(string $raw, int $size, string $name): string + { + if (strlen($raw) > $size) { + throw new \RuntimeException(sprintf('COSE-Schluessel: Feld "%s" ist laenger als %d Byte.', $name, $size)); + } + + return str_pad($raw, $size, "\x00", STR_PAD_LEFT); + } +} diff --git a/src/Core/Webauthn/CredentialRepo.php b/src/Core/Webauthn/CredentialRepo.php new file mode 100644 index 0000000..f2961cf --- /dev/null +++ b/src/Core/Webauthn/CredentialRepo.php @@ -0,0 +1,303 @@ +> + */ + public function forUser(int $userId): array + { + $stmt = $this->db->prepare(' + SELECT id, credential_id, label, sign_count, transports, created_at, last_used_at + FROM dc_user_credentials + WHERE user_id = :u + ORDER BY created_at ASC + '); + $stmt->execute([':u' => $userId]); + + $out = []; + foreach ($stmt->fetchAll() as $row) { + $row['credential_b64'] = WebauthnServer::b64uEncode((string)$row['credential_id']); + // Die rohe Kennung hat in der Oberflaeche nichts zu suchen - sie + // ist binaer und wuerde jede HTML-Ausgabe verunstalten. + unset($row['credential_id']); + $out[] = $row; + } + + return $out; + } + + /** + * Die rohen Kennungen eines Kontos, fuer allowCredentials. + * + * @return list + */ + public function rawIdsForUser(int $userId): array + { + $stmt = $this->db->prepare('SELECT credential_id FROM dc_user_credentials WHERE user_id = :u'); + $stmt->execute([':u' => $userId]); + + return array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN)); + } + + public function countForUser(int $userId): int + { + $stmt = $this->db->prepare('SELECT COUNT(*) FROM dc_user_credentials WHERE user_id = :u'); + $stmt->execute([':u' => $userId]); + + return (int)$stmt->fetchColumn(); + } + + /** + * Sucht einen Schluessel - und zwar am Konto, nicht nur an der Kennung. + * + * Die Einschraenkung auf das Konto ist der Grund, warum diese Methode + * nicht bloss die Kennung nimmt: Sonst koennte der Schluessel eines + * beliebigen anderen Kontos eine Anmeldung abschliessen, solange er nur + * gueltig unterschreibt. + * + * @return array|null + */ + public function find(int $userId, string $rawCredentialId): ?array + { + $stmt = $this->db->prepare(' + SELECT * FROM dc_user_credentials + WHERE user_id = :u AND credential_id = :c + LIMIT 1 + '); + $stmt->bindValue(':u', $userId, PDO::PARAM_INT); + $stmt->bindValue(':c', $rawCredentialId, PDO::PARAM_LOB); + $stmt->execute(); + + $row = $stmt->fetch(); + + return is_array($row) ? $row : null; + } + + public function add( + int $userId, + string $credentialId, + string $publicKeyPem, + int $alg, + int $signCount, + ?string $aaguid, + string $label, + ?string $transports + ): void { + $stmt = $this->db->prepare(' + INSERT INTO dc_user_credentials + (user_id, credential_id, public_key, alg, sign_count, aaguid, transports, label, created_at) + VALUES + (:u, :c, :pk, :alg, :sc, :aaguid, :transports, :label, UTC_TIMESTAMP()) + '); + $stmt->bindValue(':u', $userId, PDO::PARAM_INT); + $stmt->bindValue(':c', $credentialId, PDO::PARAM_LOB); + $stmt->bindValue(':pk', $publicKeyPem); + $stmt->bindValue(':alg', $alg, PDO::PARAM_INT); + $stmt->bindValue(':sc', $signCount, PDO::PARAM_INT); + $stmt->bindValue(':aaguid', $aaguid, $aaguid === null ? PDO::PARAM_NULL : PDO::PARAM_LOB); + $stmt->bindValue(':transports', $transports, $transports === null ? PDO::PARAM_NULL : PDO::PARAM_STR); + $stmt->bindValue(':label', $label); + $stmt->execute(); + + Logger::info('Hardwarekey registriert', ['user_id' => $userId, 'label' => $label]); + } + + public function touch(int $credentialRowId, int $signCount): void + { + $stmt = $this->db->prepare(' + UPDATE dc_user_credentials + SET sign_count = :sc, last_used_at = UTC_TIMESTAMP() + WHERE id = :id + '); + $stmt->execute([':sc' => $signCount, ':id' => $credentialRowId]); + } + + /** Entfernt einen Schluessel. Das Konto begrenzt, wessen Schluessel das sein darf. */ + public function delete(int $userId, int $credentialRowId): bool + { + $stmt = $this->db->prepare('DELETE FROM dc_user_credentials WHERE id = :id AND user_id = :u'); + $stmt->execute([':id' => $credentialRowId, ':u' => $userId]); + + $removed = $stmt->rowCount() > 0; + + if ($removed) { + Logger::info('Hardwarekey entfernt', ['user_id' => $userId, 'credential' => $credentialRowId]); + } + + return $removed; + } + + public function rename(int $userId, int $credentialRowId, string $label): bool + { + $stmt = $this->db->prepare('UPDATE dc_user_credentials SET label = :l WHERE id = :id AND user_id = :u'); + $stmt->execute([':l' => $label, ':id' => $credentialRowId, ':u' => $userId]); + + return $stmt->rowCount() > 0; + } + + // ----------------------------------------------------------------- + // Notfalltoken + // ----------------------------------------------------------------- + + /** + * Erzeugt einen neuen Notfalltoken und liefert ihn im Klartext zurueck. + * + * Der Rueckgabewert ist die einzige Gelegenheit, ihn zu sehen. In der + * Datenbank landet nur der Hash - wer sie liest, haelt damit keinen + * Zweitschluessel in der Hand. Ein vorhandener Token wird dabei + * ueberschrieben; zwei gueltige Notfallwege waeren einer zu viel. + */ + public function issueRecoveryToken(int $userId): string + { + $token = self::generateToken(); + + $stmt = $this->db->prepare(' + UPDATE dc_users + SET recovery_hash = :h, recovery_created_at = UTC_TIMESTAMP(), recovery_used_at = NULL + WHERE id = :id + '); + $stmt->execute([':h' => password_hash($token, PASSWORD_DEFAULT), ':id' => $userId]); + + Logger::info('Notfalltoken erzeugt', ['user_id' => $userId]); + + return $token; + } + + /** + * Prueft einen Notfalltoken und verbraucht ihn bei Erfolg. + * + * Einmalgebrauch heisst hier woertlich: Nach dem erfolgreichen Vorzeigen + * ist der Hash weg. Wer sich damit angemeldet hat, muss sich in der + * Oberflaeche einen neuen erzeugen - und merkt dadurch, dass er es tun + * muss, statt es zu vergessen. + */ + public function consumeRecoveryToken(int $userId, string $presented): bool + { + $stmt = $this->db->prepare('SELECT recovery_hash FROM dc_users WHERE id = :id LIMIT 1'); + $stmt->execute([':id' => $userId]); + $hash = $stmt->fetchColumn(); + + if (!is_string($hash) || $hash === '') { + // Trotzdem einmal rechnen: Ob ein Notfalltoken hinterlegt ist, + // soll sich nicht an der Antwortzeit ablesen lassen. + password_verify($presented, self::dummyHash()); + return false; + } + + if (!password_verify(self::normalizeToken($presented), $hash)) { + return false; + } + + $clear = $this->db->prepare(' + UPDATE dc_users + SET recovery_hash = NULL, recovery_used_at = UTC_TIMESTAMP() + WHERE id = :id + '); + $clear->execute([':id' => $userId]); + + Logger::warning('Anmeldung mit Notfalltoken', ['user_id' => $userId]); + + return true; + } + + /** + * Zustand des Notfalltokens fuer die Anzeige. + * + * @return array{present:bool,created_at:?string,used_at:?string} + */ + public function recoveryState(int $userId): array + { + $stmt = $this->db->prepare(' + SELECT recovery_hash, recovery_created_at, recovery_used_at + FROM dc_users WHERE id = :id LIMIT 1 + '); + $stmt->execute([':id' => $userId]); + $row = $stmt->fetch(); + + if (!is_array($row)) { + return ['present' => false, 'created_at' => null, 'used_at' => null]; + } + + return [ + 'present' => is_string($row['recovery_hash'] ?? null) && $row['recovery_hash'] !== '', + 'created_at' => $row['recovery_created_at'] ?? null, + 'used_at' => $row['recovery_used_at'] ?? null, + ]; + } + + /** + * Der Klartext, den der Betreiber notiert: fuenf Gruppen zu vier Zeichen. + */ + private static function generateToken(): string + { + $alphabet = self::TOKEN_ALPHABET; + $max = strlen($alphabet) - 1; + + $groups = []; + for ($g = 0; $g < self::TOKEN_GROUPS; $g++) { + $group = ''; + for ($i = 0; $i < 4; $i++) { + $group .= $alphabet[random_int(0, $max)]; + } + $groups[] = $group; + } + + return implode('-', $groups); + } + + /** + * Macht aus dem Abgetippten das, was gehasht wurde. + * + * Bindestriche und Leerzeichen fallen weg, Kleinbuchstaben werden gross: + * Der Token wird von einem Zettel abgeschrieben, und ein vergessener + * Bindestrich ist kein Grund, jemanden auszusperren. + */ + private static function normalizeToken(string $presented): string + { + $stripped = strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $presented) ?? ''); + + $groups = str_split($stripped, 4); + + return implode('-', $groups); + } + + private static function dummyHash(): string + { + static $hash = null; + if ($hash === null) { + $hash = password_hash(bin2hex(random_bytes(16)), PASSWORD_DEFAULT); + } + return $hash; + } +} diff --git a/src/Core/Webauthn/WebauthnServer.php b/src/Core/Webauthn/WebauthnServer.php new file mode 100644 index 0000000..4f8aec1 --- /dev/null +++ b/src/Core/Webauthn/WebauthnServer.php @@ -0,0 +1,369 @@ + $existingCredentialIds Roh, nicht kodiert + * @return array + */ + public static function registrationOptions( + string $challenge, + int $userId, + string $username, + array $existingCredentialIds + ): array { + $exclude = []; + foreach ($existingCredentialIds as $id) { + $exclude[] = ['type' => 'public-key', 'id' => self::b64uEncode($id)]; + } + + $params = []; + foreach (self::ALGORITHMS as $alg) { + $params[] = ['type' => 'public-key', 'alg' => $alg]; + } + + return [ + 'challenge' => self::b64uEncode($challenge), + 'rp' => [ + 'id' => self::rpId(), + 'name' => (string)Config::get('app.name', 'Deploymentcenter'), + ], + 'user' => [ + // Die Nutzerkennung ist die interne ID, nicht der Name. Wird + // das Konto umbenannt, bleiben registrierte Schluessel damit + // gueltig - haette hier der Name gestanden, waere jede + // Umbenennung ein stiller Verlust aller Schluessel. + 'id' => self::b64uEncode((string)$userId), + 'name' => $username, + 'displayName' => $username, + ], + 'pubKeyCredParams' => $params, + 'timeout' => self::CHALLENGE_TTL * 1000, + 'attestation' => 'none', + 'excludeCredentials' => $exclude, + 'authenticatorSelection' => [ + // "cross-platform": gemeint ist ein Stueck Hardware zum + // Anstecken, nicht der Fingerabdrucksensor des Rechners, an + // dem man gerade sitzt. Wer Windows Hello registriert, hat + // einen zweiten Faktor, der mit dem ersten im selben Geraet + // sitzt. + 'authenticatorAttachment' => 'cross-platform', + 'userVerification' => 'discouraged', + 'residentKey' => 'discouraged', + ], + ]; + } + + /** + * Prueft die Antwort des Browsers auf create() und liefert, was gespeichert wird. + * + * @return array{credential_id:string,public_key:string,sign_count:int,aaguid:?string,alg:int} + */ + public static function verifyRegistration( + string $clientDataJson, + string $attestationObject, + string $expectedChallenge + ): array { + self::checkClientData($clientDataJson, 'webauthn.create', $expectedChallenge); + + $attestation = Cbor::decodeComplete($attestationObject); + if (!is_array($attestation) || !isset($attestation['authData']) || !is_string($attestation['authData'])) { + throw new \RuntimeException('Das Attestationsobjekt enthaelt keine Authenticator-Daten.'); + } + + $authData = self::parseAuthData($attestation['authData'], true); + + if (($authData['flags'] & self::FLAG_ATTESTED_DATA) === 0) { + throw new \RuntimeException('Der Authenticator hat keinen Schluessel mitgeschickt.'); + } + + $key = CoseKey::toPem($authData['cose']); + + return [ + 'credential_id' => $authData['credential_id'], + 'public_key' => $key['pem'], + 'sign_count' => $authData['sign_count'], + 'aaguid' => $authData['aaguid'], + 'alg' => $key['alg'], + ]; + } + + // ----------------------------------------------------------------- + // Anmelden + // ----------------------------------------------------------------- + + /** + * Die Vorgaben, mit denen der Browser navigator.credentials.get() ruft. + * + * @param list $credentialIds Roh, nicht kodiert + * @return array + */ + public static function assertionOptions(string $challenge, array $credentialIds): array + { + $allow = []; + foreach ($credentialIds as $id) { + $allow[] = ['type' => 'public-key', 'id' => self::b64uEncode($id)]; + } + + return [ + 'challenge' => self::b64uEncode($challenge), + 'rpId' => self::rpId(), + 'timeout' => self::CHALLENGE_TTL * 1000, + 'userVerification' => 'discouraged', + 'allowCredentials' => $allow, + ]; + } + + /** + * Prueft die Antwort des Browsers auf get(). + * + * @return array{sign_count:int} + */ + public static function verifyAssertion( + string $clientDataJson, + string $authenticatorData, + string $signature, + string $expectedChallenge, + string $publicKeyPem, + int $alg, + int $storedSignCount + ): array { + self::checkClientData($clientDataJson, 'webauthn.get', $expectedChallenge); + + $authData = self::parseAuthData($authenticatorData, false); + + // Unterschrieben wird ueber die Authenticator-Daten und den Hash der + // Clientdaten - in dieser Reihenfolge, unveraendert. + $signed = $authenticatorData . hash('sha256', $clientDataJson, true); + + $key = openssl_pkey_get_public($publicKeyPem); + if ($key === false) { + throw new \RuntimeException('Der hinterlegte oeffentliche Schluessel ist unlesbar.'); + } + + $ok = openssl_verify($signed, $signature, $key, CoseKey::digestFor($alg)); + + if ($ok !== 1) { + throw new \RuntimeException('Die Signatur des Hardwarekeys stimmt nicht.'); + } + + // Klonerkennung. Ein Authenticator zaehlt bei jeder Benutzung hoch; + // sieht der Server denselben oder einen kleineren Wert wieder, gibt + // es den Schluessel zweimal. Ausgenommen ist der Fall, dass beide + // Werte 0 sind: Ein Teil der Hardware fuehrt den Zaehler gar nicht, + // und "zaehlt nicht" darf nicht wie "geklont" aussehen. + if (!($storedSignCount === 0 && $authData['sign_count'] === 0) + && $authData['sign_count'] <= $storedSignCount) { + throw new \RuntimeException(sprintf( + 'Der Zaehler des Hardwarekeys ist nicht gestiegen (%d nach %d). Moeglicherweise ein Duplikat.', + $authData['sign_count'], + $storedSignCount + )); + } + + return ['sign_count' => $authData['sign_count']]; + } + + // ----------------------------------------------------------------- + // Gemeinsame Pruefungen + // ----------------------------------------------------------------- + + private static function checkClientData(string $json, string $expectedType, string $expectedChallenge): void + { + $data = json_decode($json, true); + + if (!is_array($data)) { + throw new \RuntimeException('Die Clientdaten sind kein lesbares JSON.'); + } + + if (($data['type'] ?? null) !== $expectedType) { + throw new \RuntimeException(sprintf( + 'Unerwartete Zeremonie: "%s" statt "%s".', + is_string($data['type'] ?? null) ? $data['type'] : '?', + $expectedType + )); + } + + $presented = is_string($data['challenge'] ?? null) ? self::b64uDecode($data['challenge']) : ''; + if ($presented === '' || !hash_equals($expectedChallenge, $presented)) { + throw new \RuntimeException('Die Herausforderung stimmt nicht. Bitte erneut versuchen.'); + } + + $origin = is_string($data['origin'] ?? null) ? $data['origin'] : ''; + if (!hash_equals(self::expectedOrigin(), $origin)) { + throw new \RuntimeException(sprintf( + 'Die Anmeldung kam von "%s", erwartet wird "%s".', + $origin, + self::expectedOrigin() + )); + } + } + + /** + * Zerlegt die Authenticator-Daten. + * + * 32 Byte rpIdHash | 1 Byte flags | 4 Byte signCount + * danach bei einer Registrierung: + * 16 Byte aaguid | 2 Byte Laenge | credentialId | COSE-Schluessel + * + * @return array{flags:int,sign_count:int,credential_id:string,aaguid:?string,cose:array} + */ + private static function parseAuthData(string $authData, bool $expectCredential): array + { + if (strlen($authData) < 37) { + throw new \RuntimeException('Die Authenticator-Daten sind zu kurz.'); + } + + $rpIdHash = substr($authData, 0, 32); + if (!hash_equals(hash('sha256', self::rpId(), true), $rpIdHash)) { + throw new \RuntimeException(sprintf( + 'Der Schluessel gehoert zu einer anderen Adresse als "%s".', + self::rpId() + )); + } + + $flags = ord($authData[32]); + + if (($flags & self::FLAG_USER_PRESENT) === 0) { + throw new \RuntimeException('Der Hardwarekey wurde nicht beruehrt.'); + } + + $unpacked = unpack('Ncount', substr($authData, 33, 4)); + $signCount = is_array($unpacked) ? (int)$unpacked['count'] : 0; + + $result = [ + 'flags' => $flags, + 'sign_count' => $signCount, + 'credential_id' => '', + 'aaguid' => null, + 'cose' => [], + ]; + + if (!$expectCredential) { + return $result; + } + + if (strlen($authData) < 55) { + throw new \RuntimeException('Die Authenticator-Daten enthalten keine Schluesseldaten.'); + } + + $aaguid = substr($authData, 37, 16); + + $lengthBytes = unpack('nlen', substr($authData, 53, 2)); + $credentialLength = is_array($lengthBytes) ? (int)$lengthBytes['len'] : 0; + + if ($credentialLength < 1 || $credentialLength > 1023 || strlen($authData) < 55 + $credentialLength) { + throw new \RuntimeException('Die Laengenangabe der Schluesselkennung ist unbrauchbar.'); + } + + $result['credential_id'] = substr($authData, 55, $credentialLength); + $result['aaguid'] = $aaguid === str_repeat("\x00", 16) ? null : $aaguid; + + // Der COSE-Schluessel schliesst sich unmittelbar an. Der Versatz + // interessiert danach nicht mehr: Hinter dem Schluessel stehen nur + // noch Erweiterungen, die wir nicht auswerten. + $offset = 55 + $credentialLength; + $cose = Cbor::decode($authData, $offset); + + if (!is_array($cose)) { + throw new \RuntimeException('Der mitgelieferte Schluessel ist keine COSE-Abbildung.'); + } + + $result['cose'] = $cose; + + return $result; + } + + // ----------------------------------------------------------------- + // base64url - ohne Auffuellzeichen, mit - und _ statt + und / + // ----------------------------------------------------------------- + + public static function b64uEncode(string $raw): string + { + return rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + } + + public static function b64uDecode(string $encoded): string + { + $normalized = strtr($encoded, '-_', '+/'); + $padded = str_pad($normalized, (int)(ceil(strlen($normalized) / 4) * 4), '=', STR_PAD_RIGHT); + $decoded = base64_decode($padded, true); + + return $decoded === false ? '' : $decoded; + } +} diff --git a/src/Modules/Bugtracker/Roadmap.php b/src/Modules/Bugtracker/Roadmap.php index 721bb9d..78492ce 100644 --- a/src/Modules/Bugtracker/Roadmap.php +++ b/src/Modules/Bugtracker/Roadmap.php @@ -355,46 +355,243 @@ final class Roadmap // ================================================================== /** - * Vergibt die Reihenfolge eines Bereichs neu, in Zehnerschritten. + * Ordnet die Eintraege eines Bereichs untereinander neu. * - * Die Luecken sind der Zweck: Ein Eintrag laesst sich spaeter - * dazwischenschieben (Rang 15), ohne dass alle folgenden neu geschrieben - * werden muessen. Erst wenn die Luecke aufgebraucht ist, laeuft das hier - * noch einmal. + * WICHTIG - das war einmal anders, siehe #177: roadmap_rank gilt + * PROJEKTWEIT, nicht innerhalb eines Bereichs. nextUp() sortiert danach + * ohne jede Gruppierung nach epic, und die Tafel schneidet ihre Phasen + * daraus. Die frueher hier vergebene Folge 10/20/30 je Bereich hatte + * deshalb eine Wirkung, die niemand angefordert hatte: Eine Geste + * innerhalb eines Bereichs schob dessen Eintraege gegenueber allen + * anderen Bereichen durch die halbe Warteschlange. + * + * Jetzt werden ausschliesslich die Raenge vertauscht, die diese + * Eintraege ohnehin schon halten. Ihre Plaetze in der projektweiten + * Reihenfolge bleiben als Menge unveraendert - nur wer auf welchem + * sitzt, aendert sich. Genau das ist die Geste, die gemeint war. * * @param list $orderedIds Item-IDs in der gewuenschten Reihenfolge */ public function reorder(string $projectSlug, string $epic, array $orderedIds): int { + $orderedIds = array_values(array_map('intval', $orderedIds)); + + if ($orderedIds === []) { + return 0; + } + + $placeholders = implode(',', array_fill(0, count($orderedIds), '?')); + $stmt = $this->db->prepare(' - UPDATE bugtracker_items - SET roadmap_rank = :rank - WHERE id = :id AND project_slug = :slug AND epic = :epic + SELECT id, roadmap_rank FROM bugtracker_items + WHERE project_slug = ? AND epic = ? AND id IN (' . $placeholders . ') + '); + $stmt->execute(array_merge([$projectSlug, $epic], $orderedIds)); + + $known = []; + $ranks = []; + foreach ($stmt->fetchAll() ?: [] as $row) { + $known[(int)$row['id']] = true; + if ($row['roadmap_rank'] !== null) { + $ranks[] = (int)$row['roadmap_rank']; + } + } + + // Nur die Eintraege, die es wirklich gibt - und in der gewuenschten + // Reihenfolge, nicht in der der Datenbank. + $targets = array_values(array_filter($orderedIds, static fn ($id) => isset($known[$id]))); + + if ($targets === []) { + return 0; + } + + sort($ranks); + + // Ungereihte Eintraege im Bereich brauchen zusaetzliche Plaetze. + // Sie kommen hinter den hoechsten vorhandenen Rang, in Zehnerschritten - + // dort standen sie ohnehin schon, naemlich am Ende. + $next = ($ranks === [] ? 0 : $ranks[count($ranks) - 1]); + while (count($ranks) < count($targets)) { + $next += 10; + $ranks[] = $next; + } + + $update = $this->db->prepare(' + UPDATE bugtracker_items SET roadmap_rank = :rank WHERE id = :id AND project_slug = :slug '); $touched = 0; - $rank = 10; - - foreach ($orderedIds as $id) { - $stmt->execute([ - ':rank' => $rank, - ':id' => (int)$id, - ':slug' => $projectSlug, - ':epic' => $epic, - ]); - $touched += $stmt->rowCount(); - $rank += 10; + foreach ($targets as $i => $id) { + $update->execute([':rank' => $ranks[$i], ':id' => $id, ':slug' => $projectSlug]); + $touched += $update->rowCount(); } return $touched; } /** - * Schiebt einen Eintrag innerhalb seines Bereichs um eine Position. + * Setzt einen Eintrag zwischen zwei Nachbarn - das Ziehen auf der Tafel. + * + * Der Rang ist die Mitte zwischen den Nachbarraengen. Ist dort kein Platz + * mehr, wird das Projekt EINMAL neu durchnummeriert (Zehnerschritte) und + * danach erneut die Mitte genommen. Dass das passiert ist, steht im + * Rueckgabewert - eine stille Neuvergabe aller Raenge waere das, wovor + * #177 warnt. + * + * @param int|null $prevId Nachbar davor (kleinerer Rang), null = ganz nach vorn + * @param int|null $nextId Nachbar dahinter (groesserer Rang), null = ganz nach hinten + * @param int|null $minRank Untere Grenze der Zielspalte, falls sie leer ist + * @param int|null $maxRank Obere Grenze der Zielspalte, falls sie leer ist + * @return array{rank:int,renumbered:bool} + */ + public function rankBetween( + int $itemId, + ?int $prevId, + ?int $nextId, + ?int $minRank = null, + ?int $maxRank = null + ): array { + $stmt = $this->db->prepare('SELECT id, project_slug FROM bugtracker_items WHERE id = :id LIMIT 1'); + $stmt->execute([':id' => $itemId]); + $item = $stmt->fetch(); + + if (!is_array($item)) { + throw new \RuntimeException(sprintf('Item #%d existiert nicht.', $itemId)); + } + + $projectSlug = (string)$item['project_slug']; + + $bounds = $this->bounds($projectSlug, $itemId, $prevId, $nextId, $minRank, $maxRank); + $rank = self::midpoint($bounds['lower'], $bounds['upper']); + + if ($rank !== null) { + $this->setRank($itemId, $rank); + return ['rank' => $rank, 'renumbered' => false]; + } + + // Kein Platz zwischen den Nachbarn. Einmal Luft schaffen. + $this->renumber($projectSlug); + + $bounds = $this->bounds($projectSlug, $itemId, $prevId, $nextId, $minRank, $maxRank); + $rank = self::midpoint($bounds['lower'], $bounds['upper']); + + if ($rank === null) { + throw new \RuntimeException('Zwischen den Nachbarn ist auch nach dem Neunummerieren kein Platz.'); + } + + $this->setRank($itemId, $rank); + + return ['rank' => $rank, 'renumbered' => true]; + } + + /** + * Die Raenge der beiden Nachbarn. + * + * @return array{lower:?int,upper:?int} + */ + private function bounds( + string $projectSlug, + int $itemId, + ?int $prevId, + ?int $nextId, + ?int $minRank, + ?int $maxRank + ): array { + $rankOf = function (?int $id) use ($projectSlug, $itemId): ?int { + if ($id === null || $id === $itemId) { + return null; + } + $stmt = $this->db->prepare(' + SELECT roadmap_rank FROM bugtracker_items + WHERE id = :id AND project_slug = :slug LIMIT 1 + '); + $stmt->execute([':id' => $id, ':slug' => $projectSlug]); + $rank = $stmt->fetchColumn(); + + return ($rank === false || $rank === null) ? null : (int)$rank; + }; + + $lower = $rankOf($prevId); + $upper = $rankOf($nextId); + + // Leere Zielspalte: Dann geben die Phasengrenzen den Rahmen vor. + if ($lower === null && $minRank !== null) { + $lower = $minRank - 1; + } + if ($upper === null && $maxRank !== null) { + $upper = $maxRank + 1; + } + + return ['lower' => $lower, 'upper' => $upper]; + } + + /** + * Die Mitte zwischen zwei Raengen, oder null wenn dort kein Platz ist. + */ + private static function midpoint(?int $lower, ?int $upper): ?int + { + if ($lower === null && $upper === null) { + return 10; + } + + if ($lower === null) { + // Ganz nach vorn. Unter 1 geht nichts mehr. + return $upper > 1 ? (int)max(1, intdiv($upper, 2)) : null; + } + + if ($upper === null) { + return $lower + 10; + } + + if ($upper - $lower < 2) { + return null; + } + + return intdiv($lower + $upper, 2); + } + + /** + * Vergibt alle Raenge eines Projekts neu, in Zehnerschritten. + * + * Die bestehende Reihenfolge bleibt dabei erhalten - es wird nur Luft + * zwischen die Eintraege gelassen. Laeuft ausschliesslich dann, wenn + * zwischen zwei Nachbarn kein Platz mehr ist. + */ + public function renumber(string $projectSlug): int + { + $stmt = $this->db->prepare(' + SELECT id FROM bugtracker_items + WHERE project_slug = :slug AND roadmap_rank IS NOT NULL + ORDER BY roadmap_rank ASC, id ASC + '); + $stmt->execute([':slug' => $projectSlug]); + + $update = $this->db->prepare('UPDATE bugtracker_items SET roadmap_rank = :rank WHERE id = :id'); + + $rank = 0; + $touched = 0; + foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) ?: [] as $id) { + $rank += 10; + $update->execute([':rank' => $rank, ':id' => (int)$id]); + $touched++; + } + + return $touched; + } + + /** + * Schiebt einen Eintrag um eine Position in der Reihenfolge des Projekts. * * Tauscht die Raenge mit dem Nachbarn, statt sie durchzuzaehlen: Das ist - * ein UPDATE auf zwei Zeilen und bleibt auch bei einem Bereich mit - * hundert Eintraegen billig. + * ein UPDATE auf zwei Zeilen und bleibt auch bei hundert Eintraegen + * billig. + * + * Der Nachbar wird PROJEKTWEIT gesucht und nicht mehr nur im eigenen + * Bereich (#177). Zuvor tauschte ein Klick die Raenge zweier Eintraege + * desselben Bereichs, die in der projektweiten Reihenfolge weit + * auseinanderlagen - der Eintrag sprang dann in der Warteschlange ueber + * beliebig viele fremde Vorhaben hinweg, obwohl die Geste "eine Position" + * hiess. */ public function move(int $itemId, string $direction): bool { @@ -415,9 +612,9 @@ final class Roadmap if ($item['roadmap_rank'] === null) { $max = $this->db->prepare(' SELECT COALESCE(MAX(roadmap_rank), 0) FROM bugtracker_items - WHERE project_slug = :slug AND epic = :epic + WHERE project_slug = :slug '); - $max->execute([':slug' => $item['project_slug'], ':epic' => $item['epic']]); + $max->execute([':slug' => $item['project_slug']]); $newRank = ((int)$max->fetchColumn()) + 10; $this->setRank($itemId, $newRank); @@ -429,7 +626,7 @@ final class Roadmap $neighbour = $this->db->prepare(' SELECT id, roadmap_rank FROM bugtracker_items - WHERE project_slug = :slug AND epic = :epic + WHERE project_slug = :slug AND roadmap_rank IS NOT NULL AND roadmap_rank ' . $comparison . ' :rank ORDER BY roadmap_rank ' . $sort . ', id ' . $sort . ' @@ -437,7 +634,6 @@ final class Roadmap '); $neighbour->execute([ ':slug' => $item['project_slug'], - ':epic' => $item['epic'], ':rank' => (int)$item['roadmap_rank'], ]); $other = $neighbour->fetch(); @@ -462,22 +658,60 @@ final class Roadmap // Bereiche verwalten // ================================================================== - public function saveEpic(string $projectSlug, string $epic, string $title, ?string $summary, int $sortOrder): void - { - $stmt = $this->db->prepare(' - INSERT INTO roadmap_epics (project_slug, epic, title, summary, sort_order) - VALUES (:slug, :epic, :title, :summary, :sort) - ON DUPLICATE KEY UPDATE title = VALUES(title), - summary = VALUES(summary), - sort_order = VALUES(sort_order) - '); - $stmt->execute([ + /** + * Legt einen Themenbereich an oder aendert ihn. + * + * $color und $highlight steuern die Spur auf der Tafel. Das Kennzeichen + * heisst absichtlich "hervorheben" und nicht "Sicherheit": Welcher + * Bereich ins Auge fallen soll, ist eine Entscheidung des Projekts. + * Bei uns ist es die Sicherheit, anderswo heisst er anders - ein Name im + * Code waere fuer genau ein Projekt richtig. + * + * $color und $highlight auf null zu lassen aendert sie nicht. Sonst + * wuerde jeder Aufruf aus dem bestehenden Formular, das diese Felder + * nicht kennt, die Farbe stillschweigend loeschen. + */ + public function saveEpic( + string $projectSlug, + string $epic, + string $title, + ?string $summary, + int $sortOrder, + ?string $color = null, + ?bool $highlight = null + ): void { + $columns = 'project_slug, epic, title, summary, sort_order'; + $values = ':slug, :epic, :title, :summary, :sort'; + $updates = 'title = VALUES(title), summary = VALUES(summary), sort_order = VALUES(sort_order)'; + + $params = [ ':slug' => $projectSlug, ':epic' => mb_substr(trim($epic), 0, 24), ':title' => mb_substr(trim($title), 0, 160), ':summary' => ($summary !== null && trim($summary) !== '') ? trim($summary) : null, ':sort' => $sortOrder, - ]); + ]; + + if ($color !== null) { + $columns .= ', color'; + $values .= ', :color'; + $updates .= ', color = VALUES(color)'; + $params[':color'] = trim($color) !== '' ? mb_substr(trim($color), 0, 16) : null; + } + + if ($highlight !== null) { + $columns .= ', highlight'; + $values .= ', :highlight'; + $updates .= ', highlight = VALUES(highlight)'; + $params[':highlight'] = $highlight ? 1 : 0; + } + + $stmt = $this->db->prepare(' + INSERT INTO roadmap_epics (' . $columns . ') + VALUES (' . $values . ') + ON DUPLICATE KEY UPDATE ' . $updates . ' + '); + $stmt->execute($params); } /** diff --git a/src/Modules/Bugtracker/RoadmapBoard.php b/src/Modules/Bugtracker/RoadmapBoard.php new file mode 100644 index 0000000..f9638b6 --- /dev/null +++ b/src/Modules/Bugtracker/RoadmapBoard.php @@ -0,0 +1,563 @@ + + */ + public function build(string $projectSlug, array $options = []): array + { + $doneLimit = (int)($options['done_limit'] ?? self::DONE_LIMIT); + + $rows = $this->load($projectSlug); + + $done = []; + $thinking = []; + $ranked = []; + + foreach ($rows as $row) { + $status = (string)$row['status']; + + if (in_array($status, self::HIDDEN_STATUSES, true)) { + continue; + } + + if (in_array($status, ['resolved', 'closed'], true)) { + $done[] = $row; + continue; + } + + // "Wird ueberlegt": Zurueckgestelltes, Ideen und alles, was noch + // keinen Rang hat. Letzteres ist der wichtigste Fall - ein + // Vorhaben ohne Rang ist nicht eingeordnet, und das soll man + // sehen, statt es ans Ende einer Spalte zu sortieren, wo es wie + // eingeordnet aussieht. + if ($status === 'deferred' + || $row['roadmap_rank'] === null + || (string)$row['severity'] === 'idea') { + $thinking[] = $row; + continue; + } + + $ranked[] = $row; + } + + $doneTotal = count($done); + usort($done, static fn ($a, $b) => strcmp((string)$b['updated_at'], (string)$a['updated_at'])); + $doneShown = $doneLimit > 0 ? array_slice($done, 0, $doneLimit) : $done; + + $phases = $this->phases($projectSlug, $ranked); + + // --- Kaertchen den Spalten zuordnen ----------------------------- + $items = []; + + foreach ($doneShown as $row) { + $items[] = $this->card($row, 'fundament'); + } + + foreach ($ranked as $row) { + $items[] = $this->card($row, $this->phaseFor($phases, (int)$row['roadmap_rank'])); + } + + foreach ($thinking as $row) { + $items[] = $this->card($row, 'ueberlegt'); + } + + return [ + 'project' => $projectSlug, + 'phases' => $this->phaseList($phases, $doneTotal, count($doneShown), count($thinking)), + 'lanes' => $this->lanes($projectSlug, $items), + 'items' => $items, + 'stats' => [ + 'done' => $doneTotal, + 'planned' => count($ranked), + 'thinking' => count($thinking), + 'highlight' => $this->countHighlighted($projectSlug, $items), + ], + 'meta' => [ + 'generated_at' => gmdate('Y-m-d H:i:s'), + 'phases_guessed' => $phases['guessed'], + 'done_shown' => count($doneShown), + 'done_total' => $doneTotal, + 'done_limit' => $doneLimit, + ], + ]; + } + + // ================================================================== + // Laden + // ================================================================== + + /** @return list> */ + private function load(string $projectSlug): array + { + $stmt = $this->db->prepare(' + SELECT id, project_slug, type, title, status, severity, epic, roadmap_rank, + target_build, defer_trigger, doc_anchor, claimed_by, lease_until, + occurrence_count, created_at, updated_at + FROM bugtracker_items + WHERE project_slug = :slug + ORDER BY roadmap_rank IS NULL ASC, roadmap_rank ASC, id ASC + '); + $stmt->execute([':slug' => $projectSlug]); + + return $stmt->fetchAll() ?: []; + } + + // ================================================================== + // Phasen + // ================================================================== + + /** + * Die Rangphasen: gepflegt, oder aus den groessten Luecken geschnitten. + * + * @param list> $ranked + * @return array{list:list>,guessed:bool} + */ + private function phases(string $projectSlug, array $ranked): array + { + $configured = $this->configuredPhases($projectSlug); + + if ($configured !== []) { + return ['list' => $configured, 'guessed' => false]; + } + + return ['list' => $this->autoPhases($ranked), 'guessed' => true]; + } + + /** @return list> */ + private function configuredPhases(string $projectSlug): array + { + try { + $stmt = $this->db->prepare(' + SELECT sort_order, rank_from, rank_to, label, subtitle + FROM roadmap_phases + WHERE project_slug = :slug + ORDER BY sort_order ASC, rank_from ASC + '); + $stmt->execute([':slug' => $projectSlug]); + $rows = $stmt->fetchAll() ?: []; + } catch (\Throwable $e) { + // Vor Migration 019 gibt es die Tabelle nicht. Dann wird + // geschnitten - das ist der Normalfall, nicht der Fehlerfall. + return []; + } + + $out = []; + foreach ($rows as $i => $row) { + $out[] = [ + 'key' => 'p' . (int)$row['sort_order'], + 'label' => (string)$row['label'], + 'subtitle' => $row['subtitle'] !== null ? (string)$row['subtitle'] : null, + 'rank_from' => (int)$row['rank_from'], + 'rank_to' => (int)$row['rank_to'], + 'kind' => $i === 0 ? 'now' : 'plan', + ]; + } + + return $out; + } + + /** + * Schneidet an den groessten Luecken der Rangfolge. + * + * Die Grenzen sind geraten, und die Tafel sagt das auch - "phases_guessed". + * Geraten ist hier trotzdem besser als fest verdrahtet: roadmap_rank wird + * je Projekt frei vergeben, und wer 5/10/15 benutzt, hat andere Abstaende + * als jemand, der in Hundertern zaehlt. Eine Grenze im Code waere fuer das + * eine Projekt richtig und fuer jedes andere falsch. + * + * @param list> $ranked + * @return list> + */ + private function autoPhases(array $ranked): array + { + if ($ranked === []) { + return []; + } + + $ranks = []; + foreach ($ranked as $row) { + $ranks[] = (int)$row['roadmap_rank']; + } + sort($ranks); + + $count = count($ranks); + + // Zu wenige Eintraege fuer eine Aufteilung: eine Spalte, fertig. + if ($count < self::MIN_AUTO_PHASE_SIZE * 2) { + return [[ + 'key' => 'p0', + 'label' => self::AUTO_LABELS[0][0], + 'subtitle' => self::AUTO_LABELS[0][1], + 'rank_from' => $ranks[0], + 'rank_to' => $ranks[$count - 1], + 'kind' => 'now', + ]]; + } + + // Luecken sammeln: Position i heisst "zwischen Index i und i+1". + $gaps = []; + for ($i = 0; $i < $count - 1; $i++) { + $gaps[] = ['at' => $i, 'size' => $ranks[$i + 1] - $ranks[$i]]; + } + + usort($gaps, static fn ($a, $b) => $b['size'] <=> $a['size']); + + // Groesste Luecken der Reihe nach annehmen, solange auf beiden Seiten + // genug uebrig bleibt. + $cuts = []; + foreach ($gaps as $gap) { + if (count($cuts) >= self::MAX_AUTO_PHASES - 1) { + break; + } + if ($gap['size'] <= 0) { + continue; + } + + $candidate = $cuts; + $candidate[] = $gap['at']; + sort($candidate); + + if ($this->segmentsLargeEnough($candidate, $count)) { + $cuts = $candidate; + } + } + + sort($cuts); + + $phases = []; + $start = 0; + + foreach (array_merge($cuts, [$count - 1]) as $index => $cutAt) { + $labels = self::AUTO_LABELS[min($index, count(self::AUTO_LABELS) - 1)]; + + $phases[] = [ + 'key' => 'p' . $index, + 'label' => $labels[0], + 'subtitle' => $labels[1], + 'rank_from' => $ranks[$start], + 'rank_to' => $ranks[$cutAt], + 'kind' => $index === 0 ? 'now' : 'plan', + ]; + + $start = $cutAt + 1; + } + + return $phases; + } + + /** + * Haben alle Abschnitte die Mindestgroesse? + * + * @param list $cuts Aufsteigend sortierte Schnittpositionen + */ + private function segmentsLargeEnough(array $cuts, int $count): bool + { + $start = 0; + foreach (array_merge($cuts, [$count - 1]) as $cutAt) { + if (($cutAt - $start + 1) < self::MIN_AUTO_PHASE_SIZE) { + return false; + } + $start = $cutAt + 1; + } + + return true; + } + + /** + * In welche Spalte gehoert dieser Rang? + * + * @param array{list:list>,guessed:bool} $phases + */ + private function phaseFor(array $phases, int $rank): string + { + $last = 'p0'; + + foreach ($phases['list'] as $phase) { + if ($rank >= $phase['rank_from'] && $rank <= $phase['rank_to']) { + return (string)$phase['key']; + } + if ($rank > $phase['rank_to']) { + $last = (string)$phase['key']; + } + } + + // Ein Rang ausserhalb aller gepflegten Bereiche - er faellt in die + // letzte Spalte, die noch unter ihm liegt, sonst in die erste. Ein + // Kaertchen darf nicht verschwinden, nur weil die Grenzen nicht + // nachgezogen wurden. + return $phases['list'] === [] ? 'p0' : $last; + } + + /** + * Die Spaltenliste fuer die Darstellung, mit den beiden festen Raendern. + * + * @param array{list:list>,guessed:bool} $phases + * @return list> + */ + private function phaseList(array $phases, int $doneTotal, int $doneShown, int $thinking): array + { + $out = [[ + 'key' => 'fundament', + 'label' => 'Fundament', + 'subtitle' => $doneTotal > $doneShown + ? sprintf('Traegt heute (%d von %d gezeigt)', $doneShown, $doneTotal) + : 'Traegt heute', + 'kind' => 'done', + 'rank_from' => null, + 'rank_to' => null, + ]]; + + foreach ($phases['list'] as $phase) { + $phase['subtitle'] = $phase['subtitle'] ?? null; + $phase['hint'] = sprintf('Rang %d-%d', $phase['rank_from'], $phase['rank_to']); + $out[] = $phase; + } + + $out[] = [ + 'key' => 'ueberlegt', + 'label' => 'Wird ueberlegt', + 'subtitle' => 'Zurueckgestelltes, Ideen, noch nicht Eingeordnetes', + 'kind' => 'think', + 'rank_from' => null, + 'rank_to' => null, + 'hint' => sprintf('%d ohne festen Platz', $thinking), + ]; + + return $out; + } + + // ================================================================== + // Spuren und Kaertchen + // ================================================================== + + /** + * Die Spuren: Themenbereiche in ihrer gepflegten Reihenfolge. + * + * @param list> $items + * @return list> + */ + private function lanes(string $projectSlug, array $items): array + { + $used = []; + foreach ($items as $item) { + $used[(string)$item['lane']] = true; + } + + $lanes = []; + + try { + $stmt = $this->db->prepare(' + SELECT epic, title, summary, color, highlight, sort_order + FROM roadmap_epics + WHERE project_slug = :slug + ORDER BY sort_order ASC, epic ASC + '); + $stmt->execute([':slug' => $projectSlug]); + $rows = $stmt->fetchAll() ?: []; + } catch (\Throwable $e) { + // Vor Migration 019 fehlen color und highlight. + $stmt = $this->db->prepare(' + SELECT epic, title, summary, sort_order + FROM roadmap_epics + WHERE project_slug = :slug + ORDER BY sort_order ASC, epic ASC + '); + $stmt->execute([':slug' => $projectSlug]); + $rows = $stmt->fetchAll() ?: []; + } + + foreach ($rows as $row) { + $epic = (string)$row['epic']; + if (!isset($used[$epic])) { + continue; // leere Spuren nehmen nur Platz weg + } + + $lanes[] = [ + 'epic' => $epic, + 'title' => (string)$row['title'], + 'summary' => $row['summary'] !== null ? (string)$row['summary'] : null, + 'color' => isset($row['color']) && $row['color'] !== null ? (string)$row['color'] : null, + 'highlight' => (int)($row['highlight'] ?? 0) === 1, + ]; + unset($used[$epic]); + } + + // Was uebrig bleibt, hat keinen gepflegten Bereich - und genau das + // soll man sehen. Eine eigene Spur am Ende, statt es unter einen + // fremden Titel zu mischen. + if (isset($used[''])) { + $lanes[] = [ + 'epic' => '', + 'title' => 'Ohne Themenbereich', + 'summary' => 'Noch keinem Bereich zugeordnet.', + 'color' => null, + 'highlight' => false, + ]; + unset($used['']); + } + + foreach (array_keys($used) as $epic) { + $lanes[] = [ + 'epic' => (string)$epic, + 'title' => (string)$epic, + 'summary' => 'Bereich ist nicht angelegt.', + 'color' => null, + 'highlight' => false, + ]; + } + + return $lanes; + } + + /** + * Ein Kaertchen. Alle Zustaende kommen aus vorhandenen Feldern. + * + * @param array $row + * @return array + */ + private function card(array $row, string $phase): array + { + $status = (string)$row['status']; + $claimed = is_string($row['claimed_by'] ?? null) && $row['claimed_by'] !== ''; + + $leaseUntil = $row['lease_until'] ?? null; + $leaseExpired = false; + if ($claimed && is_string($leaseUntil) && $leaseUntil !== '') { + $leaseExpired = strtotime($leaseUntil . ' UTC') < time(); + } + + // Reihenfolge der Pruefungen ist die Aussage: Nachpruefung steht vor + // "in Arbeit", weil ein Item, dessen Fix nicht trug, keine laufende + // Arbeit ist, sondern eine offene Frage. + if (in_array($status, ['resolved', 'closed'], true)) { + $state = 'done'; + } elseif (in_array($status, ['awaiting_verification', 'in_verification', 'fix_insufficient'], true)) { + $state = 'verification'; + } elseif ($status === 'deferred') { + $state = 'deferred'; + } elseif ((string)$row['severity'] === 'idea') { + $state = 'idea'; + } elseif ($status === 'in_progress' || ($claimed && !$leaseExpired)) { + $state = 'working'; + } else { + $state = 'open'; + } + + $created = strtotime((string)$row['created_at'] . ' UTC'); + $isNew = $created !== false && (time() - $created) < (self::NEW_DAYS * 86400); + + return [ + 'id' => (int)$row['id'], + 'phase' => $phase, + 'lane' => (string)($row['epic'] ?? ''), + // Fremdtext von fremden Maschinen. Er geht hier unveraendert + // durch und wird erst beim Rendern escaped - nicht davor, sonst + // stuende die Maskierung im JSON und ein zweiter Verbraucher + // bekaeme sie doppelt. + 'title' => (string)$row['title'], + 'state' => $state, + 'status' => $status, + 'status_label' => Roadmap::STATUS_LABELS[$status] ?? $status, + 'severity' => (string)$row['severity'], + 'type' => (string)$row['type'], + 'rank' => $row['roadmap_rank'] !== null ? (int)$row['roadmap_rank'] : null, + 'target_build' => $row['target_build'] !== null ? (string)$row['target_build'] : null, + 'defer_trigger' => $row['defer_trigger'] !== null ? (string)$row['defer_trigger'] : null, + 'doc_anchor' => $row['doc_anchor'] !== null ? (string)$row['doc_anchor'] : null, + 'claimed_by' => $claimed ? (string)$row['claimed_by'] : null, + 'lease_until' => $claimed && is_string($leaseUntil) ? $leaseUntil : null, + 'lease_expired' => $leaseExpired, + 'is_new' => $isNew, + 'updated_at' => (string)$row['updated_at'], + ]; + } + + /** + * @param list> $items + */ + private function countHighlighted(string $projectSlug, array $items): int + { + $highlighted = []; + + try { + $stmt = $this->db->prepare(' + SELECT epic FROM roadmap_epics + WHERE project_slug = :slug AND highlight = 1 + '); + $stmt->execute([':slug' => $projectSlug]); + foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) ?: [] as $epic) { + $highlighted[(string)$epic] = true; + } + } catch (\Throwable $e) { + return 0; + } + + $count = 0; + foreach ($items as $item) { + if ($item['state'] !== 'done' && isset($highlighted[(string)$item['lane']])) { + $count++; + } + } + + return $count; + } + +} diff --git a/tests/e2e/run.py b/tests/e2e/run.py index 3647a37..ae0fc42 100644 --- a/tests/e2e/run.py +++ b/tests/e2e/run.py @@ -44,6 +44,7 @@ import suite_sperre # noqa: E402,F401 import suite_kern # noqa: E402,F401 import suite_bugtracker # noqa: E402,F401 import suite_dienste # noqa: E402,F401 +import suite_tafel # noqa: E402,F401 def main(): diff --git a/tests/e2e/suite_tafel.py b/tests/e2e/suite_tafel.py new file mode 100644 index 0000000..e3b661f --- /dev/null +++ b/tests/e2e/suite_tafel.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python3 +""" +Roadmap-Tafel und zweite Stufe der Anmeldung. + +Die Tafel wird ueber ihre Struktur geprueft und nicht ueber ihr Aussehen: +Der Server liefert Spalten, Spuren und Zustaende fertig aus, und genau das +ist der Vertrag, auf den sich WebUI, CLI und ein spaeterer Export stuetzen. + +Die WebAuthn-Zeremonie selbst laeuft hier nicht mit. Sie braucht einen +Authenticator, der wirklich unterschreibt - dafuer gibt es ein eigenes +Werkzeug ausserhalb der Suite, weil es eine Krypto-Bibliothek verlangt und +diese Suite mit der Standardbibliothek auskommen soll. Was hier geprueft +wird, ist der Rahmen: dass die Endpunkte ohne Zwischenzustand nichts +herausgeben und dass ein sitzungsloser Aufruf abgewiesen wird. +""" + +import time + +from harness import (test, erwarte, erwarte_status, erwarte_erfolg, + erwarte_fehler, ueberspringen) + +PROJEKT = 'myapp' # existiert nach dem Zuruecksetzen, siehe sql/schema.sql + + +def _item_id(resp): + for name in ('item_id', 'id'): + wert = resp.feld(name) + if isinstance(wert, int) and wert > 0: + return wert + return resp.feld('item', 'id') + + +def _melden(ctx, titel, ref): + return ctx.client.post('/api/bugtracker/v1/report', body={ + 'project_slug': PROJEKT, + 'title': titel, + 'description': 'Angelegt von tests/e2e (Tafel). Darf jederzeit geloescht werden.', + 'severity': 'medium', + 'environment': 'testing', + 'client_ref': ref, + }) + + +# --------------------------------------------------------------------------- +# Aufbau der Tafel +# --------------------------------------------------------------------------- + +@test('tafel', 'Tafel liefert Spalten, Spuren und Kaertchen') +def _(ctx): + r = ctx.client.get('/api/bugtracker/v1/roadmap?action=board&project=' + PROJEKT) + erwarte_erfolg(r) + + for feld in ('phases', 'lanes', 'items', 'stats', 'meta'): + erwarte(r.feld(feld) is not None, "Feld '%s' fehlt in der Antwort" % feld) + + ctx['tafel'] = r.json + + +@test('tafel', 'Die beiden festen Randspalten sind immer da') +def _(ctx): + """ + "Fundament" links und "Wird ueberlegt" rechts haengen am Status und nicht + am Rang. Sie muessen auch dann stehen, wenn dazwischen gar nichts ist - + sonst haette ein frisches Projekt keine Tafel, sondern eine leere Flaeche. + """ + tafel = ctx.get('tafel') + if tafel is None: + ueberspringen('tafel') + + schluessel = [p['key'] for p in tafel['phases']] + erwarte(schluessel[0] == 'fundament', "Erste Spalte ist '%s'" % schluessel[0]) + erwarte(schluessel[-1] == 'ueberlegt', "Letzte Spalte ist '%s'" % schluessel[-1]) + + +@test('tafel', 'Geratene Spaltengrenzen sind als solche gekennzeichnet') +def _(ctx): + """ + Ohne gepflegte roadmap_phases schneidet die Tafel selbst. Das darf sie - + aber sie muss es sagen. Eine geratene Grenze, die wie eine Entscheidung + aussieht, ist schlimmer als gar keine. + """ + tafel = ctx.get('tafel') + if tafel is None: + ueberspringen('tafel') + + erwarte('phases_guessed' in tafel['meta'], + "meta.phases_guessed fehlt - die Tafel sagt nicht, woher ihre Grenzen kommen") + + +@test('tafel', 'Erledigte Vorhaben werden gekappt') +def _(ctx): + """ + Bei ueber hundert erledigten Eintraegen erschlaegt die Fundamentspalte + die ganze Tafel. done_limit muss greifen, und die Antwort muss die + Gesamtzahl trotzdem nennen - sonst sieht gekappt wie vollstaendig aus. + """ + r = ctx.client.get('/api/bugtracker/v1/roadmap?action=board&project=%s&done_limit=1' % PROJEKT) + erwarte_erfolg(r) + + meta = r.feld('meta', default={}) + erwarte('done_total' in meta and 'done_shown' in meta, + "meta nennt done_shown/done_total nicht") + erwarte(meta['done_shown'] <= 1, + "done_limit=1 zeigt %s erledigte Eintraege" % meta['done_shown']) + + +@test('tafel', 'Kaertchen tragen ihren Zustand und ihre Spalte') +def _(ctx): + tafel = ctx.get('tafel') + if tafel is None: + ueberspringen('tafel') + + spalten = {p['key'] for p in tafel['phases']} + erlaubt = {'done', 'verification', 'working', 'deferred', 'idea', 'open'} + + for karte in tafel['items']: + erwarte(karte['state'] in erlaubt, + "Unbekannter Zustand '%s' an #%s" % (karte['state'], karte['id'])) + erwarte(karte['phase'] in spalten, + "Karte #%s zeigt auf die Spalte '%s', die es nicht gibt" + % (karte['id'], karte['phase'])) + + +@test('tafel', 'Nachpruefung wird nicht als erledigt gezeichnet') +def _(ctx): + """ + Der Fall, den die Bastelloesung im Projekt clawddotnet von Hand als + "Status pruefen" danebenschreiben musste: Ein Fix, der nicht getragen + hat, ist keine Erledigung. + """ + r = _melden(ctx, 'Tafel-Testfall: wartet auf Nachpruefung', 'e2e-tafel-verify-%d' % time.time()) + erwarte_status(r, 200, 201) + item = _item_id(r) + + ctx.client.post('/api/bugtracker/v1/roadmap?action=set', + body={'id': item, 'epic': 'T', 'roadmap_rank': 10}) + ctx.client.post('/api/bugtracker/v1/manage/?action=status', + body={'id': item, 'status': 'awaiting_verification'}) + + r = ctx.client.get('/api/bugtracker/v1/roadmap?action=board&project=' + PROJEKT) + erwarte_erfolg(r) + + treffer = [k for k in r.feld('items', default=[]) if k['id'] == item] + erwarte(treffer, "Item #%d taucht auf der Tafel nicht auf" % item) + erwarte(treffer[0]['state'] == 'verification', + "Zustand ist '%s' statt 'verification'" % treffer[0]['state']) + + ctx['tafel_item'] = item + + +@test('tafel', 'Vorhaben ohne Rang landen in "Wird ueberlegt"') +def _(ctx): + """ + Ein Vorhaben ohne Rang ist nicht eingeordnet. Es ans Ende einer Spalte zu + sortieren liesse es eingeordnet aussehen. + """ + r = _melden(ctx, 'Tafel-Testfall: noch nicht eingeordnet', 'e2e-tafel-ohne-%d' % time.time()) + erwarte_status(r, 200, 201) + item = _item_id(r) + + ctx.client.post('/api/bugtracker/v1/roadmap?action=set', body={'id': item, 'epic': 'T'}) + + r = ctx.client.get('/api/bugtracker/v1/roadmap?action=board&project=' + PROJEKT) + treffer = [k for k in r.feld('items', default=[]) if k['id'] == item] + erwarte(treffer, "Item #%d taucht auf der Tafel nicht auf" % item) + erwarte(treffer[0]['phase'] == 'ueberlegt', + "Ungereihtes Item steht in Spalte '%s'" % treffer[0]['phase']) + + +@test('tafel', 'Tafel verlangt ein einzelnes Projekt') +def _(ctx): + """ + Die Phasen werden aus roadmap_rank geschnitten, und der Rang gilt je + Projekt. Zwei Projekte uebereinanderzulegen ergaebe Spalten, die in + keinem von beiden stimmen. + """ + r = ctx.client.get('/api/bugtracker/v1/roadmap?action=board&project=all') + erwarte_fehler(r, http=400) + + +# --------------------------------------------------------------------------- +# Einsortieren - der Befund aus #177 +# --------------------------------------------------------------------------- + +@test('tafel', 'rank_between setzt den Eintrag zwischen seine Nachbarn') +def _(ctx): + stempel = int(time.time()) + ids = [] + for i, rang in enumerate((100, 200, 300)): + r = _melden(ctx, 'Tafel-Testfall: Rangnachbar %d' % i, 'e2e-tafel-rang-%d-%d' % (stempel, i)) + erwarte_status(r, 200, 201) + item = _item_id(r) + ctx.client.post('/api/bugtracker/v1/roadmap?action=set', + body={'id': item, 'epic': 'T', 'roadmap_rank': rang}) + ids.append(item) + + r = ctx.client.post('/api/bugtracker/v1/roadmap?action=rank_between', + body={'id': ids[2], 'prev_id': ids[0], 'next_id': ids[1]}) + erwarte_erfolg(r) + + rang = r.feld('rank') + erwarte(100 < rang < 200, "Rang %s liegt nicht zwischen 100 und 200" % rang) + erwarte(r.feld('renumbered') is False, + "Ohne Not wurde neu nummeriert - das ist genau das, wovor #177 warnt") + + ctx['tafel_ids'] = ids + + +@test('tafel', 'Kein Platz: einmal neu nummerieren, und es steht in der Antwort') +def _(ctx): + """ + Zwischen zwei direkt benachbarten Raengen ist kein Platz. Dann darf neu + nummeriert werden - aber nicht stillschweigend. + """ + ids = ctx.get('tafel_ids') + if not ids: + ueberspringen('tafel_ids') + + # Zwei Eintraege direkt nebeneinander setzen + ctx.client.post('/api/bugtracker/v1/roadmap?action=set', + body={'id': ids[0], 'roadmap_rank': 500}) + ctx.client.post('/api/bugtracker/v1/roadmap?action=set', + body={'id': ids[1], 'roadmap_rank': 501}) + + r = ctx.client.post('/api/bugtracker/v1/roadmap?action=rank_between', + body={'id': ids[2], 'prev_id': ids[0], 'next_id': ids[1]}) + erwarte_erfolg(r) + erwarte(r.feld('renumbered') is True, + "Zwischen 500 und 501 war Platz? renumbered=%s" % r.feld('renumbered')) + + # Und danach liegt der Eintrag wirklich dazwischen. + r = ctx.client.get('/api/bugtracker/v1/roadmap?action=board&project=' + PROJEKT) + raenge = {k['id']: k['rank'] for k in r.feld('items', default=[])} + erwarte(raenge.get(ids[0]) < raenge.get(ids[2]) < raenge.get(ids[1]), + "Nach dem Neunummerieren: %s / %s / %s" + % (raenge.get(ids[0]), raenge.get(ids[2]), raenge.get(ids[1]))) + + +@test('tafel', 'move tauscht mit dem projektweiten Nachbarn (#177)') +def _(ctx): + """ + Der Nachbar wird projektweit gesucht, nicht im eigenen Bereich. Zuvor + sprang ein Eintrag bei einem Klick ueber beliebig viele fremde Vorhaben + hinweg, obwohl die Geste "eine Position" hiess. + """ + r = ctx.client.get('/api/bugtracker/v1/roadmap?action=board&project=' + PROJEKT) + erwarte_erfolg(r) + + gereiht = sorted( + [k for k in r.feld('items', default=[]) if k['rank'] is not None and k['state'] != 'done'], + key=lambda k: (k['rank'], k['id']) + ) + if len(gereiht) < 3: + ueberspringen('zu wenige gereihte Eintraege') + + mitte, davor = gereiht[1], gereiht[0] + + r = ctx.client.post('/api/bugtracker/v1/roadmap?action=move', + body={'id': mitte['id'], 'direction': 'up'}) + erwarte_erfolg(r) + + r = ctx.client.get('/api/bugtracker/v1/roadmap?action=board&project=' + PROJEKT) + raenge = {k['id']: k['rank'] for k in r.feld('items', default=[])} + + erwarte(raenge.get(mitte['id']) == davor['rank'] and raenge.get(davor['id']) == mitte['rank'], + "Raenge nicht getauscht: #%s %s->%s, #%s %s->%s" + % (mitte['id'], mitte['rank'], raenge.get(mitte['id']), + davor['id'], davor['rank'], raenge.get(davor['id']))) + + +@test('tafel', 'Themenbereich traegt Farbe und Hervorhebung') +def _(ctx): + r = ctx.client.post('/api/bugtracker/v1/roadmap?action=epic', body={ + 'project_slug': PROJEKT, 'epic': 'T', 'title': 'Tafel-Testbereich', + 'summary': 'Von tests/e2e angelegt.', 'sort_order': 900, + 'color': '#F6465D', 'highlight': 1, + }) + erwarte_erfolg(r) + + r = ctx.client.get('/api/bugtracker/v1/roadmap?action=board&project=' + PROJEKT) + spuren = {l['epic']: l for l in r.feld('lanes', default=[])} + erwarte('T' in spuren, "Spur 'T' fehlt auf der Tafel") + erwarte(spuren['T']['highlight'] is True, "Hervorhebung nicht uebernommen") + erwarte(spuren['T']['color'] == '#F6465D', "Farbe ist '%s'" % spuren['T']['color']) + + +@test('tafel', 'Ein Aufruf ohne diese Felder loescht Farbe und Hervorhebung nicht') +def _(ctx): + """ + Das bestehende Formular im WebUI kennt color und highlight nicht. Wuerde + ein Aufruf ohne sie die Werte zuruecksetzen, waere jede Titelkorrektur + ein stiller Verlust. + """ + r = ctx.client.post('/api/bugtracker/v1/roadmap?action=epic', body={ + 'project_slug': PROJEKT, 'epic': 'T', 'title': 'Tafel-Testbereich (umbenannt)', + 'sort_order': 900, + }) + erwarte_erfolg(r) + + r = ctx.client.get('/api/bugtracker/v1/roadmap?action=board&project=' + PROJEKT) + spuren = {l['epic']: l for l in r.feld('lanes', default=[])} + erwarte(spuren.get('T', {}).get('highlight') is True, + "Hervorhebung ging bei einem Aufruf ohne das Feld verloren") + erwarte(spuren.get('T', {}).get('color') == '#F6465D', + "Farbe ging bei einem Aufruf ohne das Feld verloren") + + +# --------------------------------------------------------------------------- +# Zweite Stufe der Anmeldung +# --------------------------------------------------------------------------- + +@test('tafel', 'WebAuthn-Endpunkt gibt ohne offene Anmeldung nichts heraus') +def _(ctx): + """ + login_options laeuft im Zwischenzustand zwischen Passwort und zweitem + Faktor. Ohne diesen Zustand darf der Endpunkt nicht verraten, ob es ein + Konto gibt oder welche Schluessel dazu hinterlegt sind. + """ + r = ctx.client.post('/api/auth/v1/?action=login_options', body={}, token=None) + erwarte_status(r, 401) + erwarte(r.feld('error', 'code') == 'no_pending_login', + "Fehlercode ist '%s'" % r.feld('error', 'code')) + + +@test('tafel', 'Registrieren verlangt eine Anmeldung') +def _(ctx): + r = ctx.client.post('/api/auth/v1/?action=register_options', body={}, token=None) + erwarte_status(r, 401) + + +@test('tafel', 'Unbekannte Zeremonie wird abgewiesen') +def _(ctx): + r = ctx.client.post('/api/auth/v1/?action=rundgang', body={}, token=None) + erwarte_status(r, 404) + erwarte(isinstance(r.feld('error', 'available'), list), + "Die Absage nennt die moeglichen Aktionen nicht") diff --git a/tests/server/testctl.php b/tests/server/testctl.php index a0a063f..69ef0b1 100644 --- a/tests/server/testctl.php +++ b/tests/server/testctl.php @@ -161,6 +161,21 @@ switch ($action) { $adminUser = (string)(Http::str('admin_user') ?? 'admin'); $adminPass = (string)(Http::str('admin_pass') ?? 'test02763!'); + // Registrierte Hardwarekeys des alten Kontos mitnehmen. Ohne das + // blieben verwaiste Zeilen stehen, und weil credential_id geraeteweit + // eindeutig ist, liesse sich derselbe Yubikey danach nicht wieder + // registrieren - er gehoerte noch einer Konto-ID, die es nicht mehr + // gibt. Ein Zuruecksetzen, nach dem der Hardwarekey verbrannt ist, + // waere schlimmer als keines. + try { + $db->prepare(' + DELETE FROM dc_user_credentials + WHERE user_id IN (SELECT id FROM dc_users WHERE username = :u) + ')->execute([':u' => $adminUser]); + } catch (Throwable $e) { + // Vor Migration 019 gibt es die Tabelle nicht. + } + $db->prepare('DELETE FROM dc_users WHERE username = :u')->execute([':u' => $adminUser]); $db->prepare(' INSERT INTO dc_users (username, password_hash, created_at)