- 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>
6.6 KiB
SEC2 — Passwort-Self-Service, E-Mail-Änderung, Sessions
Branch
dev-sec2-auth-selfservice(Basisdev-sec1-mail-smtp) · Grundlage:docs/sicherheit/SEC2-Auth-SelfService-Detail.md· Abhängigkeit: SEC1 (Templatespassword_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:
sessionsValidAfterwird gesetzt → jedes JWT mit älteremiatgilt 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
iatwird 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,
mustChangePasswordlö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
iatfail-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:
/resetmit 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/changePasswordSelfbereit. - 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).