9830ac29a5
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>
26 lines
1.1 KiB
TypeScript
26 lines
1.1 KiB
TypeScript
/**
|
||
* Baut eine Download-URL für ein im Backend gespeichertes Upload-File.
|
||
*
|
||
* Geht über `GET /api/files/download?path=...` – der Backend-Controller
|
||
* macht einen Per-File-Ownership-Check (Pfad → Resource → canAccessCustomer
|
||
* / canAccessContract). Damit kann auch ein eingeloggter User keine
|
||
* fremden Dateien abrufen, selbst wenn er den Pfad kennen würde.
|
||
*
|
||
* <a href> und window.open senden keinen Authorization-Header, daher
|
||
* Token als Query-Parameter (auth-Middleware akzeptiert `?token=<jwt>`).
|
||
*
|
||
* Trade-off: Tokens in URLs können in Logs/Referrer landen. Eine
|
||
* sauberere Lösung mit kurzlebigen Download-Tokens (signierte URLs)
|
||
* wäre v1.1-Item.
|
||
*/
|
||
import { getAccessToken } from '../services/api';
|
||
|
||
export function fileUrl(path: string | null | undefined): string {
|
||
if (!path) return '';
|
||
const token = getAccessToken();
|
||
const normalizedPath = path.startsWith('/') ? path : '/' + path;
|
||
const base = `/api/files/download?path=${encodeURIComponent(normalizedPath)}`;
|
||
if (!token) return base;
|
||
return `${base}&token=${encodeURIComponent(token)}`;
|
||
}
|