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:
@@ -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);
|
||||||
|
});
|
||||||
@@ -1119,20 +1119,28 @@ export async function sendPortalCredentials(req: AuthRequest, res: Response): Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loginEmail = customer.portalEmail || customer.email!;
|
const loginEmail = customer.portalEmail || customer.email!;
|
||||||
const plaintextPassword = await authService.getCustomerPortalPassword(customerId);
|
const pwResult = await authService.getCustomerPortalPassword(customerId);
|
||||||
if (!plaintextPassword) {
|
if (pwResult.status === 'none') {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Klartext-Passwort nicht verfügbar (alte Anlage ohne Encrypted-Feld – bitte neu setzen)',
|
error: 'Klartext-Passwort nicht verfügbar (alte Anlage ohne Encrypted-Feld – bitte neu setzen)',
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
return;
|
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({
|
await authService.sendPortalCredentialsEmail({
|
||||||
to: targetEmail,
|
to: targetEmail,
|
||||||
customer,
|
customer,
|
||||||
loginEmail,
|
loginEmail,
|
||||||
password: plaintextPassword,
|
password: pwResult.password,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Versendetes Passwort ist ein Einmalpasswort → beim ersten Login muss
|
// Versendetes Passwort ist ein Einmalpasswort → beim ersten Login muss
|
||||||
@@ -1190,7 +1198,7 @@ export async function getPortalPassword(req: AuthRequest, res: Response): Promis
|
|||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
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-
|
// 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
|
||||||
// + Insider-Threat-Erkennung.
|
// + Insider-Threat-Erkennung.
|
||||||
@@ -1199,10 +1207,19 @@ export async function getPortalPassword(req: AuthRequest, res: Response): Promis
|
|||||||
action: 'READ',
|
action: 'READ',
|
||||||
resourceType: 'PortalPassword',
|
resourceType: 'PortalPassword',
|
||||||
resourceId: customerId.toString(),
|
resourceId: customerId.toString(),
|
||||||
label: `Klartext-Portal-Passwort von Kunde #${customerId} entschlüsselt`,
|
label: `Klartext-Portal-Passwort von Kunde #${customerId} entschlüsselt (${result.status})`,
|
||||||
customerId,
|
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) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -408,23 +408,50 @@ export async function markPortalPasswordForChange(customerId: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kundenportal-Passwort im Klartext abrufen
|
// Ergebnis des Klartext-Passwort-Abrufs.
|
||||||
export async function getCustomerPortalPassword(customerId: number): Promise<string | null> {
|
// - '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({
|
const customer = await prisma.customer.findUnique({
|
||||||
where: { id: customerId },
|
where: { id: customerId },
|
||||||
select: { portalPasswordEncrypted: true },
|
select: { portalPasswordEncrypted: true, portalPasswordHash: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!customer?.portalPasswordEncrypted) {
|
if (!customer?.portalPasswordEncrypted) {
|
||||||
return null;
|
return { status: 'none' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let decrypted: string;
|
||||||
try {
|
try {
|
||||||
return decrypt(customer.portalPasswordEncrypted);
|
decrypted = decrypt(customer.portalPasswordEncrypted);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Entschlüsseln des Passworts:', 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: {
|
export async function createUser(data: {
|
||||||
|
|||||||
@@ -97,6 +97,22 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ 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)
|
- [x] **🎁 Gutschrift: Sachwert ohne Betrag = keine Rechnung** (2026-08-09)
|
||||||
- Eine Sachwert-Gutschrift darf jetzt **betragslos** sein (Betrag leer/0):
|
- Eine Sachwert-Gutschrift darf jetzt **betragslos** sein (Betrag leer/0):
|
||||||
dann findet **keine Rechnungsstellung** statt – der Kunde hat den
|
dann findet **keine Rechnungsstellung** statt – der Kunde hat den
|
||||||
|
|||||||
Reference in New Issue
Block a user