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