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:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user