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,9 +1,31 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { Request, Response, CookieOptions } from 'express';
|
||||
import * as authService from '../services/auth.service.js';
|
||||
import { AuthRequest, ApiResponse } from '../types/index.js';
|
||||
import prisma from '../lib/prisma.js';
|
||||
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
||||
|
||||
// Refresh-Token-Cookie-Konfiguration. Der Cookie:
|
||||
// - httpOnly → kein JavaScript-Zugriff → bei XSS nicht klaubar
|
||||
// - secure → nur über HTTPS (in Prod via HTTPS_ENABLED, in Dev egal)
|
||||
// - sameSite 'strict' → CSRF-Schutz; Cross-Site-Requests senden den Cookie nicht
|
||||
// - path '/api/auth' → wird nur an Auth-Endpoints mitgeschickt
|
||||
const REFRESH_COOKIE_NAME = 'refresh_token';
|
||||
function getRefreshCookieOptions(): CookieOptions {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: process.env.HTTPS_ENABLED === 'true',
|
||||
sameSite: 'strict',
|
||||
path: '/api/auth',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 Tage, gleicht Refresh-JWT-Lifetime
|
||||
};
|
||||
}
|
||||
function setRefreshCookie(res: Response, token: string): void {
|
||||
res.cookie(REFRESH_COOKIE_NAME, token, getRefreshCookieOptions());
|
||||
}
|
||||
function clearRefreshCookie(res: Response): void {
|
||||
res.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' });
|
||||
}
|
||||
|
||||
// Mitarbeiter-Login
|
||||
export async function login(req: Request, res: Response): Promise<void> {
|
||||
const { email, password } = req.body || {};
|
||||
@@ -18,6 +40,9 @@ export async function login(req: Request, res: Response): Promise<void> {
|
||||
}
|
||||
|
||||
const result = await authService.login(email, password);
|
||||
// Refresh-Token in httpOnly-Cookie, Access-Token im Body (Frontend hält
|
||||
// ihn nur in memory). `token`-Feld bleibt aus Kompatibilität bestehen.
|
||||
setRefreshCookie(res, result.refreshToken);
|
||||
emitSecurityEvent({
|
||||
type: 'LOGIN_SUCCESS',
|
||||
severity: 'INFO',
|
||||
@@ -27,7 +52,10 @@ export async function login(req: Request, res: Response): Promise<void> {
|
||||
userEmail: email,
|
||||
endpoint: ctx.endpoint,
|
||||
});
|
||||
res.json({ success: true, data: result } as ApiResponse);
|
||||
res.json({
|
||||
success: true,
|
||||
data: { token: result.accessToken, user: result.user },
|
||||
} as ApiResponse);
|
||||
} catch (error) {
|
||||
emitSecurityEvent({
|
||||
type: 'LOGIN_FAILED',
|
||||
@@ -58,6 +86,7 @@ export async function customerLogin(req: Request, res: Response): Promise<void>
|
||||
}
|
||||
|
||||
const result = await authService.customerLogin(email, password);
|
||||
setRefreshCookie(res, result.refreshToken);
|
||||
emitSecurityEvent({
|
||||
type: 'LOGIN_SUCCESS',
|
||||
severity: 'INFO',
|
||||
@@ -67,7 +96,10 @@ export async function customerLogin(req: Request, res: Response): Promise<void>
|
||||
userEmail: email,
|
||||
endpoint: ctx.endpoint,
|
||||
});
|
||||
res.json({ success: true, data: result } as ApiResponse);
|
||||
res.json({
|
||||
success: true,
|
||||
data: { token: result.accessToken, user: result.user },
|
||||
} as ApiResponse);
|
||||
} catch (error) {
|
||||
emitSecurityEvent({
|
||||
type: 'LOGIN_FAILED',
|
||||
@@ -257,6 +289,10 @@ export async function logout(req: AuthRequest, res: Response): Promise<void> {
|
||||
data: { tokenInvalidatedAt: new Date() },
|
||||
});
|
||||
}
|
||||
// Refresh-Cookie löschen, sonst könnte der Browser einen abgemeldeten User
|
||||
// direkt wieder einloggen (server-seitige Invalidation oben fängt das ab,
|
||||
// aber UI würde sich verirren).
|
||||
clearRefreshCookie(res);
|
||||
const ctx = contextFromRequest(req);
|
||||
emitSecurityEvent({
|
||||
type: 'LOGOUT',
|
||||
@@ -277,6 +313,36 @@ export async function logout(req: AuthRequest, res: Response): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Neuen Access-Token aus dem httpOnly-Refresh-Cookie holen. Wird vom Frontend
|
||||
// (axios-Interceptor) bei 401 oder beim App-Start aufgerufen.
|
||||
export async function refresh(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const cookies = (req as any).cookies || {};
|
||||
const refreshToken = cookies[REFRESH_COOKIE_NAME];
|
||||
if (!refreshToken) {
|
||||
res.status(401).json({ success: false, error: 'Kein Refresh-Token vorhanden' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await authService.refreshAccessToken(refreshToken);
|
||||
// Refresh-Cookie rotieren – verhindert Replay eines geklauten Refresh-Tokens
|
||||
// bis zur vollen Lifetime.
|
||||
setRefreshCookie(res, result.refreshToken);
|
||||
res.json({
|
||||
success: true,
|
||||
data: { token: result.accessToken, user: result.user },
|
||||
} as ApiResponse);
|
||||
} catch (error) {
|
||||
// Refresh fehlgeschlagen: Cookie wegputzen, damit der Browser nicht
|
||||
// weiter mit einem invaliden Token weiterhin den Endpoint klopft.
|
||||
clearRefreshCookie(res);
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Refresh fehlgeschlagen',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
export async function register(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { email, password, firstName, lastName, roleIds } = req.body;
|
||||
|
||||
Reference in New Issue
Block a user