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
+37 -37
View File
@@ -1,5 +1,6 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { authApi } from '../services/api';
import axios from 'axios';
import { authApi, setAccessToken, getAccessToken } from '../services/api';
import type { User } from '../types';
interface AuthContextType {
@@ -8,7 +9,7 @@ interface AuthContextType {
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
customerLogin: (email: string, password: string) => Promise<void>;
logout: () => void;
logout: () => Promise<void>;
hasPermission: (permission: string) => boolean;
isCustomer: boolean;
isCustomerPortal: boolean;
@@ -40,32 +41,31 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
}, [user, developerMode]);
// Beim App-Start versuchen, einen Access-Token via Refresh-Cookie zu holen.
// Wenn das klappt → User ist eingeloggt. Wenn nicht → User muss sich neu
// anmelden. Der Access-Token bleibt nur im memory (kein localStorage).
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
authApi.me()
.then((res) => {
if (res.success && res.data) {
setUser(res.data);
} else {
localStorage.removeItem('token');
}
})
.catch(() => {
localStorage.removeItem('token');
})
.finally(() => {
setIsLoading(false);
});
} else {
setIsLoading(false);
}
(async () => {
try {
const res = await axios.post('/api/auth/refresh', {}, { withCredentials: true });
if (res.data?.success && res.data?.data?.token) {
setAccessToken(res.data.data.token);
// Danach den vollen User aus /me laden (Permissions etc.)
const me = await authApi.me();
if (me.success && me.data) setUser(me.data);
}
} catch {
// Kein gültiger Refresh-Cookie → User ist nicht eingeloggt
} finally {
setIsLoading(false);
}
})();
}, []);
const login = async (email: string, password: string) => {
const res = await authApi.login(email, password);
if (res.success && res.data) {
localStorage.setItem('token', res.data.token);
setAccessToken(res.data.token);
setUser(res.data.user);
} else {
throw new Error(res.error || 'Login fehlgeschlagen');
@@ -75,31 +75,31 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const customerLogin = async (email: string, password: string) => {
const res = await authApi.customerLogin(email, password);
if (res.success && res.data) {
localStorage.setItem('token', res.data.token);
setAccessToken(res.data.token);
setUser(res.data.user);
} else {
throw new Error(res.error || 'Login fehlgeschlagen');
}
};
const logout = () => {
localStorage.removeItem('token');
const logout = async () => {
// Server-Logout: invalidiert Refresh-Token-Cookie + tokenInvalidatedAt
try {
await authApi.logout();
} catch {
// Selbst wenn der Server-Logout fehlschlägt: client-side clear
}
setAccessToken(null);
setUser(null);
};
const refreshUser = async () => {
const token = localStorage.getItem('token');
if (token) {
try {
const res = await authApi.me();
console.log('refreshUser response:', res);
console.log('permissions:', res.data?.permissions);
if (res.success && res.data) {
setUser(res.data);
}
} catch (err) {
console.error('refreshUser error:', err);
}
if (!getAccessToken()) return;
try {
const res = await authApi.me();
if (res.success && res.data) setUser(res.data);
} catch (err) {
console.error('refreshUser error:', err);
}
};