Commit Graph

3 Commits

Author SHA1 Message Date
duffyduck 8534be22d0 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>
2026-05-16 18:48:13 +02:00
duffyduck 9830ac29a5 security: JWT raus aus localStorage – Refresh-Cookie-Pattern für SPA
Behebt das Pentest-Finding „JWT in localStorage (MITTEL)": bei XSS war
der Token JS-erreichbar → Angreifer könnte alle Anbieter-Credentials
abrufen. Branchenstandard-Lösung für SPAs jetzt umgesetzt.

Architektur:
- Access-Token: 15 min Lifetime, lebt NUR im JavaScript-Memory
  (api.ts tokenStore + AuthContext). Kein localStorage mehr.
- Refresh-Token: 7 Tage, im httpOnly-Cookie (Secure bei HTTPS_ENABLED,
  SameSite=Strict, Path=/api/auth). JavaScript hat keinen Zugriff →
  XSS klaut max. einen 15-min-Access-Token.

Backend:
- signAccessToken/signRefreshToken mit `type`-Claim
- Auth-Middleware verweigert Tokens mit type=refresh
- POST /api/auth/login + /customer-login: setzt refresh_token-Cookie,
  gibt access-Token im Body
- POST /api/auth/refresh: liest Cookie, rotiert ihn, gibt neuen Access
  aus. Prüft tokenInvalidatedAt (Logout/Rollenänderung = sofortige
  Invalidation auch des Refresh-Tokens)
- POST /api/auth/logout: löscht Cookie + setzt tokenInvalidatedAt
- cookie-parser als neue Dependency

Frontend:
- api.ts: in-memory tokenStore (kein localStorage); withCredentials=true
  für Cookie-Roundtrip; axios-Response-Interceptor mit
  Single-Flight-Refresh-Retry bei 401 (Original-Request wird
  transparent retried mit neuem Token)
- AuthContext: beim App-Start /auth/refresh aufrufen → wenn Cookie
  noch gültig, ist der User automatisch eingeloggt. Tab-Reload
  funktioniert weiterhin obwohl Access-Token nur in memory ist.
- 9 alte `localStorage.getItem('token')`-Stellen migriert auf
  `getAccessToken()` (PDF-Preview-iframe, Audit-Log-CSV-Export,
  DB-Backup-Download, File-Download-URLs, Portal-PDF-Link)

Live verifiziert:
- Login setzt Cookie (httpOnly, SameSite=Strict, Path=/api/auth) + Bearer
- API mit Bearer: 200; ohne: 401
- Refresh mit Cookie: rotiert sauber + neuer Access-Token im Body
- Refresh-Token als Bearer abgewiesen: 401 ("Falscher Token-Typ")
- Logout: Cookie gelöscht, danach /refresh → 401

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:06:17 +02:00
Stefan Hacker e209e9bbca first commit 2026-01-29 01:16:54 +01:00