feat(auth): Anmeldung, Rollen und Benutzerverwaltung
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Anmeldung über einen generischen OIDC-Anbieter (Ziel authentik) mit Discovery,
PKCE, state und nonce, dazu ein lokaler Login mit argon2 als Notfallzugang. Die
Rechte stehen in einer Tabelle, die Prüfung liegt ausschließlich serverseitig,
und Rolle wie Aktiv-Status werden bei jeder Anfrage aus der Datenbank gelesen —
eine entzogene Rolle greift damit sofort, ein abgeschaltetes Konto kommt nicht
mit gültigem Cookie noch zwölf Stunden weiter.

Kein Auth.js: v5 steht dauerhaft auf Beta, das Projekt ist im Wartungsmodus und
sein Prisma-Adapter deckt Prisma 7 nicht ab. Stattdessen openid-client und jose,
wie in ProxLedger. Die API von openid-client 6 wurde vorher gegen die
Typdefinitionen im Paket geprüft, nicht aus dem Gedächtnis geschrieben.

Ein unabhängiger Review hat einen Blocker und acht weitere Befunde gefunden, alle
behoben. Der wichtigste war eine offene Weiterleitung: die Prüfung des
Rücksprungziels verglich Präfixe, und "/<TAB>/fremd.example" besteht alle drei
Bedingungen. 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.

Weitere Befunde: jede OIDC-Anmeldung hätte ohne konfigurierte Gruppen die Rolle
still auf LESER gesetzt, bei einem einzigen Administrator ohne Rückweg; die
Verknüpfung über die E-Mail-Adresse geschah ohne email_verified und wäre der Weg
gewesen, den lokalen Notfall-Administrator zu übernehmen; der letzte aktive
Administrator konnte sich selbst herabstufen.

Drei Fehler waren meine eigenen. Der Platzhalter-Hash gegen den Zeitkanal war
erfunden und hätte sofort geworfen statt Zeit zu kosten, die Zusage wäre also
falsch gewesen. nextUrl.origin lieferte im Test localhost statt 127.0.0.1 und
wäre hinter einem Reverse Proxy der interne Host; alle Umleitungen gehen jetzt
über APP_URL. Und overflow-x: hidden auf body hat die eigene Mobil-Prüfung blind
gemacht — auf der Benutzerseite war bei 390 px ein Knopf abgeschnitten, ohne dass
scrollWidth es zeigen konnte.

274 Tests, darunter die vollständige Rechtematrix, die Angriffe auf das
Rücksprungziel, die Trennung von Sitzungs- und Kurzzeit-Token und ein
struktureller Nachweis, dass jede Server Action mit einer Rechteprüfung beginnt.

Closes #7

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-25 03:33:48 +02:00
co-authored by Claude Opus 5
parent 7a0f875f55
commit ffb063b178
43 changed files with 3426 additions and 56 deletions
+60 -2
View File
@@ -9,11 +9,12 @@ als Unterpunkte mit Aufgabe und Dauer.
|---|---|---|
| #005 | M0 – Gerüst, Prozess, Design-Token | 1 h 25 min |
| #006 | M1 – Datenmodell | 1 h 10 min |
| | **Summe** | **2 h 35 min** |
| #007 | M2 – Auth, Rollen, Benutzer | 1 h 35 min |
| | **Summe** | **4 h 10 min** |
| Meilenstein | Tatsächlich | Status |
|---|---|---|
| v0.1.0 | 2 h 35 min | in Arbeit |
| v0.1.0 | 4 h 10 min | in Arbeit |
---
@@ -165,3 +166,60 @@ als Unterpunkte mit Aufgabe und Dauer.
- #007 Auth, Rollen, Benutzer: `openid-client` mit Discovery, `jose`-Sitzungscookie, lokaler
Login mit argon2, `RECHTE`-Tabelle, Login-Seite mit Rücksprung auf die ursprüngliche URL.
---
## 2026-09-25 · Sitzung 3 · 01:55–03:35
**Issue:** #007 (M2)
**Version:** 0.1.0
### Erledigt
- OIDC mit `openid-client` 6.8.8: Discovery, PKCE, `state`, `nonce`; API vorher gegen die
Typdefinitionen im Paket geprüft statt aus dem Gedächtnis geschrieben
- `jose`-Sitzungscookie, lokaler Login mit argon2, Rechtetabelle, JIT-Provisionierung
- Anmeldeseite, geschützter Bereich, Benutzerverwaltung, rollenabhängige Navigation
- Sub-Agent hat `rollen.ts` und `sitzung.ts` samt 85 Tests parallel gebaut (rund 6 min)
- Unabhängiger Review-Agent: ein Blocker, acht weitere Befunde, alle abgearbeitet
- **274 Tests** grün (vorher 106)
### Entscheidungen
- **Rücksprungziel wird aufgelöst, nicht mit Präfixen verglichen.** Die Präfix-Prüfung war eine
offene Weiterleitung: `/<TAB>/fremd.example` besteht `startsWith("/")`, ist kein `//` und kein
`/\` — Browser entfernen den Tabulator aber beim Verarbeiten der URL, übrig bleibt
`//fremd.example`. Das Auflösen gegen eine Wegwerf-Basis macht genau das, was der Browser tut,
und der Vergleich der Herkunft erschlägt alle Formen auf einmal.
- **Alle Umleitungen über `APP_URL`.** `nextUrl.origin` lieferte im Test `localhost` statt
`127.0.0.1`; hinter einem Reverse Proxy wäre es der interne Host. Neues Modul
`src/lib/adressen.ts`, die Herkunft der Anfrage ist nur noch Notnagel.
- **Ohne `OIDC_GROUP_*` bleibt die Rolle, wie sie ist.** Sonst wäre jede OIDC-Anmeldung eine
stille Herabstufung auf `LESER` — bei einem einzigen Administrator ohne Rückweg.
- **Verknüpfung über die Adresse nur bei `email_verified`.** Sonst wäre ein Anbieter mit frei
wählbaren Adressen der Weg, den lokalen Notfall-Administrator zu übernehmen.
- **Der letzte aktive Administrator ist geschützt**, gegen Herabstufung wie gegen Abschalten.
- **Kein `overflow-x: hidden` auf `body`.** Es sieht aus wie eine Lösung, verdeckt aber nur: zu
breiter Inhalt wird abgeschnitten statt erreichbar, und die Prüfung im Screenshot-Skript kann
ihn nicht mehr finden. Layouts müssen bei 390 px von sich aus passen.
- **`ROLLEN_TEXT` liegt in `src/lib`,** nicht in der Server-Schicht: eine Client-Komponente hätte
sonst die komplette Rechtetabelle ins Browser-Bündel gezogen.
### Probleme
- **Der Platzhalter-Hash gegen den Zeitkanal war erfunden** und hätte sofort geworfen statt Zeit
zu kosten. Die Zusage wäre damit schlicht falsch gewesen. Jetzt ein echter, lazy erzeugter
argon2-Hash; nachgemessen rund 1 ms Unterschied über je 60 Anfragen.
- **Beim Verschieben der Übersicht nach `(app)/` blieb `<Huelle>` in der Seite stehen.** Die Hülle
wurde doppelt gerendert — zwei Kopfzeilen, das innere Menü ohne Benutzer. Es war im Screenshot
zu sehen und ist mir trotzdem entgangen; gefunden hat es der Review-Agent. Ein Bild anzusehen
ersetzt nicht, gezielt nach dem zu suchen, was sich gerade geändert hat.
- **Prisma-Modelltypen kommen aus `@/generated/prisma/client`,** nicht aus `.../models` — dort
liegen nur die Hilfstypen der Abfragen. Steht jetzt in `CLAUDE.md`.
- **`networkidle` als Wartebedingung in Playwright** läuft bei einer Anwendung mit offenen
Verbindungen in den Timeout. `load` plus geladene Schriften ist deterministisch.
### Nächster Schritt
- #008 Betriebsmodus und Kunden: `betriebsmodus()`, `kundeErmitteln()`, Moduswechsel in beiden
Richtungen, Kundenverwaltung, Admin-Einstellungen.
+55 -19
View File
@@ -1,9 +1,9 @@
# #007 M2 – Auth, Rollen, Benutzer
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.1.0
**Labels:** phase-1, backend, frontend, security
**Geschätzt:** 8 h **Tatsächlich:** –
**Geschätzt:** 8 h **Tatsächlich:** 1 h 35 min
## Ziel
@@ -11,23 +11,59 @@ Anmeldung per generischem OIDC (Ziel authentik) und als Notfallzugang lokal per
## 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/`
- [x] `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
- [x] `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
- [x] Lokaler Login mit `@node-rs/argon2`, nur aktiv bei `AUTH_LOCAL_ENABLED=true`
- [x] 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
- [x] `src/server/auth/rollen.ts`: `RECHTE` als eine Tabelle (`satisfies Record<string, readonly Rolle[]>`) plus `darf()` und `ROLLEN_TEXT`
- [x] `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()`
- [x] `src/proxy.ts`: Allowlist öffentlicher Pfade, Weiterleitung auf `/anmelden?ziel=…`
- [x] Login-Seite im Design, OIDC-Knopf und lokales Formular, Rücksprung auf die ursprünglich angeforderte URL (nötig für `/t/[tagCode]`)
- [x] Admin → Benutzer: Liste mit Rolle und Aktiv-Status, lokale Benutzer anlegen wenn lokaler Login aktiv
- [x] 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
- [x] Anmeldung mit dem Seed-Admin gelingt, Abmelden beendet die Sitzung
- [x] Bei `AUTH_LOCAL_ENABLED=false` ist das lokale Formular nicht vorhanden und der Endpunkt lehnt ab
- [x] Tests fahren jede Rolle gegen jedes Recht: `LESER` darf nichts schreiben, `TECHNIKER` darf keine Verwaltung, `REDAKTEUR` darf Vorlagen, `ADMIN` alles
- [x] Eine Rollenänderung in der Datenbank greift ohne neue Anmeldung
- [x] Ein deaktivierter Benutzer wird mit bestehendem Cookie abgewiesen
- [x] Aufruf von `/t/LZ-7K3F9Q` ohne Anmeldung führt nach dem Login genau dorthin zurück
- [x] 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.
+2 -2
View File
@@ -11,7 +11,7 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #004 | [Phase 4: Aktionen, Connectors, REST-API](004-epic-phase-4.md) | offen | v0.4.0 | – | – |
| #005 | [M0 – Gerüst, Prozess, Design-Token](005-geruest-prozess-design.md) | erledigt | v0.1.0 | 6 h | 1 h 25 min |
| #006 | [M1 – Datenmodell](006-datenmodell.md) | erledigt | v0.1.0 | 5 h | 1 h 10 min |
| #007 | [M2 – Auth, Rollen, Benutzer](007-auth-rollen-benutzer.md) | offen | v0.1.0 | 8 h | – |
| #007 | [M2 – Auth, Rollen, Benutzer](007-auth-rollen-benutzer.md) | erledigt | v0.1.0 | 8 h | 1 h 35 min |
| #008 | [M3 – Betriebsmodus und Kunden](008-betriebsmodus-kunden.md) | offen | v0.1.0 | 6 h | – |
| #009 | [M4 – Vorlagen-Versionierung im Service](009-vorlagen-versionierung.md) | offen | v0.1.0 | 6 h | – |
| #010 | [M5 – Vorlagen-Editor](010-vorlagen-editor.md) | offen | v0.1.0 | 10 h | – |
@@ -24,4 +24,4 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #017 | [M12 – Seed der sieben Vorlagen](017-seed-sieben-vorlagen.md) | offen | v0.1.0 | 8 h | – |
| #018 | [M13 – E2E-Tests, Anleitung, PDF](018-e2e-anleitung-pdf.md) | offen | v0.1.0 | 14 h | – |
| #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | offen | v0.1.0 | 10 h | – |
| | **Summe** | | | **136 h** | **2 h 35 min** |
| | **Summe** | | | **136 h** | **4 h 10 min** |
Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB