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.
Quelle: docs/issues/007-auth-rollen-benutzer.md — dort gepflegt, hierher gespiegelt.
**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
- [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
- [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.
---
Quelle: `docs/issues/007-auth-rollen-benutzer.md` — dort gepflegt, hierher gespiegelt.
florian.wilde
added this to the v0.1.0 milestone 2026-09-24 20:44:40 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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-client6.x mit Discovery, UmgebungsvariablenOIDC_ISSUER,OIDC_CLIENT_ID,OIDC_CLIENT_SECRET,OIDC_NAME; Rücksprung-URL ausAPP_URLabgeleitet, damit es hinter dem Reverse Proxy stimmtsrc/lib/sitzung.ts:jose-signiertes Sitzungscookie (HS256,APP_SECRETmit mindestens 32 Zeichen erzwungen, Hinweis aufopenssl rand -base64 48), Nutzlast{benutzerId, email, name, herkunft}, Laufzeit 12 h@node-rs/argon2, nur aktiv beiAUTH_LOCAL_ENABLED=truegroups-Claim überOIDC_GROUP_ADMIN,OIDC_GROUP_EDITOR,OIDC_GROUP_TECHNICIAN,OIDC_GROUP_VIEWERaktualisieren; JIT-Provisioning unbekannter Benutzer beim ersten Loginsrc/server/auth/rollen.ts:RECHTEals eine Tabelle (satisfies Record<string, readonly Rolle[]>) plusdarf()undROLLEN_TEXTsrc/server/auth/sitzungspflicht.ts:angemeldet()(React-cache, ohne Weiterleitung) undrechtErforderlich(recht); Rolle und Aktiv-Status pro Request aus der Datenbank, nicht aus dem Token;sitzungenUngueltigAbfür Widerruf; reine, testbaresitzungBewerten()src/proxy.ts: Allowlist öffentlicher Pfade, Weiterleitung auf/anmelden?ziel=…/t/[tagCode])docs/screenshots/m2/Akzeptanzkriterien
AUTH_LOCAL_ENABLED=falseist das lokale Formular nicht vorhanden und der Endpunkt lehnt abLESERdarf nichts schreiben,TECHNIKERdarf keine Verwaltung,REDAKTEURdarf Vorlagen,ADMINalles/t/LZ-7K3F9Qohne Anmeldung führt nach dem Login genau dorthin zurückBeim Bauen aufgefallen
Präfixe (
/, nicht//, nicht/\)./<TAB>/fremd.examplebesteht alle drei — Browserentfernen Tabulator, Zeilenumbruch und Wagenrücklauf beim Verarbeiten einer URL, übrig bleibt
//fremd.exampleund damit ein fremder Host. Genau das Phishing, das die Prüfung abwehrensollte. Sie löst das Ziel jetzt gegen eine Wegwerf-Basis auf und vergleicht die Herkunft; eine
zweite Runde fängt zusätzlich
/..//fremd.exampleab, das beim Auflösen zu//fremd.examplewird. Prozentkodierte Steuerzeichen (
/%09/…) sind harmlos: sie werden nicht dekodiert undbleiben ein Pfad auf der eigenen Herkunft.
nextUrl.origintaugt nicht für Umleitungen. Die OIDC-Umleitung landete auflocalhost:3100, obwohl die Anfrage an127.0.0.1:3100ging. Hinter einem Reverse Proxy wäredas der interne Host. Alle Umleitungen gehen jetzt über
src/lib/adressen.tsund damit überAPP_URL.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: hiddenaufbodyhat meinen eigenen Mobil-Test blind gemacht. Auf derBenutzerseite war bei 390 px der Knopf „Setzen" abgeschnitten, die Prüfung
scrollWidth > clientWidthkonnte das aber nicht sehen. Dashiddenist raus, die Liste sindKarten statt einer Tabelle.
OIDC_GROUP_*-Variablen — eine stille Herabstufung des Administrators aufLESER, aus der esbei nur einem Administrator keinen Rückweg gegeben hätte. Ohne eingerichtete Zuordnung bleibt
die Rolle jetzt unangetastet.
email_verifiedwäre der Weg gewesen, den lokalenNotfall-Administrator zu übernehmen, wenn der Anbieter frei wählbare Adressen zulässt.
Zählabfrage vor der Änderung.
MELDUNGEN[schluessel]griff auf die Prototypenkette zu:?fehler=constructorergab eine500er-Seite — auf der einen Seite, die immer gehen muss. Jetzt eine
Map.(app)/blieb<Huelle>in der Seite stehen, die Hüllewurde also doppelt gerendert — zwei Kopfzeilen, das innere Menü ohne Benutzer. Im Screenshot
sichtbar gewesen und trotzdem übersehen; der unabhängige Review hat es gefunden.
Quelle:
docs/issues/007-auth-rollen-benutzer.md— dort gepflegt, hierher gespiegelt.