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:
@@ -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