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",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^7.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"dotenv-expand": "^13.0.0",
|
||||
@@ -608,7 +610,6 @@
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/connect": "*",
|
||||
"@types/node": "*"
|
||||
@@ -618,11 +619,19 @@
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@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": {
|
||||
"version": "2.8.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||
@@ -636,7 +645,6 @@
|
||||
"version": "4.17.25",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
|
||||
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^4.17.33",
|
||||
@@ -648,7 +656,6 @@
|
||||
"version": "4.19.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz",
|
||||
"integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"@types/qs": "*",
|
||||
@@ -659,8 +666,7 @@
|
||||
"node_modules/@types/http-errors": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||
"dev": true
|
||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="
|
||||
},
|
||||
"node_modules/@types/jsonwebtoken": {
|
||||
"version": "9.0.10",
|
||||
@@ -697,8 +703,7 @@
|
||||
"node_modules/@types/mime": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
||||
"dev": true
|
||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
@@ -719,7 +724,6 @@
|
||||
"version": "22.19.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
|
||||
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -752,14 +756,12 @@
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="
|
||||
},
|
||||
"node_modules/@types/range-parser": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="
|
||||
},
|
||||
"node_modules/@types/readdir-glob": {
|
||||
"version": "1.1.5",
|
||||
@@ -774,7 +776,6 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
@@ -783,7 +784,6 @@
|
||||
"version": "1.15.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
|
||||
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/http-errors": "*",
|
||||
"@types/node": "*",
|
||||
@@ -794,7 +794,6 @@
|
||||
"version": "0.17.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
|
||||
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/mime": "^1",
|
||||
"@types/node": "*"
|
||||
@@ -1252,6 +1251,25 @@
|
||||
"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": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
@@ -3374,8 +3392,7 @@
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
|
||||
},
|
||||
"node_modules/unicode-properties": {
|
||||
"version": "1.4.1",
|
||||
|
||||
@@ -21,9 +21,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^7.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"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 { AuthRequest, ApiResponse } from '../types/index.js';
|
||||
import prisma from '../lib/prisma.js';
|
||||
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
||||
|
||||
// Refresh-Token-Cookie-Konfiguration. Der Cookie:
|
||||
// - httpOnly → kein JavaScript-Zugriff → bei XSS nicht klaubar
|
||||
// - secure → nur über HTTPS (in Prod via HTTPS_ENABLED, in Dev egal)
|
||||
// - sameSite 'strict' → CSRF-Schutz; Cross-Site-Requests senden den Cookie nicht
|
||||
// - path '/api/auth' → wird nur an Auth-Endpoints mitgeschickt
|
||||
const REFRESH_COOKIE_NAME = 'refresh_token';
|
||||
function getRefreshCookieOptions(): CookieOptions {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: process.env.HTTPS_ENABLED === 'true',
|
||||
sameSite: 'strict',
|
||||
path: '/api/auth',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 Tage, gleicht Refresh-JWT-Lifetime
|
||||
};
|
||||
}
|
||||
function setRefreshCookie(res: Response, token: string): void {
|
||||
res.cookie(REFRESH_COOKIE_NAME, token, getRefreshCookieOptions());
|
||||
}
|
||||
function clearRefreshCookie(res: Response): void {
|
||||
res.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' });
|
||||
}
|
||||
|
||||
// Mitarbeiter-Login
|
||||
export async function login(req: Request, res: Response): Promise<void> {
|
||||
const { email, password } = req.body || {};
|
||||
@@ -18,6 +40,9 @@ export async function login(req: Request, res: Response): Promise<void> {
|
||||
}
|
||||
|
||||
const result = await authService.login(email, password);
|
||||
// Refresh-Token in httpOnly-Cookie, Access-Token im Body (Frontend hält
|
||||
// ihn nur in memory). `token`-Feld bleibt aus Kompatibilität bestehen.
|
||||
setRefreshCookie(res, result.refreshToken);
|
||||
emitSecurityEvent({
|
||||
type: 'LOGIN_SUCCESS',
|
||||
severity: 'INFO',
|
||||
@@ -27,7 +52,10 @@ export async function login(req: Request, res: Response): Promise<void> {
|
||||
userEmail: email,
|
||||
endpoint: ctx.endpoint,
|
||||
});
|
||||
res.json({ success: true, data: result } as ApiResponse);
|
||||
res.json({
|
||||
success: true,
|
||||
data: { token: result.accessToken, user: result.user },
|
||||
} as ApiResponse);
|
||||
} catch (error) {
|
||||
emitSecurityEvent({
|
||||
type: 'LOGIN_FAILED',
|
||||
@@ -58,6 +86,7 @@ export async function customerLogin(req: Request, res: Response): Promise<void>
|
||||
}
|
||||
|
||||
const result = await authService.customerLogin(email, password);
|
||||
setRefreshCookie(res, result.refreshToken);
|
||||
emitSecurityEvent({
|
||||
type: 'LOGIN_SUCCESS',
|
||||
severity: 'INFO',
|
||||
@@ -67,7 +96,10 @@ export async function customerLogin(req: Request, res: Response): Promise<void>
|
||||
userEmail: email,
|
||||
endpoint: ctx.endpoint,
|
||||
});
|
||||
res.json({ success: true, data: result } as ApiResponse);
|
||||
res.json({
|
||||
success: true,
|
||||
data: { token: result.accessToken, user: result.user },
|
||||
} as ApiResponse);
|
||||
} catch (error) {
|
||||
emitSecurityEvent({
|
||||
type: 'LOGIN_FAILED',
|
||||
@@ -257,6 +289,10 @@ export async function logout(req: AuthRequest, res: Response): Promise<void> {
|
||||
data: { tokenInvalidatedAt: new Date() },
|
||||
});
|
||||
}
|
||||
// Refresh-Cookie löschen, sonst könnte der Browser einen abgemeldeten User
|
||||
// direkt wieder einloggen (server-seitige Invalidation oben fängt das ab,
|
||||
// aber UI würde sich verirren).
|
||||
clearRefreshCookie(res);
|
||||
const ctx = contextFromRequest(req);
|
||||
emitSecurityEvent({
|
||||
type: 'LOGOUT',
|
||||
@@ -277,6 +313,36 @@ export async function logout(req: AuthRequest, res: Response): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Neuen Access-Token aus dem httpOnly-Refresh-Cookie holen. Wird vom Frontend
|
||||
// (axios-Interceptor) bei 401 oder beim App-Start aufgerufen.
|
||||
export async function refresh(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const cookies = (req as any).cookies || {};
|
||||
const refreshToken = cookies[REFRESH_COOKIE_NAME];
|
||||
if (!refreshToken) {
|
||||
res.status(401).json({ success: false, error: 'Kein Refresh-Token vorhanden' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await authService.refreshAccessToken(refreshToken);
|
||||
// Refresh-Cookie rotieren – verhindert Replay eines geklauten Refresh-Tokens
|
||||
// bis zur vollen Lifetime.
|
||||
setRefreshCookie(res, result.refreshToken);
|
||||
res.json({
|
||||
success: true,
|
||||
data: { token: result.accessToken, user: result.user },
|
||||
} as ApiResponse);
|
||||
} catch (error) {
|
||||
// Refresh fehlgeschlagen: Cookie wegputzen, damit der Browser nicht
|
||||
// weiter mit einem invaliden Token weiterhin den Endpoint klopft.
|
||||
clearRefreshCookie(res);
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Refresh fehlgeschlagen',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
export async function register(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { email, password, firstName, lastName, roleIds } = req.body;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import express from 'express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import path from 'path';
|
||||
@@ -212,6 +213,9 @@ app.use(
|
||||
|
||||
// JSON-Body-Limit: 5 MB (Uploads laufen über multer, brauchen kein json())
|
||||
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)
|
||||
app.use(auditContextMiddleware);
|
||||
|
||||
@@ -31,7 +31,17 @@ export async function authenticate(
|
||||
// Algorithmus explizit auf HS256 festlegen (Defense-in-Depth gegen alg-confusion).
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET as string, {
|
||||
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
|
||||
if (decoded.userId && decoded.iat) {
|
||||
|
||||
@@ -7,6 +7,7 @@ const router = Router();
|
||||
|
||||
router.post('/login', loginRateLimiter, authController.login);
|
||||
router.post('/customer-login', loginRateLimiter, authController.customerLogin);
|
||||
router.post('/refresh', authController.refresh);
|
||||
router.get('/me', authenticate, authController.me);
|
||||
router.post('/logout', authenticate, authController.logout);
|
||||
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 { 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.
|
||||
// Bestehende Hashes mit Faktor 10 bleiben gültig (bcrypt kodiert den Faktor im Hash).
|
||||
const BCRYPT_COST = 12;
|
||||
@@ -100,12 +120,12 @@ export async function login(email: string, password: string) {
|
||||
isCustomerPortal: false,
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, process.env.JWT_SECRET as string, {
|
||||
expiresIn: (process.env.JWT_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn'],
|
||||
});
|
||||
const accessToken = signAccessToken(payload);
|
||||
const refreshToken = signRefreshToken(payload);
|
||||
|
||||
return {
|
||||
token,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
@@ -188,12 +208,12 @@ export async function customerLogin(email: string, password: string) {
|
||||
representedCustomerIds,
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, process.env.JWT_SECRET as string, {
|
||||
expiresIn: (process.env.JWT_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn'],
|
||||
});
|
||||
const accessToken = signAccessToken(payload);
|
||||
const refreshToken = signRefreshToken(payload);
|
||||
|
||||
return {
|
||||
token,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: customer.id,
|
||||
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
|
||||
export async function setCustomerPortalPassword(customerId: number, password: string) {
|
||||
console.log('[SetPortalPassword] Setze Passwort für Kunde:', customerId);
|
||||
|
||||
Reference in New Issue
Block a user