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>
This commit is contained in:
@@ -406,6 +406,36 @@ export async function register(req: Request, res: Response): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kurzlebiger Download-Token (60s) für Aufrufe, die den Token in der URL
|
||||||
|
// brauchen (PDF-iframes, window.open für Audit-Export usw.). Aufrufer
|
||||||
|
// authentifiziert sich normal per Bearer-Header. Antwort: ein download-
|
||||||
|
// scoped JWT, das die Auth-Middleware nur via `?token=` akzeptiert.
|
||||||
|
export async function createDownloadToken(req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
res.status(401).json({ success: false, error: 'Nicht authentifiziert' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload: any = {
|
||||||
|
email: req.user.email,
|
||||||
|
permissions: req.user.permissions,
|
||||||
|
isCustomerPortal: !!req.user.isCustomerPortal,
|
||||||
|
};
|
||||||
|
if (req.user.userId) payload.userId = req.user.userId;
|
||||||
|
if (req.user.customerId) payload.customerId = req.user.customerId;
|
||||||
|
if ((req.user as any).representedCustomerIds) {
|
||||||
|
payload.representedCustomerIds = (req.user as any).representedCustomerIds;
|
||||||
|
}
|
||||||
|
const token = authService.signDownloadToken(payload);
|
||||||
|
res.json({ success: true, data: { token } } as ApiResponse);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Fehler beim Erstellen des Download-Tokens',
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Vom Endkunden selbst nach Einmalpasswort-Login aufgerufen, um sein eigenes
|
// Vom Endkunden selbst nach Einmalpasswort-Login aufgerufen, um sein eigenes
|
||||||
// Passwort zu vergeben. Server invalidiert die laufende Session, Frontend
|
// Passwort zu vergeben. Server invalidiert die laufende Session, Frontend
|
||||||
// loggt aus und schickt zurück zum Login.
|
// loggt aus und schickt zurück zum Login.
|
||||||
|
|||||||
@@ -1096,9 +1096,10 @@ export async function setPortalPassword(req: Request, res: Response): Promise<vo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPortalPassword(req: Request, res: Response): Promise<void> {
|
export async function getPortalPassword(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
const password = await authService.getCustomerPortalPassword(customerId);
|
const password = await authService.getCustomerPortalPassword(customerId);
|
||||||
// Klartext-Passwort-Read auditieren (CRITICAL): wer hat wann das Portal-
|
// Klartext-Passwort-Read auditieren (CRITICAL): wer hat wann das Portal-
|
||||||
// Passwort eines Kunden entschlüsselt? Wichtig für DSGVO-Nachvollziehbarkeit
|
// Passwort eines Kunden entschlüsselt? Wichtig für DSGVO-Nachvollziehbarkeit
|
||||||
|
|||||||
@@ -13,12 +13,15 @@ export async function authenticate(
|
|||||||
|
|
||||||
// Token aus Header oder Query-Parameter (für Downloads)
|
// Token aus Header oder Query-Parameter (für Downloads)
|
||||||
let token: string | null = null;
|
let token: string | null = null;
|
||||||
|
let tokenSource: 'header' | 'query' | null = null;
|
||||||
|
|
||||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
token = authHeader.split(' ')[1];
|
token = authHeader.split(' ')[1];
|
||||||
|
tokenSource = 'header';
|
||||||
} else if (req.query.token && typeof req.query.token === 'string') {
|
} else if (req.query.token && typeof req.query.token === 'string') {
|
||||||
// Fallback für Downloads: Token als Query-Parameter
|
// Fallback für Downloads: Token als Query-Parameter
|
||||||
token = req.query.token;
|
token = req.query.token;
|
||||||
|
tokenSource = 'query';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -38,7 +41,19 @@ export async function authenticate(
|
|||||||
// Endpoint. Legacy-Tokens (vor der Refresh-Token-Einführung) haben kein
|
// Endpoint. Legacy-Tokens (vor der Refresh-Token-Einführung) haben kein
|
||||||
// `type` und werden als Access akzeptiert, damit bestehende Sessions nicht
|
// `type` und werden als Access akzeptiert, damit bestehende Sessions nicht
|
||||||
// zwangsabgemeldet werden.
|
// zwangsabgemeldet werden.
|
||||||
if (decoded.type && decoded.type !== 'access') {
|
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' });
|
res.status(401).json({ success: false, error: 'Falscher Token-Typ' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,4 +19,7 @@ router.post('/password-reset/confirm', passwordResetRateLimiter, authController.
|
|||||||
// Force-Change-Password nach Einmalpasswort-Login (Kundenportal)
|
// Force-Change-Password nach Einmalpasswort-Login (Kundenportal)
|
||||||
router.post('/change-initial-portal-password', authenticate, authController.changeInitialPortalPassword);
|
router.post('/change-initial-portal-password', authenticate, authController.changeInitialPortalPassword);
|
||||||
|
|
||||||
|
// Kurzlebiger Download-Token (60s) für ?token=-Aufrufe (PDF/Export-Window)
|
||||||
|
router.post('/download-token', authenticate, authController.createDownloadToken);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -27,6 +27,17 @@ export function signRefreshToken(payload: JwtPayload): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kurzlebiger Download-Token (60s, single-purpose). Wird vom Frontend
|
||||||
|
// abgerufen, wenn ein Endpoint per `?token=` aufgerufen werden muss
|
||||||
|
// (z.B. PDF-iframe, Audit-Export-Window). Selbst wenn dieser Token in
|
||||||
|
// nginx-Access-Logs oder der Browser-History landet, ist er nach
|
||||||
|
// 60 Sekunden wertlos. Pentest Runde 7 (2026-05-17) – NIEDRIG.
|
||||||
|
export function signDownloadToken(payload: JwtPayload): string {
|
||||||
|
return jwt.sign({ ...payload, type: 'download' }, process.env.JWT_SECRET as string, {
|
||||||
|
expiresIn: '60s',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 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;
|
||||||
|
|||||||
@@ -97,6 +97,37 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ Erledigt
|
||||||
|
|
||||||
|
- [x] **🚨 Pentest Runde 7 – Hit-List durchgegangen + kurzlebige Download-Tokens**
|
||||||
|
- **Credential-Endpoints** (Contracts password/internet/sip/simcard +
|
||||||
|
Stressfrei mailbox/send/reset-password): ALLE bereits durch
|
||||||
|
`canAccessContract`/`canAccessStressfreiEmail` gesichert – keine
|
||||||
|
Lücke gefunden.
|
||||||
|
- **`GET /customers/:id/portal/password`** (Klartext-Portal-Passwort-
|
||||||
|
Abruf): hatte KEINEN `canAccessCustomer`-Check. Fix: eingefügt.
|
||||||
|
Defense in depth gegen versehentlich falsch vergebene
|
||||||
|
`customers:update`-Permission.
|
||||||
|
- **Admin-Funktionen** (factory-reset, developer/*, audit-logs/rehash,
|
||||||
|
audit-logs/customer): alle durch admin-level Permissions
|
||||||
|
(`settings:update`, `developer:access`, `audit:admin`, `audit:read`)
|
||||||
|
geschützt – Portal-User haben diese nicht.
|
||||||
|
- **Token-in-URL (NIEDRIG)**: Langlebige Access-JWTs landeten als
|
||||||
|
`?token=` in URLs für PDF-iframe, Audit-Log-Export, PDF-Generate
|
||||||
|
und Portal-Privacy-PDF → nginx-Access-Logs, Browser-History,
|
||||||
|
Referer-Header.
|
||||||
|
* **Neuer Mechanismus**: `POST /api/auth/download-token` liefert
|
||||||
|
ein kurzlebiges JWT mit `type: 'download'` und `exp: 60s`.
|
||||||
|
* Auth-Middleware akzeptiert `type: 'download'` AUSSCHLIESSLICH
|
||||||
|
via `?token=` Query, niemals als Bearer-Header. So kann ein in
|
||||||
|
Logs geleaktes Download-Token nicht für reguläre API-Aufrufe
|
||||||
|
missbraucht werden.
|
||||||
|
* Frontend-Migration: 4 Stellen umgestellt (Audit-Log-Export,
|
||||||
|
PDF-Template-Preview, PDF-Generate von ContractDetail + Modal,
|
||||||
|
Portal-Privacy-PDF). `fileUrl` und `getAttachmentUrl` sind
|
||||||
|
synchron und in vielen Components verstreut – Migration dieser
|
||||||
|
bleibt als Folge-Aufgabe.
|
||||||
|
* Live-verifiziert: Download-Token = 1773 Zeichen, type=download,
|
||||||
|
exp-iat=60s, als Header → 401, als ?token= → 200.
|
||||||
|
|
||||||
- [x] **🚨 Pentest Runde 6 – Sammelfix + Strukturelles Audit (8 Findings + Audit-Sweep)**
|
- [x] **🚨 Pentest Runde 6 – Sammelfix + Strukturelles Audit (8 Findings + Audit-Sweep)**
|
||||||
- **KRITISCH-01 `GET /emails/:id/thread`**: kein Owner-Check →
|
- **KRITISCH-01 `GET /emails/:id/thread`**: kein Owner-Check →
|
||||||
Portal-Kunde konnte alle Mail-Threads durchsuchen. Fix:
|
Portal-Kunde konnte alle Mail-Threads durchsuchen. Fix:
|
||||||
|
|||||||
@@ -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, getAccessToken } from '../../services/api';
|
import { contractApi, uploadApi, meterApi, contractTaskApi, appSettingsApi, gdprApi, pdfTemplateApi, authApi } 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';
|
||||||
@@ -3483,14 +3483,14 @@ function GenerateOrderButton({ contractId }: { contractId: number }) {
|
|||||||
if (inputs && (inputs.needsStressfreiEmail || inputs.manualFields.length > 0)) {
|
if (inputs && (inputs.needsStressfreiEmail || inputs.manualFields.length > 0)) {
|
||||||
setShowInputModal({ templateId, templateName });
|
setShowInputModal({ templateId, templateName });
|
||||||
} else {
|
} else {
|
||||||
// Direkt generieren (GET-Link)
|
// Direkt generieren (GET-Link) – kurzlebiger Download-Token
|
||||||
const token = getAccessToken();
|
const downloadToken = await authApi.getDownloadToken();
|
||||||
window.open(`${pdfTemplateApi.generateUrl(templateId, contractId)}?token=${token || ''}`, '_blank');
|
window.open(`${pdfTemplateApi.generateUrl(templateId, contractId)}?token=${downloadToken ?? ''}`, '_blank');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback: direkt generieren
|
// Fallback: direkt generieren
|
||||||
const token = getAccessToken();
|
const downloadToken = await authApi.getDownloadToken();
|
||||||
window.open(`${pdfTemplateApi.generateUrl(templateId, contractId)}?token=${token || ''}`, '_blank');
|
window.open(`${pdfTemplateApi.generateUrl(templateId, contractId)}?token=${downloadToken ?? ''}`, '_blank');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3561,10 +3561,10 @@ function GenerateInputModal({ templateId, templateName, contractId, onClose }: {
|
|||||||
|
|
||||||
const inputs = inputsData?.data;
|
const inputs = inputsData?.data;
|
||||||
|
|
||||||
const handleGenerate = () => {
|
const handleGenerate = async () => {
|
||||||
const token = getAccessToken();
|
const downloadToken = await authApi.getDownloadToken();
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('token', token || '');
|
params.set('token', downloadToken ?? '');
|
||||||
if (stressfreiEmailId) params.set('stressfreiEmailId', stressfreiEmailId);
|
if (stressfreiEmailId) params.set('stressfreiEmailId', stressfreiEmailId);
|
||||||
for (const [key, value] of Object.entries(manualValues)) {
|
for (const [key, value] of Object.entries(manualValues)) {
|
||||||
if (value) params.set(`manual_${key}`, value);
|
if (value) params.set(`manual_${key}`, value);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
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, getAccessToken } from '../../services/api';
|
import { gdprApi, authApi } from '../../services/api';
|
||||||
import type { ConsentType, ConsentStatus, CustomerConsent } from '../../types';
|
import type { ConsentType, ConsentStatus, CustomerConsent } from '../../types';
|
||||||
import {
|
import {
|
||||||
Shield,
|
Shield,
|
||||||
@@ -93,7 +94,14 @@ 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 = getAccessToken();
|
|
||||||
|
// Kurzlebigen Download-Token (60s) für den PDF-Link.
|
||||||
|
const [pdfToken, setPdfToken] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
authApi.getDownloadToken().then((t) => { if (!cancelled) setPdfToken(t); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -172,8 +180,9 @@ export default function PortalPrivacy() {
|
|||||||
{/* Datenschutzerklärung */}
|
{/* Datenschutzerklärung */}
|
||||||
<Card title="Datenschutzerklärung" className="mb-6">
|
<Card title="Datenschutzerklärung" className="mb-6">
|
||||||
<div className="flex justify-end mb-4">
|
<div className="flex justify-end mb-4">
|
||||||
|
{pdfToken && (
|
||||||
<a
|
<a
|
||||||
href={`${gdprApi.getMyPrivacyPdfUrl}?token=${token}`}
|
href={`${gdprApi.getMyPrivacyPdfUrl}?token=${pdfToken}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-2 text-sm text-blue-600 hover:text-blue-800"
|
className="flex items-center gap-2 text-sm text-blue-600 hover:text-blue-800"
|
||||||
@@ -181,6 +190,7 @@ export default function PortalPrivacy() {
|
|||||||
<FileDown className="w-4 h-4" />
|
<FileDown className="w-4 h-4" />
|
||||||
Als PDF herunterladen
|
Als PDF herunterladen
|
||||||
</a>
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="prose prose-sm max-w-none"
|
className="prose prose-sm max-w-none"
|
||||||
|
|||||||
@@ -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, getAccessToken } from '../../services/api';
|
import { auditLogApi, AuditLogSearchParams, authApi } 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';
|
||||||
@@ -300,8 +300,9 @@ export default function AuditLogs() {
|
|||||||
const handleExport = async (format: 'json' | 'csv') => {
|
const handleExport = async (format: 'json' | 'csv') => {
|
||||||
try {
|
try {
|
||||||
if (format === 'csv') {
|
if (format === 'csv') {
|
||||||
// CSV direkt als Download
|
// CSV direkt als Download – mit kurzlebigem Download-Token (60s),
|
||||||
const token = getAccessToken();
|
// damit das langlebige Access-JWT nicht in Logs/Referer/History landet.
|
||||||
|
const downloadToken = await authApi.getDownloadToken();
|
||||||
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);
|
||||||
@@ -309,7 +310,7 @@ export default function AuditLogs() {
|
|||||||
if (filters.resourceType) params.set('resourceType', filters.resourceType);
|
if (filters.resourceType) params.set('resourceType', filters.resourceType);
|
||||||
if (filters.startDate) params.set('startDate', filters.startDate);
|
if (filters.startDate) params.set('startDate', filters.startDate);
|
||||||
if (filters.endDate) params.set('endDate', filters.endDate);
|
if (filters.endDate) params.set('endDate', filters.endDate);
|
||||||
window.open(`/api/audit-logs/export?${params}&token=${token}`, '_blank');
|
window.open(`/api/audit-logs/export?${params}&token=${downloadToken ?? ''}`, '_blank');
|
||||||
} else {
|
} else {
|
||||||
const result = await auditLogApi.export({ ...filters, format });
|
const result = await auditLogApi.export({ ...filters, format });
|
||||||
const blob = new Blob(
|
const blob = new Blob(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, 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, getAccessToken } from '../../services/api';
|
import { pdfTemplateApi, contractApi, authApi } 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';
|
||||||
@@ -266,6 +266,16 @@ function FieldMappingModal({ template, onClose }: { template: PdfTemplate; onClo
|
|||||||
}, {} as Record<string, CrmField[]>);
|
}, {} as Record<string, CrmField[]>);
|
||||||
|
|
||||||
const [highlightedField, setHighlightedField] = useState<string | null>(null);
|
const [highlightedField, setHighlightedField] = useState<string | null>(null);
|
||||||
|
// Kurzlebigen Download-Token (60s) für die iframe-URL holen, damit das
|
||||||
|
// langlebige Access-JWT nicht in Browser-History/Referer landet.
|
||||||
|
const [previewToken, setPreviewToken] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
authApi.getDownloadToken().then((t) => {
|
||||||
|
if (!cancelled) setPreviewToken(t);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [template.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex">
|
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex">
|
||||||
@@ -275,11 +285,13 @@ function FieldMappingModal({ template, onClose }: { template: PdfTemplate; onClo
|
|||||||
<span>PDF-Vorschau mit Feldnamen</span>
|
<span>PDF-Vorschau mit Feldnamen</span>
|
||||||
<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>
|
||||||
|
{previewToken && (
|
||||||
<iframe
|
<iframe
|
||||||
src={`/api/pdf-templates/${template.id}/preview?token=${getAccessToken() || ''}`}
|
src={`/api/pdf-templates/${template.id}/preview?token=${previewToken}`}
|
||||||
className="flex-1 w-full bg-white"
|
className="flex-1 w-full bg-white"
|
||||||
title="PDF Vorschau mit Feldnamen"
|
title="PDF Vorschau mit Feldnamen"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Zuordnung rechts */}
|
{/* Zuordnung rechts */}
|
||||||
@@ -428,11 +440,12 @@ function TestPreviewModal({ template, onClose }: { template: PdfTemplate; onClos
|
|||||||
});
|
});
|
||||||
|
|
||||||
const contracts: Contract[] = contractsData?.data || [];
|
const contracts: Contract[] = contractsData?.data || [];
|
||||||
const token = getAccessToken();
|
|
||||||
|
|
||||||
const handleGenerate = () => {
|
const handleGenerate = async () => {
|
||||||
if (!selectedContractId) return;
|
if (!selectedContractId) return;
|
||||||
const url = `${pdfTemplateApi.generateUrl(template.id, selectedContractId)}?token=${token || ''}`;
|
// Kurzlebigen Download-Token (60s) statt langlebigem Access-JWT in der URL.
|
||||||
|
const downloadToken = await authApi.getDownloadToken();
|
||||||
|
const url = `${pdfTemplateApi.generateUrl(template.id, selectedContractId)}?token=${downloadToken ?? ''}`;
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,18 @@ export const authApi = {
|
|||||||
const res = await api.post<ApiResponse<void>>('/auth/change-initial-portal-password', { newPassword });
|
const res = await api.post<ApiResponse<void>>('/auth/change-initial-portal-password', { newPassword });
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
// Kurzlebiger Download-Token (60s) für URL-basierte Aufrufe wie
|
||||||
|
// iframe-PDF-Preview oder window.open mit ?token=. Selbst wenn dieser
|
||||||
|
// Token in einem Access-Log oder der Browser-History landet, ist er nach
|
||||||
|
// einer Minute wertlos.
|
||||||
|
getDownloadToken: async (): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const res = await api.post<ApiResponse<{ token: string }>>('/auth/download-token');
|
||||||
|
return res.data?.data?.token || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Customers
|
// Customers
|
||||||
|
|||||||
Reference in New Issue
Block a user