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
+1
View File
@@ -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():
+336
View File
@@ -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")