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
+102 -23
View File
@@ -1,41 +1,112 @@
import axios from 'axios';
import type { ApiResponse, Customer, Contract, ContractTask, ContractTaskSubtask, ContractTaskStatus, SalesPlatform, CancellationPeriod, ContractDuration, ContractCategory, Provider, Tariff, User, Address, BankCard, IdentityDocument, Meter, MeterReading, Invoice, Role, PortalSettings, CustomerRepresentative, CustomerSummary, ContractHistoryEntry, AuditLog, AuditSensitivity, AuditRetentionPolicy, CustomerConsent, ConsentType, ConsentStatus, DataDeletionRequest, DeletionRequestStatus, GDPRDashboardStats, RepresentativeAuthorization } from '../types';
// ============================================================================
// In-Memory-Token-Store
// ============================================================================
// Der Access-Token wird BEWUSST nicht in localStorage gespeichert (XSS-Schutz).
// Stattdessen lebt er im Modul-State + wird über den /api/auth/refresh-Endpoint
// nach Page-Reload neu geholt (Refresh-Token sitzt in einem httpOnly-Cookie,
// das JavaScript nie sieht).
let accessToken: string | null = null;
const tokenListeners = new Set<(t: string | null) => void>();
export function setAccessToken(t: string | null): void {
accessToken = t;
tokenListeners.forEach((l) => l(t));
}
export function getAccessToken(): string | null {
return accessToken;
}
export function subscribeToken(listener: (t: string | null) => void): () => void {
tokenListeners.add(listener);
return () => tokenListeners.delete(listener);
}
// ============================================================================
// Axios-Instance
// ============================================================================
const api = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
headers: { 'Content-Type': 'application/json' },
// withCredentials: Cookies werden bei same-origin-Requests mitgeschickt.
// Wichtig für den /auth/refresh-Endpoint (liest den refresh_token-Cookie).
withCredentials: true,
});
// Add auth token to requests
// Request: Bearer-Header aus dem in-memory-Store
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});
// Handle auth errors and extract error messages
// Refresh-Retry-Mechanismus für 401-Antworten.
//
// Wenn der Access-Token abgelaufen ist (15-min-Lifetime), antwortet jeder
// API-Aufruf mit 401. Der Interceptor probiert dann einmal /auth/refresh →
// holt neuen Access-Token (Refresh-Token kommt automatisch via httpOnly-Cookie)
// → wiederholt den ursprünglichen Request transparent. Wenn der Refresh selbst
// scheitert (echt abgemeldet / Cookie weg): wir leiten zur Login-Seite um.
//
// Concurrent-Request-Protection: wenn 401 mehrfach parallel kommt, gibt's
// nur einen aktiven refresh-Aufruf; alle wartenden Requests teilen sich das
// Ergebnis.
let refreshInflight: Promise<string | null> | null = null;
async function doRefresh(): Promise<string | null> {
if (refreshInflight) return refreshInflight;
refreshInflight = (async () => {
try {
const res = await axios.post<ApiResponse<{ token: string }>>(
'/api/auth/refresh',
{},
{ withCredentials: true },
);
const newToken = res.data?.data?.token || null;
setAccessToken(newToken);
return newToken;
} catch {
setAccessToken(null);
return null;
} finally {
refreshInflight = null;
}
})();
return refreshInflight;
}
api.interceptors.response.use(
(response) => response,
(error) => {
// Bei 401 nur dann zur Login-Seite umleiten, wenn wir NICHT gerade auf der Login-Seite sind
// Login-Endpunkte ausschließen, da 401 dort "falsches Passwort" bedeutet
const isLoginEndpoint = error.config?.url?.includes('/auth/login') ||
error.config?.url?.includes('/auth/customer-login');
async (error) => {
const original = error.config;
const status = error.response?.status;
const url: string = original?.url || '';
if (error.response?.status === 401 && !isLoginEndpoint) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
// Auth-Endpoints selbst nicht refreshen sonst Endlos-Schleife
const isAuthEndpoint =
url.includes('/auth/login') ||
url.includes('/auth/customer-login') ||
url.includes('/auth/refresh') ||
url.includes('/auth/logout');
if (status === 401 && !isAuthEndpoint && !original?._retried) {
original._retried = true;
const newToken = await doRefresh();
if (newToken) {
original.headers = original.headers || {};
original.headers.Authorization = `Bearer ${newToken}`;
return api(original);
}
// Refresh fehlgeschlagen → echt abmelden + zur Login-Seite
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
window.location.href = '/login';
}
}
// Extract error message from response
const message = error.response?.data?.error || error.message || 'Ein Fehler ist aufgetreten';
const enhancedError = new Error(message);
return Promise.reject(enhancedError);
}
return Promise.reject(new Error(message));
},
);
// Auth
@@ -52,6 +123,10 @@ export const authApi = {
const res = await api.get<ApiResponse<User>>('/auth/me');
return res.data;
},
logout: async () => {
const res = await api.post<ApiResponse<void>>('/auth/logout');
return res.data;
},
};
// Customers
@@ -544,11 +619,15 @@ export const cachedEmailApi = {
return res.data;
},
// Anhang-URL (view=true für inline anzeigen, sonst download)
// Hinweis: gibt die URL mit dem aktuellen Access-Token als Query-Param zurück,
// weil <iframe>/<a> keinen Authorization-Header senden können. Der Token läuft
// nach 15 min ab wenn Anhang dann geöffnet wird, kommt 401; UI muss in dem
// Fall die URL frisch holen.
getAttachmentUrl: (emailId: number, filename: string, view?: boolean) => {
const token = localStorage.getItem('token');
const token = getAccessToken();
const encodedFilename = encodeURIComponent(filename);
const viewParam = view ? '&view=true' : '';
return `${api.defaults.baseURL}/emails/${emailId}/attachments/${encodedFilename}?token=${token}${viewParam}`;
return `${api.defaults.baseURL}/emails/${emailId}/attachments/${encodedFilename}?token=${token || ''}${viewParam}`;
},
// Ungelesene E-Mails zählen
getUnreadCount: async (params: { customerId?: number; contractId?: number }) => {