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) <noreply@anthropic.com>
This commit is contained in:
2026-08-11 22:46:53 +02:00
co-authored by Claude Opus 4.8
parent 15ac003dad
commit 79f6f3e629
4 changed files with 133 additions and 12 deletions
+23 -6
View File
@@ -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,
+33 -6
View File
@@ -408,23 +408,50 @@ export async function markPortalPasswordForChange(customerId: number) {
});
}
// Kundenportal-Passwort im Klartext abrufen
export async function getCustomerPortalPassword(customerId: number): Promise<string | null> {
// 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<PortalPasswordReveal> {
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: {