Files
craftvia/docs/SEC2-AUTH-SELFSERVICE.md
msolarczekandClaude Opus 5 c8e6f30a27
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s
Basis: Certvia dev@a48c5fb als Fundament für Craftvia
Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation
und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:05:39 +02:00

6.6 KiB

SEC2 — Passwort-Self-Service, E-Mail-Änderung, Sessions

Branch dev-sec2-auth-selfservice (Basis dev-sec1-mail-smtp) · Grundlage: docs/sicherheit/SEC2-Auth-SelfService-Detail.md · Abhängigkeit: SEC1 (Templates password_reset, password_changed, email_change_verify).

Gilt durchgängig für beide Auth-Domänen: Mandanten-Nutzer (/login) und Plattform-Admins (/platform/login).


1. Token (AuthToken)

Eigenschaft Umsetzung
Erzeugung 32 Byte CSPRNG, base64url
Speicherung nur SHA-256-Hash (token_hash, unique) — das Rohtoken existiert ausschließlich im Link
Vergleich konstante Zeit (timingSafeEqual)
Gültigkeit 60 Minuten (Reset und E-Mail-Verify)
Single-use usedAt per bedingtem updateMany (usedAt: null) → zwei parallele Einlösungen können nicht beide gewinnen
Neuanforderung entwertet offene Tokens desselben Typs
Domänen principalType = tenant_user | platform_admin; tenantId nur bei Mandanten-Nutzern (trägt die RLS)

Der Lookup beim Einlösen läuft über den rohen prisma-Client: Der Nutzer ist zu diesem Zeitpunkt nicht angemeldet, es gibt also keinen Mandantenkontext. Die RLS-Policy bleibt als zweite Verteidigungslinie bestehen (Migration 20260730190000_auth_tokens_sessions).


2. Session-Invalidierung (sessionsValidAfter)

NextAuth v5 arbeitet hier mit JWT (stateless) — es gibt keine Session-Tabelle zum Leeren. Stattdessen trägt jedes Konto eine Versionsmarke:

  • sessionsValidAfter wird gesetzt → jedes JWT mit älterem iat gilt als ungültig.
  • Geprüft dort, wo der Kontostatus ohnehin aus der DB gelesen wird — ohne zusätzliche Abfrage: (app)/layout.tsx (Seitenaufrufe), moduleGuard (Mutationen), requirePlatformSession() (Plattform).
  • Ohne iat wird fail-closed entschieden.
Funktion Wirkung
invalidateSessions() alle Sitzungen — Passwort-Reset, E-Mail-Änderung, Deaktivierung
invalidateOtherSessions() Marke auf eine Sekunde vor jetzt → alle älteren Tokens fallen, die laufende Sitzung überlebt (Selbständerung)

Der Sekundenversatz ist kein Zufall: iat hat Sekundenauflösung. Ohne ihn würde sich ein Nutzer bei der eigenen Passwortänderung selbst aussperren.

Offen (P1, bewusst nicht in SEC2): „aktive Sitzungen anzeigen und einzeln abmelden" — dafür bräuchte es Session-Records statt reiner JWTs.


3. Abläufe

3.1 Passwort vergessen (/forgot-password, ?domain=platform)

  • Antwort immer identisch, unabhängig davon, ob das Konto existiert.
  • Rate-Limit: 5 pro Stunde, je IP und je Konto getrennt gezählt.
  • Reset nur bei aktivem, nicht gesperrtem Konto. Deaktivierte Konten bekommen weder Mail noch abweichende Meldung.
  • Mandantenseite: existiert die Adresse in mehreren Mandanten, ist sie nicht eindeutig auflösbar → kein Reset (nach außen nicht unterscheidbar).

3.2 Einlösung (/reset?token=…)

  • Die Seite prüft den Token nur; verbraucht wird er erst beim Absenden — sonst würde ein Link-Scanner im Mailserver den Link entwerten.
  • Ein Policy-Verstoß entwertet den Link nicht: geprüft wird vor dem Verbrauch, sonst wäre der Nutzer nach einem Tippfehler ausgesperrt.
  • Danach: Argon2id-Hash setzen, mustChangePassword löschen, Fehlversuchszähler und Sperre zurücksetzen, alle Sessions invalidieren, Bestätigungsmail, Audit (ohne Token).
  • Kein Auto-Login — der Nutzer meldet sich neu an.

3.3 Passwort selbst ändern (/account, /platform/profile)

Alt-Passwort verifizieren (generische Fehlermeldung, Rate-Limit), Policy prüfen, danach andere Sitzungen abmelden — die aktuelle bleibt. Bestätigungsmail + Audit.

3.4 E-Mail-Änderung (Double-Opt-in)

Alt-Passwort bestätigen → Verifizierungslink an die neue Adresse → Klick setzt die Adresse, entwertet alle Sessions (die Adresse ist die Login-Identität) und informiert die alte Adresse. Die Kollisionsprüfung läuft zweimal (bei Anforderung und bei Bestätigung), weil die Adresse zwischenzeitlich vergeben worden sein kann. Ob sie frei ist, wird nach außen nie gemeldet.


4. Rate-Limiting

src/server/rate-limit.ts, je Aktion getrennte Fenster, Schlüssel gehasht abgelegt (kein Klartext von Adressen oder IPs im Speicher):

Aktion Limit
Reset-Anfrage 5 / Stunde
Reset-Einlösung 10 / 15 Min
Alt-Passwort-Prüfung 10 / 15 Min
E-Mail-Änderung anfordern 5 / Stunde

Bewusste Einschränkung: Die Zähler liegen im Prozessspeicher, sind bei mehreren App-Instanzen also pro Instanz. Das ist vertretbar, weil der Limiter hier nur eine erste Bremse ist — die eigentlichen Garantien (single-use-Tokens, Enumeration-Neutralität, Konto-Lockout aus F-05) hängen nicht daran. Ein geteilter Redis-Zähler gehört zu SEC5.


5. Erreichbarkeit

/forgot-password, /reset und /verify-email sind in src/proxy.ts als öffentliche Pfade eingetragen — der Nutzer ist dort per Definition nicht angemeldet. Ihre Absicherung sind Rate-Limit, Enumeration-Neutralität und single-use-Tokens, nicht das Route-Gate.


6. Verifikation

npx tsx scripts/test-auth-selfservice.ts   # Token-, Limit- und Session-Eigenschaften
npx tsx scripts/test-reset-flow.ts         # End-to-End gegen ein Wegwerf-Konto

Nachgewiesen am 30.07.2026, alle Prüfungen grün:

  • Token liegt nur gehasht in der DB (SHA-256, 64 Hex), Rohtoken nie.
  • Single-use, Ablauf, Manipulation, Typ-Verwechslung und Neuanforderung greifen.
  • Rate-Limit blockt ab dem 6. Versuch, auch von wechselnder IP bei gleichem Konto.
  • Session-Marke: älteres JWT ungültig, neueres gültig, ohne iat fail-closed.
  • Kompletter Reset-Zyklus: Passwort gewechselt, altes ungültig, Sessions entwertet, Bestätigungsmail versendet, Audit ohne Token, zweite Einlösung abgewiesen, deaktiviertes Konto ohne Token.
  • Browser: /reset mit gültigem Link zeigt das Formular samt Mandanten-Policy, mit manipuliertem Link die generische Ablehnung.

Dazu npx tsc --noEmit → npm run lint → npm run build (Guard-Check).


7. Offene Punkte

  • Step-up-Re-Auth für die E-Mail-Änderung und weitere kritische Aktionen → SEC3 (aktuell reicht die Alt-Passwort-Bestätigung).
  • Breach-Check (HaveIBeenPwned) beim Setzen eines Passworts → SEC5; der Aufrufpunkt liegt in redeemPasswordReset/changePasswordSelf bereit.
  • Geteiltes Rate-Limit über Redis → SEC5.
  • Aktive Sitzungen anzeigen/einzeln abmelden (braucht Session-Records) → P1.
  • Wartungsjob für purgeExpiredTokens() ist implementiert, aber noch nicht eingeplant (passt in den SEC1-Scheduler).