M2 – Auth, Rollen, Benutzer #7

Closed
opened 2026-09-24 20:44:40 +00:00 by florian.wilde · 0 comments
Owner

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
florian.wilde added the phase-1backendfrontendsecurity labels 2026-09-24 20:44:40 +00:00
Sign in to join this conversation.