diff --git a/backend/package-lock.json b/backend/package-lock.json index 0517d1a6..7b25bbd5 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 69ce0c4e..5e2f0ee1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts index 78d48acd..51f5283e 100644 --- a/backend/src/controllers/auth.controller.ts +++ b/backend/src/controllers/auth.controller.ts @@ -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 { const { email, password } = req.body || {}; @@ -18,6 +40,9 @@ export async function login(req: Request, res: Response): Promise { } 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 { 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 } 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 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 { 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 { } } +// 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 { + 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 { try { const { email, password, firstName, lastName, roleIds } = req.body; diff --git a/backend/src/index.ts b/backend/src/index.ts index 5436ea24..fa0c64ae 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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); diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index b59377d5..14b69f3f 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -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) { diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index f65445f1..caa2f41f 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -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); diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts index 2213ca54..0ccf9dcf 100644 --- a/backend/src/services/auth.service.ts +++ b/backend/src/services/auth.service.ts @@ -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(); + 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); diff --git a/docs/todo.md b/docs/todo.md index f15caa6d..97598c24 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,41 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ 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** - Pentest-Finding „Klartext-Passwörter über API abrufbar (HIGH, post-auth)" → reversible Verschlüsselung ist by-design (Feature diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 787c3be9..b7708f71 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -1,5 +1,6 @@ import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; -import { authApi } from '../services/api'; +import axios from 'axios'; +import { authApi, setAccessToken, getAccessToken } from '../services/api'; import type { User } from '../types'; interface AuthContextType { @@ -8,7 +9,7 @@ interface AuthContextType { isAuthenticated: boolean; login: (email: string, password: string) => Promise; customerLogin: (email: string, password: string) => Promise; - logout: () => void; + logout: () => Promise; hasPermission: (permission: string) => boolean; isCustomer: boolean; isCustomerPortal: boolean; @@ -40,32 +41,31 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }, [user, developerMode]); + // Beim App-Start versuchen, einen Access-Token via Refresh-Cookie zu holen. + // Wenn das klappt → User ist eingeloggt. Wenn nicht → User muss sich neu + // anmelden. Der Access-Token bleibt nur im memory (kein localStorage). useEffect(() => { - const token = localStorage.getItem('token'); - if (token) { - authApi.me() - .then((res) => { - if (res.success && res.data) { - setUser(res.data); - } else { - localStorage.removeItem('token'); - } - }) - .catch(() => { - localStorage.removeItem('token'); - }) - .finally(() => { - setIsLoading(false); - }); - } else { - setIsLoading(false); - } + (async () => { + try { + const res = await axios.post('/api/auth/refresh', {}, { withCredentials: true }); + if (res.data?.success && res.data?.data?.token) { + setAccessToken(res.data.data.token); + // Danach den vollen User aus /me laden (Permissions etc.) + const me = await authApi.me(); + if (me.success && me.data) setUser(me.data); + } + } catch { + // Kein gültiger Refresh-Cookie → User ist nicht eingeloggt + } finally { + setIsLoading(false); + } + })(); }, []); const login = async (email: string, password: string) => { const res = await authApi.login(email, password); if (res.success && res.data) { - localStorage.setItem('token', res.data.token); + setAccessToken(res.data.token); setUser(res.data.user); } else { throw new Error(res.error || 'Login fehlgeschlagen'); @@ -75,31 +75,31 @@ export function AuthProvider({ children }: { children: ReactNode }) { const customerLogin = async (email: string, password: string) => { const res = await authApi.customerLogin(email, password); if (res.success && res.data) { - localStorage.setItem('token', res.data.token); + setAccessToken(res.data.token); setUser(res.data.user); } else { throw new Error(res.error || 'Login fehlgeschlagen'); } }; - const logout = () => { - localStorage.removeItem('token'); + const logout = async () => { + // Server-Logout: invalidiert Refresh-Token-Cookie + tokenInvalidatedAt + try { + await authApi.logout(); + } catch { + // Selbst wenn der Server-Logout fehlschlägt: client-side clear + } + setAccessToken(null); setUser(null); }; const refreshUser = async () => { - const token = localStorage.getItem('token'); - if (token) { - try { - const res = await authApi.me(); - console.log('refreshUser response:', res); - console.log('permissions:', res.data?.permissions); - if (res.success && res.data) { - setUser(res.data); - } - } catch (err) { - console.error('refreshUser error:', err); - } + if (!getAccessToken()) return; + try { + const res = await authApi.me(); + if (res.success && res.data) setUser(res.data); + } catch (err) { + console.error('refreshUser error:', err); } }; diff --git a/frontend/src/pages/contracts/ContractDetail.tsx b/frontend/src/pages/contracts/ContractDetail.tsx index 0a086187..120ed0d0 100644 --- a/frontend/src/pages/contracts/ContractDetail.tsx +++ b/frontend/src/pages/contracts/ContractDetail.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { useParams, Link, useNavigate, useLocation } from 'react-router-dom'; import { pushHistory, popHistory } from '../../utils/navigation'; 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 { ContractDetailModal, ContractHistorySection } from '../../components/contracts'; import InvoicesSection from '../../components/contracts/InvoicesSection'; @@ -3484,13 +3484,13 @@ function GenerateOrderButton({ contractId }: { contractId: number }) { setShowInputModal({ templateId, templateName }); } else { // Direkt generieren (GET-Link) - const token = localStorage.getItem('token'); - window.open(`${pdfTemplateApi.generateUrl(templateId, contractId)}?token=${token}`, '_blank'); + const token = getAccessToken(); + window.open(`${pdfTemplateApi.generateUrl(templateId, contractId)}?token=${token || ''}`, '_blank'); } } catch { // Fallback: direkt generieren - const token = localStorage.getItem('token'); - window.open(`${pdfTemplateApi.generateUrl(templateId, contractId)}?token=${token}`, '_blank'); + const token = getAccessToken(); + window.open(`${pdfTemplateApi.generateUrl(templateId, contractId)}?token=${token || ''}`, '_blank'); } }; @@ -3562,7 +3562,7 @@ function GenerateInputModal({ templateId, templateName, contractId, onClose }: { const inputs = inputsData?.data; const handleGenerate = () => { - const token = localStorage.getItem('token'); + const token = getAccessToken(); const params = new URLSearchParams(); params.set('token', token || ''); if (stressfreiEmailId) params.set('stressfreiEmailId', stressfreiEmailId); diff --git a/frontend/src/pages/portal/PortalPrivacy.tsx b/frontend/src/pages/portal/PortalPrivacy.tsx index 382b9203..857f48ef 100644 --- a/frontend/src/pages/portal/PortalPrivacy.tsx +++ b/frontend/src/pages/portal/PortalPrivacy.tsx @@ -1,6 +1,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useAuth } from '../../context/AuthContext'; -import { gdprApi } from '../../services/api'; +import { gdprApi, getAccessToken } from '../../services/api'; import type { ConsentType, ConsentStatus, CustomerConsent } from '../../types'; import { Shield, @@ -93,7 +93,7 @@ export default function PortalPrivacy() { const consents = data?.data?.consents || []; const privacyPolicyHtml = data?.data?.privacyPolicyHtml || ''; const allGranted = consents.every((c) => c.status === 'GRANTED'); - const token = localStorage.getItem('token'); + const token = getAccessToken(); return (
diff --git a/frontend/src/pages/settings/AuditLogs.tsx b/frontend/src/pages/settings/AuditLogs.tsx index e9dc2997..ce978004 100644 --- a/frontend/src/pages/settings/AuditLogs.tsx +++ b/frontend/src/pages/settings/AuditLogs.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; 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 Card from '../../components/ui/Card'; import Button from '../../components/ui/Button'; @@ -301,7 +301,7 @@ export default function AuditLogs() { try { if (format === 'csv') { // CSV direkt als Download - const token = localStorage.getItem('token'); + const token = getAccessToken(); const params = new URLSearchParams(); params.set('format', 'csv'); if (filters.action) params.set('action', filters.action); diff --git a/frontend/src/pages/settings/DatabaseBackup.tsx b/frontend/src/pages/settings/DatabaseBackup.tsx index b5feb5f4..1d30a8c3 100644 --- a/frontend/src/pages/settings/DatabaseBackup.tsx +++ b/frontend/src/pages/settings/DatabaseBackup.tsx @@ -1,7 +1,7 @@ import { useState, useRef } from 'react'; 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 { backupApi, BackupInfo } from '../../services/api'; +import { backupApi, BackupInfo, getAccessToken } from '../../services/api'; import { useAuth } from '../../context/AuthContext'; import Button from '../../components/ui/Button'; @@ -90,7 +90,7 @@ export default function DatabaseBackup() { // Download mit Auth-Token const handleDownload = async (name: string) => { - const token = localStorage.getItem('token'); + const token = getAccessToken(); const url = backupApi.getDownloadUrl(name); try { diff --git a/frontend/src/pages/settings/PdfTemplates.tsx b/frontend/src/pages/settings/PdfTemplates.tsx index e4b432e8..84f1ef48 100644 --- a/frontend/src/pages/settings/PdfTemplates.tsx +++ b/frontend/src/pages/settings/PdfTemplates.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; 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 Card from '../../components/ui/Card'; import Button from '../../components/ui/Button'; @@ -276,7 +276,7 @@ function FieldMappingModal({ template, onClose }: { template: PdfTemplate; onClo [Feldname] zeigt wo das Feld in der PDF liegt