Skip to content

feat(auth): Zwei-Faktor-Pflicht (TOTP) für Admin-Rollen (#59) - #74

Open
pseidler89-sudo wants to merge 3 commits into
mainfrom
feat/59-admin-2fa
Open

feat(auth): Zwei-Faktor-Pflicht (TOTP) für Admin-Rollen (#59)#74
pseidler89-sudo wants to merge 3 commits into
mainfrom
feat/59-admin-2fa

Conversation

@pseidler89-sudo

Copy link
Copy Markdown
Owner

Schließt #59.

Admin-Konten hingen bisher allein am Magic-Link. Vor dem Betrieb mit mehreren Kommunen bekommen kommune_admin und super_admin einen zweiten Faktor.

Entscheidungen (Owner, 2026-08-05)

Frage Entscheidung
Erzwingung sanft mit Frist — beim ersten Admin-Zugriff beginnt eine Kulanzfrist von 14 Tagen, danach ist die Einrichtung zwingend
Wiederherstellung 10 Codes + Notfall-CLI
Step-up vor Rollenvergabe sowie Veröffentlichen/Freigeben

Zur Erzwingung: Es gibt genau einen Admin. Harte Erzwingung ab Deploy hätte ihn bei einem Fehler in der Einrichtung aus seiner eigenen Plattform ausgesperrt.

TOTP ohne Bibliothek — mit Beweis

RFC 6238 ist HMAC-SHA-1 plus dynamische Trunkierung: Anwendung vorhandener Primitive aus node:crypto, keine eigene Kryptografie. Statt einer Abhängigkeit im sicherheitskritischen Pfad ist die Implementierung gegen die offiziellen Testvektoren aus RFC 4226 (HOTP, Appendix D) und RFC 6238 (TOTP, Appendix B) verifiziert. Die Tests sind die Spezifikation — wer totp.ts ändert, muss sie grün halten.

SHA-1 ist hier korrekt und kein Versäumnis: Authenticator-Apps implementieren praktisch ausnahmslos den SHA-1-Default; die Sicherheit hängt an der Geheimhaltung des Secrets, nicht an Kollisionsresistenz.

Sicherheitseigenschaften

  • Secret verschlüsselt (AES-256-GCM, TOTP_ENC_KEY, fail-closed in Produktion wie IP_HASH_SALT). Anders als Session-Tokens kann es nicht gehasht werden — deshalb verschlüsselt: Ein DB-Dump allein hebelt den zweiten Faktor nicht aus.
  • Wiederverwendungssperre über users.totp_last_step. Ohne sie bliebe ein abgefangener Code bis zu 90 Sekunden gültig (Toleranzfenster ±1 Schritt).
  • Rate-Limit in eigenem Scope: 5 Versuche je 15 min und Konto, 20 je IP. Ohne Deckel wäre ein sechsstelliger Code mit genügend Versuchen erratbar.
  • Zweiter Faktor an der Session, nicht am Nutzer — ein zweiter Browser bekommt keinen Freifahrtschein, Logout entwertet ihn mit.
  • Wiederherstellungscodes per CAS entwertet (UPDATE … WHERE used_at IS NULL RETURNING), damit zwei parallele Einlösungen nicht beide gewinnen.
  • Notfallweg app/scripts/totp-reset.ts beendet alle Sessions des Kontos mit — wäre die Rücksetzung nötig, weil ein Konto übernommen wurde, bliebe die fremde Anmeldung sonst bestehen.

Durchsetzung zentral

Unter /admin liegen zwölf Seiten mit je eigenem Guard. Die Pflicht dort ein dreizehntes Mal zu wiederholen, hieße, sie beim nächsten Unterverzeichnis genau einmal zu vergessen — und diese eine Lücke wäre das ganze Feature. Das Gate sitzt deshalb in admin/layout.tsx und greift für jede Route darunter, auch für künftige. Server Actions sind unabhängig davon über requireAdminCtx abgesichert; das Layout ist die äußere von zwei Schichten, nicht die einzige. beobachter (reine Lesesicht) fallen nicht unter die Pflicht.

Eine Korrektur während der Arbeit

Step-up war zuerst so gebaut, dass es ohne eingerichtetes TOTP immer sperrt. Der Testlauf hat gezeigt, was das bedeutet hätte: Veröffentlichen, Freigeben und Rollenvergabe wären vom ersten Tag an hart gesperrt gewesen — genau das, was die Kulanzfrist verhindern soll. Jetzt gilt: TOTP aktiv → frischer Code nötig; kein TOTP → offen, solange die Frist läuft.

Geprüft

  • npm run lint (--max-warnings 0, a11y-Gate) und npm run typecheck sauber
  • Volle Suite gegen ephemere DB + Mailpit: 1231 von 1236 grün. Die 5 roten sind auf sauberem main identisch rot (gegengeprüft per Stash) — Umgebungsartefakte, keine Regression
  • 65 neue Tests: 44 für TOTP (inkl. RFC-Vektoren), 21+ für die Erzwingungs-Richtlinie
  • check.sh (deterministische Vorprüfung): PASS, 0 Befunde
  • Migration 0039 rein additiv — der Upgrade-Pfad-CI-Job über beide Tenants muss grün sein

Vor dem Deploy

TOTP_ENC_KEY in der Prod- und Staging-Umgebung setzen (openssl rand -base64 32). Ohne den Schlüssel wirft totp.ts in Produktion — fail-closed, absichtlich. Der Schlüssel darf danach nicht mehr getauscht werden, sonst sind alle eingerichteten Authenticator unbrauchbar.

🤖 Generated with Claude Code

https://claude.ai/code/session_018YNj1kkesDywuEGvNweBHM

pseidler89-sudo and others added 3 commits August 5, 2026 21:21
Admin-Konten hingen bisher allein am Magic-Link. Vor dem Betrieb mit mehreren
Kommunen bekommen kommune_admin und super_admin einen zweiten Faktor.

TOTP ohne Bibliothek, dafuer gegen die offiziellen Testvektoren aus RFC 4226
und RFC 6238 verifiziert (44 Tests) — HMAC-SHA-1 plus dynamische Trunkierung
ist Anwendung vorhandener Primitive, keine eigene Kryptografie, und die
RFC-Vektoren machen sie beweisbar statt bloss plausibel.

Erzwingung sanft mit Frist (Owner-Entscheid 2026-08-05): Beim ersten
Admin-Zugriff beginnt eine Kulanzfrist von 14 Tagen, danach ist die
Einrichtung zwingend. Es gibt genau einen Admin; harte Erzwingung ab Deploy
haette ihn bei einem Fehler aus seiner eigenen Plattform ausgesperrt.

Step-up vor folgenreichen Aktionen: Rollenvergabe/-entzug, Umfrage
veroeffentlichen/schliessen, Pruefung abschliessen, Digest freigeben/
veroeffentlichen. Waehrend der Kulanzfrist bleiben diese Aktionen offen —
sonst waere die "sanfte" Frist fuer genau die wichtigsten Faelle vom ersten
Tag an eine harte Sperre gewesen.

Sicherheit:
- Secret AES-256-GCM-verschluesselt (TOTP_ENC_KEY, fail-closed in prod wie
  IP_HASH_SALT) — ein DB-Dump allein hebelt den zweiten Faktor nicht aus.
- Wiederverwendungssperre ueber users.totp_last_step: derselbe Code gilt
  kein zweites Mal (sonst waere er bis zu 90 s lang gueltig).
- Rate-Limit 5 Versuche/15 min je Konto, 20 je IP, eigener Scope.
- 10 Wiederherstellungscodes, sha256-gehasht, per CAS entwertet.
- Notfallweg app/scripts/totp-reset.ts (Serverzugriff), beendet dabei alle
  Sessions des Kontos.
- Der zweite Faktor haengt an der SESSION, nicht am Nutzer: ein zweiter
  Browser bekommt keinen Freifahrtschein, Logout entwertet ihn mit.

Durchsetzung zentral in admin/layout.tsx statt in zwoelf Admin-Seiten
einzeln — die eine vergessene Seite waere sonst das ganze Feature. Server
Actions sind unabhaengig davon ueber requireAdminCtx abgesichert.

Migration 0039 rein additiv. Art.-15-Export gibt totp_confirmed_at aus, das
Secret bewusst nicht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018YNj1kkesDywuEGvNweBHM
Drei adversariale Lenses haben zwei BLOCKER gefunden. Beide waren echt.

BLOCKER 1 — rund 20 mutierende Admin-Actions hatten gar kein Zwei-Faktor-Gate.
Vier Dateien brachten eine eigene Kopie des Session-Lookups mit und liefen an
requireAdminCtx vorbei, darunter `einladen` — das ueber eine zweite Tuer genau
die Rolle vergibt, fuer die assignRole extra Step-up bekommen hatte. Der Satz im
Admin-Layout, die Actions seien "unabhaengig davon abgesichert", stimmte fuer
die Haelfte nicht. Alle lokalen Resolver sind entfernt; requireAdminCtx liefert
jetzt roleTypes mit, damit niemand mehr einen Grund hat, sich einen eigenen zu
bauen. Ein Waechter-Test bricht, sobald eine "use server"-Datei wieder einen
eigenen Session-Lookup enthaelt.

BLOCKER 2 — die Kulanzfrist war als Konto-Eigenschaft gebaut, nicht als
Migrationszustand. Jedes neu ernannte Admin-Konto haette dauerhaft 14 Tage ohne
zweiten Faktor bekommen, beliebig oft verlaengerbar ueber ein weiteres Konto;
und wer nur Server Actions aufruft, kam nie an der setzenden Stelle vorbei und
waere dauerhaft befreit gewesen. Die Frist traegt jetzt Migration 0040 einmalig
fuer die zum Rollout vorhandenen Admins ein; NULL heisst ab sofort keine Kulanz.

Weiter behoben:
- CAS statt Read-then-Write auf totp_last_step (Replay bei parallelen Requests).
- Fehlende Tenant-Filter in fuenf neuen Queries.
- Rate-Limit und Audit auf die Secret-Erzeugung; CAS-Guards gegen TOCTOU.
- Konto-Loeschung liess TOTP-Secret und Wiederherstellungscodes stehen (Art. 17).
  Der vorhandene Vollstaendigkeitstest hat das nicht bemerkt, weil er nur die
  Felder kannte, die er kannte — er geht jetzt vom Schema aus.
- Gerätewechsel (zweitFaktorNeuEinrichten) samt UI; die Fehlermeldung versprach
  bisher einen Weg, den es nicht gab.
- Info-Mail an die Kontoadresse bei Aktivierung, Code-Einloesung und Neu-
  Einrichtung — ohne Codes, ohne Secret, ohne ausloesenden Link.
- Verifizierer-Fläche: 2FA greift dort, WENN der Aufrufer Admin ist. Sonst haette
  ein ausgesperrter Admin weiter Wohnsitz-Verifizierungen vergeben koennen.
- requireRedaktionCtx statt eines dritten lokalen Musters.
- Notfall-Skript: 24 Stunden statt 14 Tagen Restfrist, korrekter Aufrufweg.
- TOTP_ENC_KEY in SELBST_HOSTING.md.

DEMO-MANDANT AUSGENOMMEN: Die Demo vergibt jedem Besucher auf Knopfdruck ein
ephemeres kommune_admin-Konto. Unter die Pflicht gestellt, muesste er eine
Authenticator-App einrichten, um sich eine Demo anzusehen — der Verwaltungs-
Rundgang waere tot. Die Ausnahme steht sichtbar in der Richtlinie, mit Warnung,
dass DEMO_TENANT_SLUG nie auf einen echten Mandanten zeigen darf.

Volle Suite: 1268 gruen, 5 rot — dieselben fuenf, die auch auf sauberem main rot
sind (Umgebungsartefakte, per Stash gegengeprueft).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018YNj1kkesDywuEGvNweBHM
Letzter offener Punkt aus Gate-B Runde 2. Die Gates gaben bei fehlender
frischer Bestaetigung ein Feld `zweiFaktor` zurueck, und es gab eine fertige,
getestete Hinweis-Komponente — nur warf jede Action das Feld weg, und die
Komponente war nirgends eingebunden. `grep "auth.zweiFaktor"` fand null Treffer.

Im Alltag hiess das: Wer 20 Minuten am Digest arbeitet und dann "Freigeben"
klickt, bekommt "Diese Aktion verlangt eine frische Bestaetigung mit Ihrem
Einmalcode." — ohne Link, ohne Knopf, und muss den Pfad raten. Das trifft jeden
Arbeitsblock ueber 15 Minuten.

Jetzt fuehren alle Step-up-Actions das Signal durch: Rollenvergabe/-entzug,
Einladungen, Konto-Sicherheit, Vier-Augen-Ernennungen, Umfrage-Lebenszyklus,
Digest-Freigabe und -Veroeffentlichung. Ergebnistypen nur OPTIONAL erweitert,
deshalb keine Testanpassung noetig.

Zwei modale Dialoge blieben bei Fehlern offen und haetten den Hinweis samt Link
hinter einer Fokusfalle unerreichbar gemacht. Sie schliessen jetzt GENAU DANN,
wenn `zweiFaktor` gesetzt ist; bei gewoehnlichen Fehlern bleiben sie stehen,
damit man direkt erneut bestaetigen kann. An zwei weiteren Stellen, wo das
Schliessen schon passierte, steht jetzt ein Kommentar, dass es eine Bedingung
des Hinweises ist und kein blosses Aufraeumen — sonst wird es wegoptimiert.

Bewusst NICHT verdrahtet: `einladungAnnehmen` und die Buerger-Actions in
polls/actions.ts. Dort entsteht gar kein Signal, weil kein Admin-Gate laeuft —
eine Attrappe waere schlimmer als die Luecke.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018YNj1kkesDywuEGvNweBHM
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant