From 79f6f3e6296c90e120134890118611cc352b28ed Mon Sep 17 00:00:00 2001 From: duffyduck Date: Tue, 11 Aug 2026 22:46:53 +0200 Subject: [PATCH] Portal-Passwort: Reveal/Send prueft Konsistenz gegen Login-Hash Pentester-Hinweis: bcrypt-Hash (Login) und verschluesseltes Reveal-Feld koennen out-of-sync sein -> Support liest ein Passwort vor, das beim Login scheitert. Analyse: alle aktuellen Schreibpfade sind konsistent (beide Felder zusammen, oder encrypted=null, oder Rehash desselben Passworts) - der Code erzeugt keinen Desync. Ursache = Altlast/manueller DB-Eingriff. Fix (defensiv, unabhaengig von der Ursache): - getCustomerPortalPassword liefert {status: ok|none|desync} und prueft den entschluesselten Klartext per bcrypt.compare gegen den Login-Hash. - Bei desync (oder Entschluesselungsfehler) geben WEDER Reveal NOCH Send-Credentials das Passwort aus -> 409 'Dateninkonsistenz, bitte neu setzen'. Reveal-Read wird mit Status auditiert. - Neues Diagnose-Script scripts/check-portal-password-sync.ts scannt alle Portal-Kunden auf Desync (nur Diagnose, aendert nichts) - fuer Prod, da der Pentester keinen FS-Zugriff hat. Verifiziert: desync -> nicht ausgegeben; konsistent -> ok; kein PW -> none. Scan laeuft (0 Desync auf Dev). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/scripts/check-portal-password-sync.ts | 61 +++++++++++++++++++ .../src/controllers/customer.controller.ts | 29 +++++++-- backend/src/services/auth.service.ts | 39 ++++++++++-- docs/todo.md | 16 +++++ 4 files changed, 133 insertions(+), 12 deletions(-) create mode 100644 backend/scripts/check-portal-password-sync.ts diff --git a/backend/scripts/check-portal-password-sync.ts b/backend/scripts/check-portal-password-sync.ts new file mode 100644 index 00000000..a9d295b9 --- /dev/null +++ b/backend/scripts/check-portal-password-sync.ts @@ -0,0 +1,61 @@ +/** + * Diagnose: findet Portal-Kunden, deren gespeichertes (verschlüsseltes) + * Passwort NICHT zum Login-Hash (bcrypt) passt. Solche „Desyncs" führen dazu, + * dass der Reveal/Send-Credentials ein Passwort ausgibt, das beim Login + * scheitert. Seit dem Reveal-Konsistenzcheck werden sie nicht mehr ausgegeben – + * dieses Script findet bestehende Altlasten (z.B. aus manuellen DB-Eingriffen + * oder alten Code-Versionen), damit man sie gezielt neu setzen kann. + * + * Aufruf (im Backend-Container/-Verzeichnis): + * npx tsx scripts/check-portal-password-sync.ts + * Nur Diagnose – ändert NICHTS. + */ +import { PrismaClient } from '@prisma/client'; +import bcrypt from 'bcryptjs'; +import { decrypt } from '../src/utils/encryption.js'; + +const prisma = new PrismaClient(); + +async function main() { + const customers = await prisma.customer.findMany({ + where: { portalPasswordEncrypted: { not: null }, portalPasswordHash: { not: null } }, + select: { + id: true, + customerNumber: true, + portalEmail: true, + portalPasswordEncrypted: true, + portalPasswordHash: true, + }, + }); + + let desync = 0; + for (const c of customers) { + let decrypted: string; + try { + decrypted = decrypt(c.portalPasswordEncrypted!); + } catch { + desync++; + console.log(`DESYNC (Entschlüsselung fehlgeschlagen): Kunde #${c.id} ${c.customerNumber}`); + continue; + } + const matches = await bcrypt.compare(decrypted, c.portalPasswordHash!); + if (!matches) { + desync++; + console.log(`DESYNC: Kunde #${c.id} ${c.customerNumber} (${c.portalEmail ?? '-'}) – gespeichertes Passwort passt nicht zum Login-Hash`); + } + } + + console.log(`\nGeprüft: ${customers.length} Portal-Kunden mit beiden Feldern. Desync: ${desync}.`); + if (desync > 0) { + console.log('→ Für diese Kunden bitte ein neues Portal-Passwort setzen/generieren.'); + } else { + console.log('→ Alles konsistent.'); + } + + await prisma.$disconnect(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/backend/src/controllers/customer.controller.ts b/backend/src/controllers/customer.controller.ts index 367e8323..54029af1 100644 --- a/backend/src/controllers/customer.controller.ts +++ b/backend/src/controllers/customer.controller.ts @@ -1119,20 +1119,28 @@ export async function sendPortalCredentials(req: AuthRequest, res: Response): Pr } const loginEmail = customer.portalEmail || customer.email!; - const plaintextPassword = await authService.getCustomerPortalPassword(customerId); - if (!plaintextPassword) { + const pwResult = await authService.getCustomerPortalPassword(customerId); + if (pwResult.status === 'none') { res.status(400).json({ success: false, error: 'Klartext-Passwort nicht verfügbar (alte Anlage ohne Encrypted-Feld – bitte neu setzen)', } as ApiResponse); return; } + // Desync: kein falsches Passwort versenden. + if (pwResult.status === 'desync') { + res.status(409).json({ + success: false, + error: 'Das gespeicherte Passwort passt nicht zum Login-Passwort (Dateninkonsistenz). Bitte erst ein neues Passwort setzen/generieren, dann erneut senden.', + } as ApiResponse); + return; + } await authService.sendPortalCredentialsEmail({ to: targetEmail, customer, loginEmail, - password: plaintextPassword, + password: pwResult.password, }); // Versendetes Passwort ist ein Einmalpasswort → beim ersten Login muss @@ -1190,7 +1198,7 @@ export async function getPortalPassword(req: AuthRequest, res: Response): Promis try { const customerId = parseInt(req.params.customerId); if (!(await canAccessCustomer(req, res, customerId))) return; - const password = await authService.getCustomerPortalPassword(customerId); + const result = await authService.getCustomerPortalPassword(customerId); // Klartext-Passwort-Read auditieren (CRITICAL): wer hat wann das Portal- // Passwort eines Kunden entschlüsselt? Wichtig für DSGVO-Nachvollziehbarkeit // + Insider-Threat-Erkennung. @@ -1199,10 +1207,19 @@ export async function getPortalPassword(req: AuthRequest, res: Response): Promis action: 'READ', resourceType: 'PortalPassword', resourceId: customerId.toString(), - label: `Klartext-Portal-Passwort von Kunde #${customerId} entschlüsselt`, + label: `Klartext-Portal-Passwort von Kunde #${customerId} entschlüsselt (${result.status})`, customerId, }); - res.json({ success: true, data: { password } } as ApiResponse); + // Desync (gespeichertes Passwort passt nicht zum Login-Hash): NICHT ausgeben + // – sonst liest der Support ein Passwort vor, das beim Login scheitert. + if (result.status === 'desync') { + res.status(409).json({ + success: false, + error: 'Das gespeicherte Portal-Passwort stimmt nicht mit dem Login-Passwort überein (Dateninkonsistenz). Bitte über „Passwort setzen/neu generieren" ein neues Passwort vergeben.', + } as ApiResponse); + return; + } + res.json({ success: true, data: { password: result.status === 'ok' ? result.password : null } } as ApiResponse); } catch (error) { res.status(500).json({ success: false, diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts index 27791642..9f051d0b 100644 --- a/backend/src/services/auth.service.ts +++ b/backend/src/services/auth.service.ts @@ -408,23 +408,50 @@ export async function markPortalPasswordForChange(customerId: number) { }); } -// Kundenportal-Passwort im Klartext abrufen -export async function getCustomerPortalPassword(customerId: number): Promise { +// Ergebnis des Klartext-Passwort-Abrufs. +// - 'ok' → password ist das gültige Klartext-Passwort +// - 'none' → kein verschlüsseltes Passwort hinterlegt (z.B. self-service +// gesetzt) → nichts zum Anzeigen/Versenden +// - 'desync' → verschlüsseltes Passwort vorhanden, passt aber NICHT zum +// Login-Hash (oder Entschlüsselung fehlgeschlagen). Darf NIE +// ausgegeben werden – sonst liest der Support ein Passwort vor, +// das beim Login scheitert (Pentest-Hinweis). +export type PortalPasswordReveal = + | { status: 'ok'; password: string } + | { status: 'none' } + | { status: 'desync' }; + +// Kundenportal-Passwort im Klartext abrufen – mit Konsistenzprüfung gegen den +// Login-Hash, damit niemals ein „falsches" (out-of-sync) Passwort herausgeht. +export async function getCustomerPortalPassword(customerId: number): Promise { const customer = await prisma.customer.findUnique({ where: { id: customerId }, - select: { portalPasswordEncrypted: true }, + select: { portalPasswordEncrypted: true, portalPasswordHash: true }, }); if (!customer?.portalPasswordEncrypted) { - return null; + return { status: 'none' }; } + let decrypted: string; try { - return decrypt(customer.portalPasswordEncrypted); + decrypted = decrypt(customer.portalPasswordEncrypted); } catch (error) { console.error('Fehler beim Entschlüsseln des Passworts:', error); - return null; + return { status: 'desync' }; } + + // Muss zum Login-Hash passen – sonst ist der gespeicherte Klartext veraltet + // und würde beim Login fehlschlagen. + if (customer.portalPasswordHash) { + const matches = await bcrypt.compare(decrypted, customer.portalPasswordHash); + if (!matches) { + console.warn(`[getCustomerPortalPassword] Desync: gespeichertes Passwort passt nicht zum Login-Hash (Kunde #${customerId})`); + return { status: 'desync' }; + } + } + + return { status: 'ok', password: decrypted }; } export async function createUser(data: { diff --git a/docs/todo.md b/docs/todo.md index ddd7688d..20a16fd8 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,22 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🔐 Portal-Passwort: Reveal/Send prüft Konsistenz (kein „falsches" PW)** (2026-08-11) + - Pentester-Hinweis: Bei einem Kunden waren bcrypt-Hash (Login) und + verschlüsseltes Reveal-Feld out-of-sync → Support hätte ein Passwort + vorgelesen, das beim Login scheitert. + - **Analyse:** Alle aktuellen Schreibpfade sind konsistent (beide Felder + zusammen ODER encrypted=null ODER Rehash desselben Passworts) – der aktuelle + Code erzeugt keinen Desync. Ursache war Altlast/manueller DB-Eingriff. + - **Fix (Belt-and-Suspenders):** `getCustomerPortalPassword` liefert jetzt + `{status: ok|none|desync}` und prüft den entschlüsselten Klartext per + `bcrypt.compare` gegen den Login-Hash. Bei `desync` (oder Entschlüsselungs- + fehler) geben **weder Reveal noch Send-Credentials** das Passwort aus, + sondern 409 „Dateninkonsistenz – bitte neu setzen". Reveal-Read wird mit + Status auditiert. + - **Diagnose-Script** `scripts/check-portal-password-sync.ts`: scannt alle + Portal-Kunden auf Hash-vs-Encrypted-Desync (nur Diagnose) – für Prod, da + der Pentester kein FS-Zugriff hat. Verifiziert (desync/ok/none-Fälle). - [x] **🎁 Gutschrift: Sachwert ohne Betrag = keine Rechnung** (2026-08-09) - Eine Sachwert-Gutschrift darf jetzt **betragslos** sein (Betrag leer/0): dann findet **keine Rechnungsstellung** statt – der Kunde hat den