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/<projekt>/item/<id>. 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 <noreply@anthropic.com>
This commit is contained in:
Deploymentcenter Bot
2026-09-20 18:16:51 +02:00
co-authored by Claude Opus 5
parent edea46e342
commit f0ed67ad29
22 changed files with 4996 additions and 70 deletions
+94 -5
View File
@@ -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'],