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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user