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>
This commit is contained in:
2026-05-16 16:06:17 +02:00
parent 0943f11999
commit 9830ac29a5
16 changed files with 431 additions and 107 deletions
+35
View File
@@ -97,6 +97,41 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt
- [x] **🛡️ JWT-Tokens raus aus localStorage Refresh-Cookie-Pattern**
- Pentest-Finding „JWT in localStorage (MITTEL)": bei XSS könnte JS
den Token klauen + alle Anbieter-Credentials abrufen. Lösung:
Branchenstandard für SPAs.
- **Access-Token**: kurzlebig (15 min), lebt nur im
JavaScript-Memory (Modul-State + AuthContext). Kein localStorage
mehr → XSS-Angriff klaut maximal einen 15-min-Token, mit dem er
eh nicht weit kommt.
- **Refresh-Token**: 7 Tage Lifetime, im **httpOnly-Cookie** (`Secure`
bei HTTPS_ENABLED, `SameSite=Strict`, `Path=/api/auth`). JavaScript
hat **keinen Zugriff** → XSS kann ihn nicht klauen.
- Backend:
* `signAccessToken/signRefreshToken` mit `type`-Claim als
Unterscheidung; Auth-Middleware lässt nur `type=access` durch
* Login + Customer-Login setzen Cookie + geben Access im Body
* `POST /api/auth/refresh` liest Cookie, gibt neuen Access aus,
rotiert Refresh-Cookie, prüft `tokenInvalidatedAt`
(sofortige Invalidation bei Rolle-Ändern/Logout)
* Logout löscht Cookie + setzt `tokenInvalidatedAt`
* `cookie-parser` als neue dependency
- Frontend:
* `api.ts`: in-memory `tokenStore` + axios-Interceptor mit
Auto-Refresh-Retry bei 401 (single-flight gegen
Concurrent-Requests)
* `AuthContext`: beim App-Start `/auth/refresh` aufrufen → wenn
Cookie noch gültig, ist der User automatisch eingeloggt
(kein Re-Login nach Tab-Reload trotz memory-only Access-Token)
* 9 alte `localStorage.getItem('token')`-Stellen migriert auf
`getAccessToken()` (PDF-Vorschau-iframe, Audit-Log-Export,
Backup-Download, File-Download-URL, …)
- Live verifiziert: Login setzt Cookie+Bearer, API-Calls mit
Bearer→200, ohne→401, Refresh-Endpoint rotiert Cookie sauber,
Refresh-Token wird als Bearer (Access) abgelehnt („Falscher
Token-Typ"), Logout löscht Cookie + invalidiert Token.
- [x] **🔒 Audit-Log für alle Klartext-Passwort-Reads**
- Pentest-Finding „Klartext-Passwörter über API abrufbar (HIGH,
post-auth)" → reversible Verschlüsselung ist by-design (Feature