Files
craftvia/docs/SEC2-AUTH-SELFSERVICE.md
T
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

155 lines
6.6 KiB
Markdown

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