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);
}
};
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate, useLocation } from 'react-router-dom';
import { pushHistory, popHistory } from '../../utils/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { contractApi, uploadApi, meterApi, contractTaskApi, appSettingsApi, gdprApi, pdfTemplateApi } from '../../services/api';
import { contractApi, uploadApi, meterApi, contractTaskApi, appSettingsApi, gdprApi, pdfTemplateApi, getAccessToken } from '../../services/api';
import { ContractEmailsSection } from '../../components/email';
import { ContractDetailModal, ContractHistorySection } from '../../components/contracts';
import InvoicesSection from '../../components/contracts/InvoicesSection';
@@ -3484,13 +3484,13 @@ function GenerateOrderButton({ contractId }: { contractId: number }) {
setShowInputModal({ templateId, templateName });
} else {
// Direkt generieren (GET-Link)
const token = localStorage.getItem('token');
window.open(`${pdfTemplateApi.generateUrl(templateId, contractId)}?token=${token}`, '_blank');
const token = getAccessToken();
window.open(`${pdfTemplateApi.generateUrl(templateId, contractId)}?token=${token || ''}`, '_blank');
}
} catch {
// Fallback: direkt generieren
const token = localStorage.getItem('token');
window.open(`${pdfTemplateApi.generateUrl(templateId, contractId)}?token=${token}`, '_blank');
const token = getAccessToken();
window.open(`${pdfTemplateApi.generateUrl(templateId, contractId)}?token=${token || ''}`, '_blank');
}
};
@@ -3562,7 +3562,7 @@ function GenerateInputModal({ templateId, templateName, contractId, onClose }: {
const inputs = inputsData?.data;
const handleGenerate = () => {
const token = localStorage.getItem('token');
const token = getAccessToken();
const params = new URLSearchParams();
params.set('token', token || '');
if (stressfreiEmailId) params.set('stressfreiEmailId', stressfreiEmailId);
+2 -2
View File
@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuth } from '../../context/AuthContext';
import { gdprApi } from '../../services/api';
import { gdprApi, getAccessToken } from '../../services/api';
import type { ConsentType, ConsentStatus, CustomerConsent } from '../../types';
import {
Shield,
@@ -93,7 +93,7 @@ export default function PortalPrivacy() {
const consents = data?.data?.consents || [];
const privacyPolicyHtml = data?.data?.privacyPolicyHtml || '';
const allGranted = consents.every((c) => c.status === 'GRANTED');
const token = localStorage.getItem('token');
const token = getAccessToken();
return (
<div>
+2 -2
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { auditLogApi, AuditLogSearchParams } from '../../services/api';
import { auditLogApi, AuditLogSearchParams, getAccessToken } from '../../services/api';
import type { AuditLog, AuditAction, AuditSensitivity } from '../../types';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
@@ -301,7 +301,7 @@ export default function AuditLogs() {
try {
if (format === 'csv') {
// CSV direkt als Download
const token = localStorage.getItem('token');
const token = getAccessToken();
const params = new URLSearchParams();
params.set('format', 'csv');
if (filters.action) params.set('action', filters.action);
@@ -1,7 +1,7 @@
import { useState, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Database, Download, Upload, Trash2, RefreshCw, HardDrive, Clock, FileText, FolderOpen, Archive, AlertTriangle, Bomb } from 'lucide-react';
import { backupApi, BackupInfo } from '../../services/api';
import { backupApi, BackupInfo, getAccessToken } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import Button from '../../components/ui/Button';
@@ -90,7 +90,7 @@ export default function DatabaseBackup() {
// Download mit Auth-Token
const handleDownload = async (name: string) => {
const token = localStorage.getItem('token');
const token = getAccessToken();
const url = backupApi.getDownloadUrl(name);
try {
+4 -4
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { pdfTemplateApi, contractApi } from '../../services/api';
import { pdfTemplateApi, contractApi, getAccessToken } from '../../services/api';
import type { PdfTemplate, CrmField, Contract } from '../../types';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
@@ -276,7 +276,7 @@ function FieldMappingModal({ template, onClose }: { template: PdfTemplate; onClo
<span className="text-gray-400 text-xs">[Feldname] zeigt wo das Feld in der PDF liegt</span>
</div>
<iframe
src={`/api/pdf-templates/${template.id}/preview?token=${localStorage.getItem('token')}`}
src={`/api/pdf-templates/${template.id}/preview?token=${getAccessToken() || ''}`}
className="flex-1 w-full bg-white"
title="PDF Vorschau mit Feldnamen"
/>
@@ -428,11 +428,11 @@ function TestPreviewModal({ template, onClose }: { template: PdfTemplate; onClos
});
const contracts: Contract[] = contractsData?.data || [];
const token = localStorage.getItem('token');
const token = getAccessToken();
const handleGenerate = () => {
if (!selectedContractId) return;
const url = `${pdfTemplateApi.generateUrl(template.id, selectedContractId)}?token=${token}`;
const url = `${pdfTemplateApi.generateUrl(template.id, selectedContractId)}?token=${token || ''}`;
window.open(url, '_blank');
};
+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 }) => {
+3 -1
View File
@@ -13,9 +13,11 @@
* 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 = localStorage.getItem('token');
const token = getAccessToken();
const normalizedPath = path.startsWith('/') ? path : '/' + path;
const base = `/api/files/download?path=${encodeURIComponent(normalizedPath)}`;
if (!token) return base;