L10b Betrieb & Aufräumen: Deploy – craftvia-worker, CI-Testjob, DEPLOY.md, Certvia-Doku archiviert

- docker-compose.coolify(.prebuilt).yml: Service craftvia-worker (Target worker, Chromium,
  shm_size 1gb, gleiche Härtung), Craftvia-Variablen für app und worker.
- Dockerfile: worker-Stage mit HOME=/home/app (Chromium-Profil für non-root); lokaler
  docker build der Targets runner und worker erfolgreich, PDF-Erzeugung im Image geprüft.
- .env.example/.env.prod.example/.env.coolify.example: alle Craftvia-Variablen inkl. RLS,
  KI-Provider, PDF_CHROMIUM_PATH, OFFLINE_MAX_DAYS, API_RATE_LIMIT_*, AI_GENERATION_RETENTION_DAYS,
  AI_MONTHLY_TOKEN_LIMIT.
- CI (.github, .gitea): Job gate mit Postgres (pgvector) und Redis als Service: migrate deploy,
  seed, Passwort für craftvia_app, tsc, lint, build, npm run test.
- docs/craftvia/DEPLOY.md (aus den Certvia-Deploy-Docs abgeleitet): Architektur, Domains, Secrets,
  Worker, Migrationen, RLS-Aktivierung, Backup/Restore, KI, Rate Limits, Aufbewahrung, Smoke,
  Update/Rollback. build-and-push-images.sh baut craftvia-worker.
- Certvia-/ISMS-Dokumente aus docs/ nach docs/_certvia-archiv/ (mit README); Verweise in README.md
  und Skript-Kommentaren angepasst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 18:19:19 +02:00
co-authored by Claude Opus 5
parent 21d6dc016a
commit cadaedc6cc
38 changed files with 902 additions and 76 deletions
@@ -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).