# 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 ```bash 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).