feat(auth): Anmeldung, Rollen und Benutzerverwaltung (#21)
OIDC mit Discovery, PKCE, state und nonce; lokaler Login mit argon2 als Notfallzugang; Rechte in einer Tabelle, Prüfung ausschließlich serverseitig, Rolle und Aktiv-Status pro Anfrage aus der Datenbank. Ein unabhängiger Review fand eine offene Weiterleitung: die Präfix-Prüfung des Rücksprungziels ließ /<TAB>/fremd.example durch, woraus der Browser //fremd.example macht. Das Ziel wird jetzt aufgelöst und die Herkunft verglichen. Dazu acht weitere Befunde, alle behoben. 274 Tests. Closes #7 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #21.
This commit is contained in:
+60
-2
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user