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:
Generated
+35
-18
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user