Einmalpasswort-Flow für Portal-Credentials

Wenn Admin "Zugangsdaten versenden" klickt, ist das Passwort jetzt ein
echtes Einmalpasswort: beim ersten erfolgreichen Portal-Login werden
Hash + Encrypted-Feld sofort genullt und der Kunde wird zwangsweise
auf eine "Neues Passwort vergeben"-Seite geleitet. Erst nach eigenem
Passwort kommt er ins Portal.

Schema:
- Customer.portalPasswordMustChange: Boolean @default(false)

Backend:
- sendPortalCredentials setzt Flag = true + erweitertes Mail-Template
  mit Einmalpasswort-Warnung
- customerLogin: bei Flag=true wird OTP konsumiert (Hash+Encrypted=null,
  portalLastLogin aktualisiert), Response enthält mustChangePassword=true
  in token-payload + user-objekt
- setCustomerPortalPassword (manuelles Setzen) räumt Flag wieder auf
- changeInitialPortalPassword: neue Service-Funktion + Endpoint
  POST /api/auth/change-initial-portal-password (authenticated, nur
  Portal-User), validiert Komplexität, setzt neuen Hash, löscht
  Encrypted, invalidiert Session via portalTokenInvalidatedAt

Frontend:
- User-Type erweitert um mustChangePassword
- AuthContext.customerLogin gibt User zurück (für sofortige Routing-
  Entscheidung)
- Login.tsx: redirect zu /change-initial-password wenn mustChangePassword
- ProtectedRoute: zwingt eingeloggte User mit Flag immer zur Change-Seite
- ChangeInitialPasswordGate: blockt User OHNE Flag vom Zugriff
- ChangeInitialPassword: eigene Seite mit Live-Komplexitäts-Hint,
  Passwort-Wiederholung, automatischer Logout + Redirect nach Erfolg

Live-verifiziert (10 Schritte):
- Setzen → Send → DB-Flag=true → OTP-Login gibt mustChange=true und
  consumed Hash → Re-Login mit OTP fehlschlägt → Change schwach=400,
  komplex=200 → neues Passwort funktioniert → Session invalidated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 18:48:13 +02:00
parent f0c97cd46d
commit 8534be22d0
12 changed files with 330 additions and 18 deletions
+42
View File
@@ -97,6 +97,48 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt
- [x] **🔐 Einmalpasswort-Flow für Portal-Credentials**
- **Intention**: Wenn wir Zugangsdaten per E-Mail an den Kunden
schicken, kennen wir das Passwort als Admin das ist solange OK,
bis er sich einmal eingeloggt hat. Danach soll er gezwungen sein,
sich ein eigenes zu vergeben, und das per-Mail-Passwort ist tot.
- **Datenmodell**: neues Feld `portalPasswordMustChange: Boolean
@default(false)` am Customer.
- **Flow**:
1. Admin klickt **Zugangsdaten versenden** → Flag wird gesetzt,
Mail-Template weist explizit auf „Einmalpasswort" hin.
2. Kunde loggt sich mit dem OTP ein → Backend gibt
`mustChangePassword: true` im Login-Response zurück UND
**konsumiert das OTP sofort**: setzt `portalPasswordHash =
null` und `portalPasswordEncrypted = null`. Ein zweiter
Login mit demselben Passwort schlägt fehl (401).
3. Frontend (`ProtectedRoute`) sieht `mustChangePassword=true`
und leitet auf `/change-initial-password` um egal welche
Route der Kunde aufrufen will, er kommt nicht weiter.
4. Auf der Seite gibt er ein neues, komplexes Passwort vor
(Live-Hint mit ✓/○, dieselben Regeln wie Backend).
5. `POST /api/auth/change-initial-portal-password` speichert
neuen Hash, **löscht das Encrypted-Feld** (Admin kann das
eigene Passwort des Kunden nicht mehr im Klartext lesen),
setzt `portalTokenInvalidatedAt = now()` und
`portalPasswordMustChange = false`.
6. Frontend loggt aus, leitet zu `/login?changed=1`,
Erfolgs-Banner: „Passwort wurde geändert. Bitte mit dem
neuen Passwort anmelden."
- **Edge case**: Tab geschlossen ohne Setzen → Kunde ist
ausgesperrt (OTP weg, eigenes Passwort nicht gesetzt). Lösung
aus seiner Sicht: Passwort-vergessen-Funktion oder Admin
versendet neue Zugangsdaten.
- **Edge case**: Admin macht zwischendurch nochmal manuelles
„Setzen" → `mustChange` wird automatisch wieder `false`. So
kann ein versehentlich versendetes OTP problemlos durch ein
direkt-gesetztes Passwort ersetzt werden.
- **Live-verifiziert (10 Schritte)**: Setzen → Send → Flag in
DB=true → Login mit OTP gibt mustChange=true zurück + Hash
in DB ist null → Re-Login mit OTP → 401 → Change-Endpoint
schwach → 400 → komplex → 200 → Login mit neuem PW →
mustChange=false + tokenInvalidatedAt gesetzt.
- [x] **🔐 Passwort-Komplexität + Portal-Credentials-UX**
- **Problem**: Bisher reichten 6 Zeichen für gesetzte Passwörter
(Portal-Login, User-Reset, Registrierung, User-Anlage). Das hat