Files
Laufzettel/docs/issues/007-auth-rollen-benutzer.md
T
Florian WildeandClaude Opus 5 abacad1cb7
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
feat(vorlagen): Versionierung, Unveraenderlichkeit und JSON-Austausch
Eine veroeffentlichte Vorlagen-Version ist fuer immer unveraenderlich — daran
haengt, dass ein archivierter Lauf in zehn Jahren noch genau die Schritte zeigt,
mit denen er gestartet wurde. Die Regel steht deshalb zweimal: im Service, der
die verstaendliche Meldung gibt, und in der Datenbank, die die Garantie gibt.
Vier Trigger fangen Schreibzugriffe ab, die nicht durch den Service kommen —
Migrationen, psql, der Seed und ab Phase 4 die Connectoren — und versperren den
Rueckweg nach ENTWURF, der alles andere ausgehebelt haette.

- Service: anlegen, Kopfdaten bearbeiten, Entwurf als tiefe Kopie der letzten
  veroeffentlichten Version, speichern, veroeffentlichen, verwerfen,
  duplizieren, archivieren
- Hoechstens ein Entwurf je Vorlage, durchgesetzt vom partiellen Index; zwei
  gleichzeitige Klicks auf „Bearbeiten“ bekommen denselben Entwurf statt eines
  Fehlers
- Optimistisches Sperren ueber geaendertAm: wer auf einem ueberholten Stand
  speichert, wird abgewiesen statt die Arbeit des anderen spurlos zu ersetzen
- JSON-Export und -Import samt Format-Dokumentation; der Export ist nachsichtig,
  der Import streng
- src/lib/validierung.ts als einzige Zod-Quelle mit deutschen Meldungen, per
  ESLint-Regel erzwungen

Der unabhaengige Review fand drei Blocker, alle an der Mandantengrenze: der
Suchfilter brachte ein zweites OR mit und ueberschrieb die Kundenbedingung,
Historie und Aufbau einer Version filterten gar nicht. Zu queries/vorlagen.ts
gab es keinen Test — jetzt gibt es tests/vorlagen-sicht.test.ts.

428 Tests gruen (vorher 336).

Schliesst #009

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 08:09:21 +02:00

5.3 KiB
Raw Blame History

#007 M2 – Auth, Rollen, Benutzer

Status: erledigt Meilenstein: v0.1.0 Labels: phase-1, backend, frontend, security Geschätzt: 8 h Tatsächlich: 1 h 35 min

Ziel

Anmeldung per generischem OIDC (Ziel authentik) und als Notfallzugang lokal per E-Mail und Passwort, dazu die serverseitige Rechteprüfung, auf der alle weiteren Services aufsetzen.

Aufgaben

  • src/server/auth/oidc.ts: openid-client 6.x mit Discovery, Umgebungsvariablen OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_NAME; Rücksprung-URL aus APP_URL abgeleitet, damit es hinter dem Reverse Proxy stimmt
  • src/lib/sitzung.ts: jose-signiertes Sitzungscookie (HS256, APP_SECRET mit mindestens 32 Zeichen erzwungen, Hinweis auf openssl rand -base64 48), Nutzlast {benutzerId, email, name, herkunft}, Laufzeit 12 h
  • Lokaler Login mit @node-rs/argon2, nur aktiv bei AUTH_LOCAL_ENABLED=true
  • Rolle bei jedem OIDC-Login aus dem groups-Claim über OIDC_GROUP_ADMIN, OIDC_GROUP_EDITOR, OIDC_GROUP_TECHNICIAN, OIDC_GROUP_VIEWER aktualisieren; JIT-Provisioning unbekannter Benutzer beim ersten Login
  • src/server/auth/rollen.ts: RECHTE als eine Tabelle (satisfies Record<string, readonly Rolle[]>) plus darf() und ROLLEN_TEXT
  • src/server/auth/sitzungspflicht.ts: angemeldet() (React-cache, ohne Weiterleitung) und rechtErforderlich(recht); Rolle und Aktiv-Status pro Request aus der Datenbank, nicht aus dem Token; sitzungenUngueltigAb für Widerruf; reine, testbare sitzungBewerten()
  • src/proxy.ts: Allowlist öffentlicher Pfade, Weiterleitung auf /anmelden?ziel=…
  • Login-Seite im Design, OIDC-Knopf und lokales Formular, Rücksprung auf die ursprünglich angeforderte URL (nötig für /t/[tagCode])
  • Admin → Benutzer: Liste mit Rolle und Aktiv-Status, lokale Benutzer anlegen wenn lokaler Login aktiv
  • Screenshots Login und Benutzerliste in docs/screenshots/m2/

Akzeptanzkriterien

  • Anmeldung mit dem Seed-Admin gelingt, Abmelden beendet die Sitzung
  • Bei AUTH_LOCAL_ENABLED=false ist das lokale Formular nicht vorhanden und der Endpunkt lehnt ab
  • Tests fahren jede Rolle gegen jedes Recht: LESER darf nichts schreiben, TECHNIKER darf keine Verwaltung, REDAKTEUR darf Vorlagen, ADMIN alles
  • Eine Rollenänderung in der Datenbank greift ohne neue Anmeldung
  • Ein deaktivierter Benutzer wird mit bestehendem Cookie abgewiesen
  • Aufruf von /t/LZ-7K3F9Q ohne Anmeldung führt nach dem Login genau dorthin zurück
  • Passwörter stehen nur als argon2-Hash in der Datenbank, nie im Klartext und nie in einer API-Antwort

Beim Bauen aufgefallen

  • Offene Weiterleitung gefunden und geschlossen. Die Prüfung des Rücksprungziels verglich Präfixe (/, nicht //, nicht /\). /<TAB>/fremd.example besteht alle drei — Browser entfernen Tabulator, Zeilenumbruch und Wagenrücklauf beim Verarbeiten einer URL, übrig bleibt //fremd.example und damit ein fremder Host. Genau das Phishing, das die Prüfung abwehren sollte. Sie löst das Ziel jetzt gegen eine Wegwerf-Basis auf und vergleicht die Herkunft; eine zweite Runde fängt zusätzlich /..//fremd.example ab, das beim Auflösen zu //fremd.example wird. Prozentkodierte Steuerzeichen (/%09/…) sind harmlos: sie werden nicht dekodiert und bleiben ein Pfad auf der eigenen Herkunft.
  • nextUrl.origin taugt nicht für Umleitungen. Die OIDC-Umleitung landete auf localhost:3100, obwohl die Anfrage an 127.0.0.1:3100 ging. Hinter einem Reverse Proxy wäre das der interne Host. Alle Umleitungen gehen jetzt über src/lib/adressen.ts und damit über APP_URL.
  • Der Platzhalter-Hash war erfunden und hätte beim Prüfen sofort geworfen statt Zeit zu kosten — die Zusage, dass die Antwortzeit nichts über vorhandene Konten verrät, wäre falsch gewesen. Jetzt ein echter argon2-Hash, einmal lazy erzeugt. Nachgemessen: rund 1 ms Unterschied über je 60 Anfragen, über ein Netz nicht auswertbar.
  • overflow-x: hidden auf body hat meinen eigenen Mobil-Test blind gemacht. Auf der Benutzerseite war bei 390 px der Knopf „Setzen“ abgeschnitten, die Prüfung scrollWidth > clientWidth konnte das aber nicht sehen. Das hidden ist raus, die Liste sind Karten statt einer Tabelle.
  • Jede OIDC-Anmeldung hätte die Rolle überschrieben, auch ohne konfigurierte OIDC_GROUP_*-Variablen — eine stille Herabstufung des Administrators auf LESER, aus der es bei nur einem Administrator keinen Rückweg gegeben hätte. Ohne eingerichtete Zuordnung bleibt die Rolle jetzt unangetastet.
  • Verknüpfung über die E-Mail-Adresse ohne email_verified wäre der Weg gewesen, den lokalen Notfall-Administrator zu übernehmen, wenn der Anbieter frei wählbare Adressen zulässt.
  • Der letzte aktive Administrator konnte sich selbst zum Leser machen. Jetzt abgelehnt, mit Zählabfrage vor der Änderung.
  • MELDUNGEN[schluessel] griff auf die Prototypenkette zu: ?fehler=constructor ergab eine 500er-Seite — auf der einen Seite, die immer gehen muss. Jetzt eine Map.
  • Beim Verschieben der Übersicht nach (app)/ blieb <Huelle> in der Seite stehen, die Hülle wurde also doppelt gerendert — zwei Kopfzeilen, das innere Menü ohne Benutzer. Im Screenshot sichtbar gewesen und trotzdem übersehen; der unabhängige Review hat es gefunden.