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>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user