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