Files
Deploymentcenter/sql/migrations/019_webauthn_and_roadmap_board.sql
Deploymentcenter BotandClaude Opus 5 f0ed67ad29 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>
2026-09-20 18:16:51 +02:00

102 lines
5.1 KiB
SQL

-- 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;