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
+35 -18
View File
@@ -9,9 +9,11 @@
"version": "1.1.0", "version": "1.1.0",
"dependencies": { "dependencies": {
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"@types/cookie-parser": "^1.4.10",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"dotenv-expand": "^13.0.0", "dotenv-expand": "^13.0.0",
@@ -608,7 +610,6 @@
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"dependencies": { "dependencies": {
"@types/connect": "*", "@types/connect": "*",
"@types/node": "*" "@types/node": "*"
@@ -618,11 +619,19 @@
"version": "3.4.38", "version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/cookie-parser": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
"license": "MIT",
"peerDependencies": {
"@types/express": "*"
}
},
"node_modules/@types/cors": { "node_modules/@types/cors": {
"version": "2.8.19", "version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
@@ -636,7 +645,6 @@
"version": "4.17.25", "version": "4.17.25",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
"dev": true,
"dependencies": { "dependencies": {
"@types/body-parser": "*", "@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33", "@types/express-serve-static-core": "^4.17.33",
@@ -648,7 +656,6 @@
"version": "4.19.8", "version": "4.19.8",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz",
"integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==",
"dev": true,
"dependencies": { "dependencies": {
"@types/node": "*", "@types/node": "*",
"@types/qs": "*", "@types/qs": "*",
@@ -659,8 +666,7 @@
"node_modules/@types/http-errors": { "node_modules/@types/http-errors": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="
"dev": true
}, },
"node_modules/@types/jsonwebtoken": { "node_modules/@types/jsonwebtoken": {
"version": "9.0.10", "version": "9.0.10",
@@ -697,8 +703,7 @@
"node_modules/@types/mime": { "node_modules/@types/mime": {
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="
"dev": true
}, },
"node_modules/@types/ms": { "node_modules/@types/ms": {
"version": "2.1.0", "version": "2.1.0",
@@ -719,7 +724,6 @@
"version": "22.19.7", "version": "22.19.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
"dev": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -752,14 +756,12 @@
"node_modules/@types/qs": { "node_modules/@types/qs": {
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="
"dev": true
}, },
"node_modules/@types/range-parser": { "node_modules/@types/range-parser": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="
"dev": true
}, },
"node_modules/@types/readdir-glob": { "node_modules/@types/readdir-glob": {
"version": "1.1.5", "version": "1.1.5",
@@ -774,7 +776,6 @@
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"
} }
@@ -783,7 +784,6 @@
"version": "1.15.10", "version": "1.15.10",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
"dev": true,
"dependencies": { "dependencies": {
"@types/http-errors": "*", "@types/http-errors": "*",
"@types/node": "*", "@types/node": "*",
@@ -794,7 +794,6 @@
"version": "0.17.6", "version": "0.17.6",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
"dev": true,
"dependencies": { "dependencies": {
"@types/mime": "^1", "@types/mime": "^1",
"@types/node": "*" "@types/node": "*"
@@ -1252,6 +1251,25 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": { "node_modules/cookie-signature": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
@@ -3374,8 +3392,7 @@
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
"dev": true
}, },
"node_modules/unicode-properties": { "node_modules/unicode-properties": {
"version": "1.4.1", "version": "1.4.1",
+2
View File
@@ -21,9 +21,11 @@
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"@types/cookie-parser": "^1.4.10",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"dotenv-expand": "^13.0.0", "dotenv-expand": "^13.0.0",
+69 -3
View File
@@ -1,9 +1,31 @@
import { Request, Response } from 'express'; import { Request, Response, CookieOptions } from 'express';
import * as authService from '../services/auth.service.js'; import * as authService from '../services/auth.service.js';
import { AuthRequest, ApiResponse } from '../types/index.js'; import { AuthRequest, ApiResponse } from '../types/index.js';
import prisma from '../lib/prisma.js'; import prisma from '../lib/prisma.js';
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.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 // Mitarbeiter-Login
export async function login(req: Request, res: Response): Promise<void> { export async function login(req: Request, res: Response): Promise<void> {
const { email, password } = req.body || {}; 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); 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({ emitSecurityEvent({
type: 'LOGIN_SUCCESS', type: 'LOGIN_SUCCESS',
severity: 'INFO', severity: 'INFO',
@@ -27,7 +52,10 @@ export async function login(req: Request, res: Response): Promise<void> {
userEmail: email, userEmail: email,
endpoint: ctx.endpoint, 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) { } catch (error) {
emitSecurityEvent({ emitSecurityEvent({
type: 'LOGIN_FAILED', type: 'LOGIN_FAILED',
@@ -58,6 +86,7 @@ export async function customerLogin(req: Request, res: Response): Promise<void>
} }
const result = await authService.customerLogin(email, password); const result = await authService.customerLogin(email, password);
setRefreshCookie(res, result.refreshToken);
emitSecurityEvent({ emitSecurityEvent({
type: 'LOGIN_SUCCESS', type: 'LOGIN_SUCCESS',
severity: 'INFO', severity: 'INFO',
@@ -67,7 +96,10 @@ export async function customerLogin(req: Request, res: Response): Promise<void>
userEmail: email, userEmail: email,
endpoint: ctx.endpoint, 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) { } catch (error) {
emitSecurityEvent({ emitSecurityEvent({
type: 'LOGIN_FAILED', type: 'LOGIN_FAILED',
@@ -257,6 +289,10 @@ export async function logout(req: AuthRequest, res: Response): Promise<void> {
data: { tokenInvalidatedAt: new Date() }, 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); const ctx = contextFromRequest(req);
emitSecurityEvent({ emitSecurityEvent({
type: 'LOGOUT', 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> { export async function register(req: Request, res: Response): Promise<void> {
try { try {
const { email, password, firstName, lastName, roleIds } = req.body; const { email, password, firstName, lastName, roleIds } = req.body;
+4
View File
@@ -1,4 +1,5 @@
import express from 'express'; import express from 'express';
import cookieParser from 'cookie-parser';
import cors from 'cors'; import cors from 'cors';
import helmet from 'helmet'; import helmet from 'helmet';
import path from 'path'; import path from 'path';
@@ -212,6 +213,9 @@ app.use(
// JSON-Body-Limit: 5 MB (Uploads laufen über multer, brauchen kein json()) // JSON-Body-Limit: 5 MB (Uploads laufen über multer, brauchen kein json())
app.use(express.json({ limit: '5mb' })); app.use(express.json({ limit: '5mb' }));
// Cookie-Parser: wird für den httpOnly-Refresh-Token-Cookie gebraucht
// (POST /api/auth/refresh liest ihn aus req.cookies).
app.use(cookieParser());
// Audit-Logging Middleware (DSGVO-konform) // Audit-Logging Middleware (DSGVO-konform)
app.use(auditContextMiddleware); app.use(auditContextMiddleware);
+11 -1
View File
@@ -31,7 +31,17 @@ export async function authenticate(
// Algorithmus explizit auf HS256 festlegen (Defense-in-Depth gegen alg-confusion). // Algorithmus explizit auf HS256 festlegen (Defense-in-Depth gegen alg-confusion).
const decoded = jwt.verify(token, process.env.JWT_SECRET as string, { const decoded = jwt.verify(token, process.env.JWT_SECRET as string, {
algorithms: ['HS256'], algorithms: ['HS256'],
}) as JwtPayload; }) as JwtPayload & { type?: string };
// Defense-in-Depth: Refresh-Tokens haben `type: 'refresh'` und dürfen
// NICHT für normale API-Calls verwendet werden nur am /api/auth/refresh-
// Endpoint. Legacy-Tokens (vor der Refresh-Token-Einführung) haben kein
// `type` und werden als Access akzeptiert, damit bestehende Sessions nicht
// zwangsabgemeldet werden.
if (decoded.type && decoded.type !== 'access') {
res.status(401).json({ success: false, error: 'Falscher Token-Typ' });
return;
}
// Prüfen ob Token durch Rechteänderung/Passwort-Reset invalidiert wurde // Prüfen ob Token durch Rechteänderung/Passwort-Reset invalidiert wurde
if (decoded.userId && decoded.iat) { if (decoded.userId && decoded.iat) {
+1
View File
@@ -7,6 +7,7 @@ const router = Router();
router.post('/login', loginRateLimiter, authController.login); router.post('/login', loginRateLimiter, authController.login);
router.post('/customer-login', loginRateLimiter, authController.customerLogin); router.post('/customer-login', loginRateLimiter, authController.customerLogin);
router.post('/refresh', authController.refresh);
router.get('/me', authenticate, authController.me); router.get('/me', authenticate, authController.me);
router.post('/logout', authenticate, authController.logout); router.post('/logout', authenticate, authController.logout);
router.post('/register', authenticate, requirePermission('users:create'), authController.register); router.post('/register', authenticate, requirePermission('users:create'), authController.register);
+116 -8
View File
@@ -7,6 +7,26 @@ import { encrypt, decrypt } from '../utils/encryption.js';
import { sendEmail, SmtpCredentials } from './smtpService.js'; import { sendEmail, SmtpCredentials } from './smtpService.js';
import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js'; import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js';
// Token-Lifetimes
// - Access-Token: kurzlebig, nur im Browser-Memory → XSS klaut max. 15 min
// - Refresh-Token: lang, im httpOnly-Cookie → kein JS-Zugriff
const ACCESS_TOKEN_EXPIRES_IN = (process.env.JWT_EXPIRES_IN || '15m') as jwt.SignOptions['expiresIn'];
const REFRESH_TOKEN_EXPIRES_IN = (process.env.JWT_REFRESH_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn'];
// Helper: signiert ein Access- bzw. Refresh-JWT mit dem `type`-Claim als
// Unterscheidung. Der Refresh-Token landet im httpOnly-Cookie und wird beim
// /auth/refresh-Endpoint geprüft, der dann einen neuen Access ausgibt.
export function signAccessToken(payload: JwtPayload): string {
return jwt.sign({ ...payload, type: 'access' }, process.env.JWT_SECRET as string, {
expiresIn: ACCESS_TOKEN_EXPIRES_IN,
});
}
export function signRefreshToken(payload: JwtPayload): string {
return jwt.sign({ ...payload, type: 'refresh' }, process.env.JWT_SECRET as string, {
expiresIn: REFRESH_TOKEN_EXPIRES_IN,
});
}
// Bcrypt-Cost-Faktor: 12 = OWASP-empfohlen (Stand 2026), ca. 250 ms pro Hash. // Bcrypt-Cost-Faktor: 12 = OWASP-empfohlen (Stand 2026), ca. 250 ms pro Hash.
// Bestehende Hashes mit Faktor 10 bleiben gültig (bcrypt kodiert den Faktor im Hash). // Bestehende Hashes mit Faktor 10 bleiben gültig (bcrypt kodiert den Faktor im Hash).
const BCRYPT_COST = 12; const BCRYPT_COST = 12;
@@ -100,12 +120,12 @@ export async function login(email: string, password: string) {
isCustomerPortal: false, isCustomerPortal: false,
}; };
const token = jwt.sign(payload, process.env.JWT_SECRET as string, { const accessToken = signAccessToken(payload);
expiresIn: (process.env.JWT_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn'], const refreshToken = signRefreshToken(payload);
});
return { return {
token, accessToken,
refreshToken,
user: { user: {
id: user.id, id: user.id,
email: user.email, email: user.email,
@@ -188,12 +208,12 @@ export async function customerLogin(email: string, password: string) {
representedCustomerIds, representedCustomerIds,
}; };
const token = jwt.sign(payload, process.env.JWT_SECRET as string, { const accessToken = signAccessToken(payload);
expiresIn: (process.env.JWT_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn'], const refreshToken = signRefreshToken(payload);
});
return { return {
token, accessToken,
refreshToken,
user: { user: {
id: customer.id, id: customer.id,
email: customer.portalEmail, email: customer.portalEmail,
@@ -214,6 +234,94 @@ export async function customerLogin(email: string, password: string) {
}; };
} }
// Refresh-Token verifizieren und neuen Access-Token ausstellen. Wirft bei
// ungültigem/abgelaufenem/invalidiertem Token. Greift auch tokenInvalidatedAt
// vom User/Customer ab → bei Rolle-Ändern oder Logout sind alle Tokens (auch
// das Refresh) sofort tot.
export async function refreshAccessToken(refreshToken: string): Promise<{
accessToken: string;
refreshToken: string;
user: any;
}> {
let decoded: any;
try {
decoded = jwt.verify(refreshToken, process.env.JWT_SECRET as string, {
algorithms: ['HS256'],
});
} catch {
throw new Error('Refresh-Token ungültig oder abgelaufen');
}
if (decoded.type !== 'refresh') {
throw new Error('Falscher Token-Typ');
}
const issuedAt = decoded.iat ? decoded.iat * 1000 : 0;
// Mitarbeiter
if (!decoded.isCustomerPortal && decoded.userId) {
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
include: {
roles: { include: { role: { include: { permissions: { include: { permission: true } } } } } },
},
});
if (!user || !user.isActive) throw new Error('Benutzer nicht aktiv');
if (user.tokenInvalidatedAt && issuedAt < user.tokenInvalidatedAt.getTime()) {
throw new Error('Refresh-Token wurde invalidiert (Logout/Rechteänderung)');
}
const permissions = new Set<string>();
for (const ur of user.roles) {
for (const rp of ur.role.permissions) {
permissions.add(`${rp.permission.resource}:${rp.permission.action}`);
}
}
const payload: JwtPayload = {
userId: user.id,
email: user.email,
permissions: Array.from(permissions),
customerId: user.customerId ?? undefined,
isCustomerPortal: false,
};
return {
accessToken: signAccessToken(payload),
refreshToken: signRefreshToken(payload),
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
permissions: Array.from(permissions),
customerId: user.customerId,
isCustomerPortal: false,
},
};
}
// Customer-Portal
if (decoded.isCustomerPortal && decoded.customerId) {
const customer = await prisma.customer.findUnique({ where: { id: decoded.customerId } });
if (!customer || !customer.portalEmail) throw new Error('Portal-Konto nicht gefunden');
if (customer.portalTokenInvalidatedAt && issuedAt < customer.portalTokenInvalidatedAt.getTime()) {
throw new Error('Refresh-Token wurde invalidiert');
}
const portalUser = await getCustomerPortalUser(customer.id);
if (!portalUser) throw new Error('Portal-Konto nicht gefunden');
const payload: JwtPayload = {
email: customer.portalEmail,
permissions: portalUser.permissions,
customerId: customer.id,
isCustomerPortal: true,
representedCustomerIds: portalUser.representedCustomers?.map((c: any) => c.id),
};
return {
accessToken: signAccessToken(payload),
refreshToken: signRefreshToken(payload),
user: portalUser,
};
}
throw new Error('Refresh-Token konnte nicht interpretiert werden');
}
// Kundenportal-Passwort setzen/ändern // Kundenportal-Passwort setzen/ändern
export async function setCustomerPortalPassword(customerId: number, password: string) { export async function setCustomerPortalPassword(customerId: number, password: string) {
console.log('[SetPortalPassword] Setze Passwort für Kunde:', customerId); console.log('[SetPortalPassword] Setze Passwort für Kunde:', customerId);
+35
View File
@@ -97,6 +97,41 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt ## ✅ Erledigt
- [x] **🛡️ JWT-Tokens raus aus localStorage Refresh-Cookie-Pattern**
- Pentest-Finding „JWT in localStorage (MITTEL)": bei XSS könnte JS
den Token klauen + alle Anbieter-Credentials abrufen. Lösung:
Branchenstandard für SPAs.
- **Access-Token**: kurzlebig (15 min), lebt nur im
JavaScript-Memory (Modul-State + AuthContext). Kein localStorage
mehr → XSS-Angriff klaut maximal einen 15-min-Token, mit dem er
eh nicht weit kommt.
- **Refresh-Token**: 7 Tage Lifetime, im **httpOnly-Cookie** (`Secure`
bei HTTPS_ENABLED, `SameSite=Strict`, `Path=/api/auth`). JavaScript
hat **keinen Zugriff** → XSS kann ihn nicht klauen.
- Backend:
* `signAccessToken/signRefreshToken` mit `type`-Claim als
Unterscheidung; Auth-Middleware lässt nur `type=access` durch
* Login + Customer-Login setzen Cookie + geben Access im Body
* `POST /api/auth/refresh` liest Cookie, gibt neuen Access aus,
rotiert Refresh-Cookie, prüft `tokenInvalidatedAt`
(sofortige Invalidation bei Rolle-Ändern/Logout)
* Logout löscht Cookie + setzt `tokenInvalidatedAt`
* `cookie-parser` als neue dependency
- Frontend:
* `api.ts`: in-memory `tokenStore` + axios-Interceptor mit
Auto-Refresh-Retry bei 401 (single-flight gegen
Concurrent-Requests)
* `AuthContext`: beim App-Start `/auth/refresh` aufrufen → wenn
Cookie noch gültig, ist der User automatisch eingeloggt
(kein Re-Login nach Tab-Reload trotz memory-only Access-Token)
* 9 alte `localStorage.getItem('token')`-Stellen migriert auf
`getAccessToken()` (PDF-Vorschau-iframe, Audit-Log-Export,
Backup-Download, File-Download-URL, …)
- Live verifiziert: Login setzt Cookie+Bearer, API-Calls mit
Bearer→200, ohne→401, Refresh-Endpoint rotiert Cookie sauber,
Refresh-Token wird als Bearer (Access) abgelehnt („Falscher
Token-Typ"), Logout löscht Cookie + invalidiert Token.
- [x] **🔒 Audit-Log für alle Klartext-Passwort-Reads** - [x] **🔒 Audit-Log für alle Klartext-Passwort-Reads**
- Pentest-Finding „Klartext-Passwörter über API abrufbar (HIGH, - Pentest-Finding „Klartext-Passwörter über API abrufbar (HIGH,
post-auth)" → reversible Verschlüsselung ist by-design (Feature post-auth)" → reversible Verschlüsselung ist by-design (Feature
+37 -37
View File
@@ -1,5 +1,6 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; 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'; import type { User } from '../types';
interface AuthContextType { interface AuthContextType {
@@ -8,7 +9,7 @@ interface AuthContextType {
isAuthenticated: boolean; isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>; login: (email: string, password: string) => Promise<void>;
customerLogin: (email: string, password: string) => Promise<void>; customerLogin: (email: string, password: string) => Promise<void>;
logout: () => void; logout: () => Promise<void>;
hasPermission: (permission: string) => boolean; hasPermission: (permission: string) => boolean;
isCustomer: boolean; isCustomer: boolean;
isCustomerPortal: boolean; isCustomerPortal: boolean;
@@ -40,32 +41,31 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
}, [user, developerMode]); }, [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(() => { useEffect(() => {
const token = localStorage.getItem('token'); (async () => {
if (token) { try {
authApi.me() const res = await axios.post('/api/auth/refresh', {}, { withCredentials: true });
.then((res) => { if (res.data?.success && res.data?.data?.token) {
if (res.success && res.data) { setAccessToken(res.data.data.token);
setUser(res.data); // Danach den vollen User aus /me laden (Permissions etc.)
} else { const me = await authApi.me();
localStorage.removeItem('token'); if (me.success && me.data) setUser(me.data);
} }
}) } catch {
.catch(() => { // Kein gültiger Refresh-Cookie → User ist nicht eingeloggt
localStorage.removeItem('token'); } finally {
}) setIsLoading(false);
.finally(() => { }
setIsLoading(false); })();
});
} else {
setIsLoading(false);
}
}, []); }, []);
const login = async (email: string, password: string) => { const login = async (email: string, password: string) => {
const res = await authApi.login(email, password); const res = await authApi.login(email, password);
if (res.success && res.data) { if (res.success && res.data) {
localStorage.setItem('token', res.data.token); setAccessToken(res.data.token);
setUser(res.data.user); setUser(res.data.user);
} else { } else {
throw new Error(res.error || 'Login fehlgeschlagen'); 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 customerLogin = async (email: string, password: string) => {
const res = await authApi.customerLogin(email, password); const res = await authApi.customerLogin(email, password);
if (res.success && res.data) { if (res.success && res.data) {
localStorage.setItem('token', res.data.token); setAccessToken(res.data.token);
setUser(res.data.user); setUser(res.data.user);
} else { } else {
throw new Error(res.error || 'Login fehlgeschlagen'); throw new Error(res.error || 'Login fehlgeschlagen');
} }
}; };
const logout = () => { const logout = async () => {
localStorage.removeItem('token'); // 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); setUser(null);
}; };
const refreshUser = async () => { const refreshUser = async () => {
const token = localStorage.getItem('token'); if (!getAccessToken()) return;
if (token) { try {
try { const res = await authApi.me();
const res = await authApi.me(); if (res.success && res.data) setUser(res.data);
console.log('refreshUser response:', res); } catch (err) {
console.log('permissions:', res.data?.permissions); console.error('refreshUser error:', err);
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 { useParams, Link, useNavigate, useLocation } from 'react-router-dom';
import { pushHistory, popHistory } from '../../utils/navigation'; import { pushHistory, popHistory } from '../../utils/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; 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 { ContractEmailsSection } from '../../components/email';
import { ContractDetailModal, ContractHistorySection } from '../../components/contracts'; import { ContractDetailModal, ContractHistorySection } from '../../components/contracts';
import InvoicesSection from '../../components/contracts/InvoicesSection'; import InvoicesSection from '../../components/contracts/InvoicesSection';
@@ -3484,13 +3484,13 @@ function GenerateOrderButton({ contractId }: { contractId: number }) {
setShowInputModal({ templateId, templateName }); setShowInputModal({ templateId, templateName });
} else { } else {
// Direkt generieren (GET-Link) // Direkt generieren (GET-Link)
const token = localStorage.getItem('token'); const token = getAccessToken();
window.open(`${pdfTemplateApi.generateUrl(templateId, contractId)}?token=${token}`, '_blank'); window.open(`${pdfTemplateApi.generateUrl(templateId, contractId)}?token=${token || ''}`, '_blank');
} }
} catch { } catch {
// Fallback: direkt generieren // Fallback: direkt generieren
const token = localStorage.getItem('token'); const token = getAccessToken();
window.open(`${pdfTemplateApi.generateUrl(templateId, contractId)}?token=${token}`, '_blank'); window.open(`${pdfTemplateApi.generateUrl(templateId, contractId)}?token=${token || ''}`, '_blank');
} }
}; };
@@ -3562,7 +3562,7 @@ function GenerateInputModal({ templateId, templateName, contractId, onClose }: {
const inputs = inputsData?.data; const inputs = inputsData?.data;
const handleGenerate = () => { const handleGenerate = () => {
const token = localStorage.getItem('token'); const token = getAccessToken();
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('token', token || ''); params.set('token', token || '');
if (stressfreiEmailId) params.set('stressfreiEmailId', stressfreiEmailId); if (stressfreiEmailId) params.set('stressfreiEmailId', stressfreiEmailId);
+2 -2
View File
@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import { gdprApi } from '../../services/api'; import { gdprApi, getAccessToken } from '../../services/api';
import type { ConsentType, ConsentStatus, CustomerConsent } from '../../types'; import type { ConsentType, ConsentStatus, CustomerConsent } from '../../types';
import { import {
Shield, Shield,
@@ -93,7 +93,7 @@ export default function PortalPrivacy() {
const consents = data?.data?.consents || []; const consents = data?.data?.consents || [];
const privacyPolicyHtml = data?.data?.privacyPolicyHtml || ''; const privacyPolicyHtml = data?.data?.privacyPolicyHtml || '';
const allGranted = consents.every((c) => c.status === 'GRANTED'); const allGranted = consents.every((c) => c.status === 'GRANTED');
const token = localStorage.getItem('token'); const token = getAccessToken();
return ( return (
<div> <div>
+2 -2
View File
@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; 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 type { AuditLog, AuditAction, AuditSensitivity } from '../../types';
import Card from '../../components/ui/Card'; import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button'; import Button from '../../components/ui/Button';
@@ -301,7 +301,7 @@ export default function AuditLogs() {
try { try {
if (format === 'csv') { if (format === 'csv') {
// CSV direkt als Download // CSV direkt als Download
const token = localStorage.getItem('token'); const token = getAccessToken();
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('format', 'csv'); params.set('format', 'csv');
if (filters.action) params.set('action', filters.action); if (filters.action) params.set('action', filters.action);
@@ -1,7 +1,7 @@
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; 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 { 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 { useAuth } from '../../context/AuthContext';
import Button from '../../components/ui/Button'; import Button from '../../components/ui/Button';
@@ -90,7 +90,7 @@ export default function DatabaseBackup() {
// Download mit Auth-Token // Download mit Auth-Token
const handleDownload = async (name: string) => { const handleDownload = async (name: string) => {
const token = localStorage.getItem('token'); const token = getAccessToken();
const url = backupApi.getDownloadUrl(name); const url = backupApi.getDownloadUrl(name);
try { try {
+4 -4
View File
@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; 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 type { PdfTemplate, CrmField, Contract } from '../../types';
import Card from '../../components/ui/Card'; import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button'; 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> <span className="text-gray-400 text-xs">[Feldname] zeigt wo das Feld in der PDF liegt</span>
</div> </div>
<iframe <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" className="flex-1 w-full bg-white"
title="PDF Vorschau mit Feldnamen" title="PDF Vorschau mit Feldnamen"
/> />
@@ -428,11 +428,11 @@ function TestPreviewModal({ template, onClose }: { template: PdfTemplate; onClos
}); });
const contracts: Contract[] = contractsData?.data || []; const contracts: Contract[] = contractsData?.data || [];
const token = localStorage.getItem('token'); const token = getAccessToken();
const handleGenerate = () => { const handleGenerate = () => {
if (!selectedContractId) return; 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'); window.open(url, '_blank');
}; };
+102 -23
View File
@@ -1,41 +1,112 @@
import axios from 'axios'; 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'; 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({ const api = axios.create({
baseURL: '/api', baseURL: '/api',
headers: { headers: { 'Content-Type': 'application/json' },
'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) => { api.interceptors.request.use((config) => {
const token = localStorage.getItem('token'); if (accessToken) {
if (token) { config.headers.Authorization = `Bearer ${accessToken}`;
config.headers.Authorization = `Bearer ${token}`;
} }
return config; 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( api.interceptors.response.use(
(response) => response, (response) => response,
(error) => { async (error) => {
// Bei 401 nur dann zur Login-Seite umleiten, wenn wir NICHT gerade auf der Login-Seite sind const original = error.config;
// Login-Endpunkte ausschließen, da 401 dort "falsches Passwort" bedeutet const status = error.response?.status;
const isLoginEndpoint = error.config?.url?.includes('/auth/login') || const url: string = original?.url || '';
error.config?.url?.includes('/auth/customer-login');
if (error.response?.status === 401 && !isLoginEndpoint) { // Auth-Endpoints selbst nicht refreshen sonst Endlos-Schleife
localStorage.removeItem('token'); const isAuthEndpoint =
localStorage.removeItem('user'); url.includes('/auth/login') ||
window.location.href = '/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 message = error.response?.data?.error || error.message || 'Ein Fehler ist aufgetreten';
const enhancedError = new Error(message); return Promise.reject(new Error(message));
return Promise.reject(enhancedError); },
}
); );
// Auth // Auth
@@ -52,6 +123,10 @@ export const authApi = {
const res = await api.get<ApiResponse<User>>('/auth/me'); const res = await api.get<ApiResponse<User>>('/auth/me');
return res.data; return res.data;
}, },
logout: async () => {
const res = await api.post<ApiResponse<void>>('/auth/logout');
return res.data;
},
}; };
// Customers // Customers
@@ -544,11 +619,15 @@ export const cachedEmailApi = {
return res.data; return res.data;
}, },
// Anhang-URL (view=true für inline anzeigen, sonst download) // 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) => { getAttachmentUrl: (emailId: number, filename: string, view?: boolean) => {
const token = localStorage.getItem('token'); const token = getAccessToken();
const encodedFilename = encodeURIComponent(filename); const encodedFilename = encodeURIComponent(filename);
const viewParam = view ? '&view=true' : ''; 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 // Ungelesene E-Mails zählen
getUnreadCount: async (params: { customerId?: number; contractId?: number }) => { getUnreadCount: async (params: { customerId?: number; contractId?: number }) => {
+3 -1
View File
@@ -13,9 +13,11 @@
* sauberere Lösung mit kurzlebigen Download-Tokens (signierte URLs) * sauberere Lösung mit kurzlebigen Download-Tokens (signierte URLs)
* wäre v1.1-Item. * wäre v1.1-Item.
*/ */
import { getAccessToken } from '../services/api';
export function fileUrl(path: string | null | undefined): string { export function fileUrl(path: string | null | undefined): string {
if (!path) return ''; if (!path) return '';
const token = localStorage.getItem('token'); const token = getAccessToken();
const normalizedPath = path.startsWith('/') ? path : '/' + path; const normalizedPath = path.startsWith('/') ? path : '/' + path;
const base = `/api/files/download?path=${encodeURIComponent(normalizedPath)}`; const base = `/api/files/download?path=${encodeURIComponent(normalizedPath)}`;
if (!token) return base; if (!token) return base;