Files
opencrm/backend/src/middleware/auth.ts
T
duffyduck 69b9a35674 Security-Hardening Runde 11: Pentest Runde 7 (Portal-PW + Download-Tokens)
Hit-List vom Pentester abgearbeitet. Hauptpunkte:

1) Contract/Mail-Credentials (password/internet/sip/simcard, mailbox/send/
   reset-password): ALLE bereits durch canAccess* gesichert, keine Lücke.

2) GET /customers/:id/portal/password (Klartext-Portal-PW-Abruf):
   fehlender canAccessCustomer-Check ergänzt. Defense in depth gegen
   versehentliche customers:update-Permission an Portal/eingeschränkte
   Mitarbeiter.

3) Admin-Endpoints (factory-reset, developer/*, audit-logs/rehash,
   audit-logs/customer): durch admin-Permissions geschützt – Portal-User
   haben diese nicht.

4) Token-in-URL (NIEDRIG): Langlebige Access-JWTs landeten als ?token= in
   URLs für iframe-PDFs, Audit-Export-Window etc. → nginx-Logs +
   Browser-History + Referer.
   Lösung: kurzlebige Download-Tokens.
   - signDownloadToken() liefert JWT mit type='download', exp=60s
   - Auth-Middleware akzeptiert type='download' AUSSCHLIESSLICH via
     ?token=, niemals als Bearer-Header
   - POST /api/auth/download-token Endpoint (authenticated)
   - Frontend: authApi.getDownloadToken() utility
   - 4 Stellen migriert: AuditLog-Export, PdfTemplate-Preview-iframe,
     PdfTemplate-Generate, ContractDetail-PDF-Generate (2x),
     Portal-Privacy-PDF
   - fileUrl/getAttachmentUrl sind synchron + breit gestreut – Migration
     bleibt für Folge-PR

Live-verifiziert:
- Download-Token: 1773 Zeichen, type=download, exp-iat=60s
- als Header → 401 (Falscher Token-Typ), als ?token= → 200
- portal-user (Customer 3) auf customers/2/portal/password → 403

Rate-Limiter-Check: express-rate-limit Fixed-Window, kein Reset bei jedem
Request (Pentester-Klage „Fenster reseted sich" stimmt mit dem Code nicht
überein – wahrscheinlich Retry-After-Misinterpretation). Kein Code-Bug
identifiziert; ggf. später Admin-Override-Endpoint nachrüsten.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:40:00 +02:00

189 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import prisma from '../lib/prisma.js';
import { AuthRequest, JwtPayload } from '../types/index.js';
import { emit as emitSecurityEvent } from '../services/securityMonitor.service.js';
export async function authenticate(
req: AuthRequest,
res: Response,
next: NextFunction
): Promise<void> {
const authHeader = req.headers.authorization;
// Token aus Header oder Query-Parameter (für Downloads)
let token: string | null = null;
let tokenSource: 'header' | 'query' | null = null;
if (authHeader && authHeader.startsWith('Bearer ')) {
token = authHeader.split(' ')[1];
tokenSource = 'header';
} else if (req.query.token && typeof req.query.token === 'string') {
// Fallback für Downloads: Token als Query-Parameter
token = req.query.token;
tokenSource = 'query';
}
if (!token) {
res.status(401).json({ success: false, error: 'Nicht authentifiziert' });
return;
}
try {
// JWT_SECRET wird beim Server-Start geprüft (Fail-Fast in index.ts)
// 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 & { 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 === 'refresh') {
res.status(401).json({ success: false, error: 'Falscher Token-Typ' });
return;
}
// Download-Tokens sind kurzlebig (60s) und dürfen NUR per `?token=`
// genutzt werden, NIE als Bearer-Header. Damit kann ein in einer URL
// geleakter Download-Token nicht für reguläre API-Aufrufe missbraucht
// werden (Pentest Runde 7 NIEDRIG, Token-in-URL-Defense).
if (decoded.type === 'download' && tokenSource !== 'query') {
res.status(401).json({ success: false, error: 'Falscher Token-Typ' });
return;
}
if (decoded.type && decoded.type !== 'access' && decoded.type !== 'download') {
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) {
// Mitarbeiter-Login
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { tokenInvalidatedAt: true, isActive: true },
});
if (!user || !user.isActive) {
res.status(401).json({ success: false, error: 'Benutzer nicht mehr aktiv' });
return;
}
if (user.tokenInvalidatedAt) {
const tokenIssuedAt = decoded.iat * 1000;
if (tokenIssuedAt < user.tokenInvalidatedAt.getTime()) {
res.status(401).json({
success: false,
error: 'Ihre Berechtigungen wurden geändert. Bitte melden Sie sich erneut an.',
});
return;
}
}
} else if (decoded.isCustomerPortal && decoded.customerId && decoded.iat) {
// Portal-Kunden-Login: gleiche Prüfung
const customer = await prisma.customer.findUnique({
where: { id: decoded.customerId },
select: { portalTokenInvalidatedAt: true, portalEnabled: true },
});
if (!customer || !customer.portalEnabled) {
res.status(401).json({ success: false, error: 'Portal-Zugang nicht mehr aktiv' });
return;
}
if (customer.portalTokenInvalidatedAt) {
const tokenIssuedAt = decoded.iat * 1000;
if (tokenIssuedAt < customer.portalTokenInvalidatedAt.getTime()) {
res.status(401).json({
success: false,
error: 'Ihre Sitzung ist ungültig. Bitte melden Sie sich erneut an.',
});
return;
}
}
}
req.user = decoded;
next();
} catch (err) {
// JWT-Failures sind interessant: alg=none, manipulierte Signature,
// expired Token. Emit SecurityEvent (asynchron, blockt nicht).
emitSecurityEvent({
type: 'TOKEN_REJECTED',
severity: err instanceof jwt.TokenExpiredError ? 'LOW' : 'HIGH',
message: err instanceof Error ? `JWT abgelehnt: ${err.message}` : 'JWT abgelehnt',
ipAddress: req.ip || (req.socket as any)?.remoteAddress || 'unknown',
endpoint: `${req.method} ${req.path}`,
});
res.status(401).json({ success: false, error: 'Ungültiger Token' });
}
}
export function requirePermission(...requiredPermissions: string[]) {
return (req: AuthRequest, res: Response, next: NextFunction): void => {
if (!req.user) {
res.status(401).json({ success: false, error: 'Nicht authentifiziert' });
return;
}
const userPermissions = req.user.permissions || [];
// Check if user has any of the required permissions
const hasPermission = requiredPermissions.some((perm) =>
userPermissions.includes(perm)
);
if (!hasPermission) {
res.status(403).json({
success: false,
error: 'Keine Berechtigung für diese Aktion',
});
return;
}
next();
};
}
// Middleware to check if user can access specific customer data
export function requireCustomerAccess(
req: AuthRequest,
res: Response,
next: NextFunction
): void {
if (!req.user) {
res.status(401).json({ success: false, error: 'Nicht authentifiziert' });
return;
}
const userPermissions = req.user.permissions || [];
// Admins and employees can access all customers
if (
userPermissions.includes('customers:read') ||
userPermissions.includes('customers:update')
) {
next();
return;
}
// Customers can only access their own data + represented customers
const customerId = parseInt(req.params.customerId || req.params.id);
const allowedIds = [
req.user.customerId,
...((req.user as any).representedCustomerIds || []),
].filter(Boolean);
if (allowedIds.includes(customerId)) {
next();
return;
}
res.status(403).json({
success: false,
error: 'Kein Zugriff auf diese Kundendaten',
});
}