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
@@ -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');
};