import prisma from '../lib/prisma.js'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import crypto from 'crypto'; import { JwtPayload } from '../types/index.js'; import { encrypt, decrypt } from '../utils/encryption.js'; import { sendEmail, SmtpCredentials } from './smtpService.js'; import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js'; import { getAuthorizedCustomerIds } from './authorization.service.js'; import * as appSettingService from './appSetting.service.js'; // Token-Lifetimes // - Access-Token: kurzlebig, nur im Browser-Memory → XSS klaut max. 15 min // - Refresh-Token: lang, im httpOnly-Cookie → kein JS-Zugriff const ACCESS_TOKEN_EXPIRES_IN = (process.env.JWT_EXPIRES_IN || '15m') as jwt.SignOptions['expiresIn']; const REFRESH_TOKEN_EXPIRES_IN = (process.env.JWT_REFRESH_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn']; // Helper: signiert ein Access- bzw. Refresh-JWT mit dem `type`-Claim als // Unterscheidung. Der Refresh-Token landet im httpOnly-Cookie und wird beim // /auth/refresh-Endpoint geprüft, der dann einen neuen Access ausgibt. export function signAccessToken(payload: JwtPayload): string { return jwt.sign({ ...payload, type: 'access' }, process.env.JWT_SECRET as string, { expiresIn: ACCESS_TOKEN_EXPIRES_IN, }); } export function signRefreshToken(payload: JwtPayload, jti?: string, familyId?: string): string { return jwt.sign( { ...payload, type: 'refresh', ...(jti ? { jti } : {}), ...(familyId ? { fam: familyId } : {}) }, process.env.JWT_SECRET as string, { expiresIn: REFRESH_TOKEN_EXPIRES_IN }, ); } // ===================== Refresh-Token-Replay-Schutz (R164-02) ===================== // // Kulanzfenster: Mehrere Tabs teilen sich denselben Cookie und koennen einen // Refresh praktisch gleichzeitig ausloesen. Ohne Toleranz wuerde der zweite // legitime Aufruf als Replay gewertet und die Sitzung gesprengt. Die Toleranz // ist bewusst eng (Sekunden UND Anzahl begrenzt): ein Burst mit vielen // Wiederverwendungen - das typische Bild beim Missbrauch eines gestohlenen // Tokens - schlaegt weiterhin an. const REFRESH_REUSE_GRACE_MS = 15_000; const REFRESH_REUSE_MAX = 3; /** Legt einen neuen Sitzungs-Refresh-Token an (neue Familie). */ export async function issueRefreshToken( payload: JwtPayload, opts: { userId?: number; customerId?: number; isCustomerPortal: boolean; familyId?: string; /** Beim Rotieren bereits beim Einloesen reservierte jti des Nachfolgers. */ jti?: string; }, ): Promise { const jti = opts.jti || crypto.randomUUID(); const familyId = opts.familyId || crypto.randomUUID(); const token = signRefreshToken(payload, jti, familyId); const decoded: any = jwt.decode(token); await prisma.refreshTokenRecord.create({ data: { jti, familyId, userId: opts.userId ?? null, customerId: opts.customerId ?? null, isCustomerPortal: opts.isCustomerPortal, expiresAt: new Date((decoded?.exp ?? 0) * 1000), }, }); return token; } /** Widerruft eine komplette Sitzungsfamilie. */ async function revokeFamily(familyId: string, reason: string): Promise { await prisma.refreshTokenRecord.updateMany({ where: { familyId, revokedAt: null }, data: { revokedAt: new Date(), revokedReason: reason }, }); } /** Widerruft alle Familien eines Subjekts (Logout, Rechteaenderung). */ export async function revokeAllRefreshTokens( subject: { userId?: number; customerId?: number }, reason: string, ): Promise { const where = subject.userId != null ? { userId: subject.userId } : { customerId: subject.customerId }; await prisma.refreshTokenRecord.updateMany({ where: { ...where, revokedAt: null }, data: { revokedAt: new Date(), revokedReason: reason }, }); } /** Abgelaufene Datensaetze entfernen (beim Login, seltener als beim Refresh). */ async function pruneExpiredRefreshTokens(): Promise { try { await prisma.refreshTokenRecord.deleteMany({ where: { expiresAt: { lt: new Date() } } }); } catch { // Aufraeumen darf den Login nie blockieren. } } /** * Prueft die jti eines vorgelegten Refresh-Tokens und verbraucht sie. * Wirft mit `code = 'REFRESH_REPLAY'`, wenn ein bereits eingeloester Token * erneut auftaucht - der Controller meldet das als Sicherheitsvorfall. */ async function consumeRefreshJti(decoded: any): Promise<{ familyId: string; /** Frische Rotation: Datensatz fuer diese jti muss noch angelegt werden. */ issueJti?: string; /** Kulanz: dieser bereits ausgestellte Nachfolger wird erneut ausgegeben. */ reuseJti?: string; }> { const jti: string | undefined = decoded?.jti; const fam: string | undefined = decoded?.fam; if (!jti || !fam) { // Token aus der Zeit vor dem Replay-Schutz: bewusst fail-closed, damit // kein Altbestand die Einmalverwendung umgehen kann. Kostet einmalig // eine Neuanmeldung. const err: any = new Error('Sitzung muss erneuert werden – bitte neu anmelden'); err.code = 'REFRESH_LEGACY'; throw err; } const rec = await prisma.refreshTokenRecord.findUnique({ where: { jti } }); if (!rec) { const err: any = new Error('Refresh-Token ungültig oder abgelaufen'); err.code = 'REFRESH_INVALID'; throw err; } if (rec.revokedAt) { const err: any = new Error('Refresh-Token wurde invalidiert (Logout/Rechteänderung)'); err.code = 'REFRESH_REVOKED'; throw err; } // Einloesen MUSS atomar sein. Ein Lesen-dann-Schreiben wuerde bei parallelen // Anfragen nichts verhindern: alle wuerden den Token als "noch unbenutzt" // sehen und durchgelassen (im Test kamen 90 gleichzeitige Requests // ausnahmslos durch). Deshalb wird der Zustandswechsel als bedingtes UPDATE // ausgefuehrt - die Datenbank entscheidet, wer zuerst war. // Die jti des Nachfolgers wird SCHON HIER festgelegt und im selben UPDATE // hinterlegt. Dadurch weiss eine spaetere Wiedervorlage, welcher Nachfolger // bereits ausgestellt wurde - Grundlage der idempotenten Kulanz (R168-01). const nachfolgerJti = crypto.randomUUID(); const beansprucht = await prisma.refreshTokenRecord.updateMany({ where: { jti, usedAt: null, revokedAt: null }, data: { usedAt: new Date(), replacedByJti: nachfolgerJti }, }); if (beansprucht.count === 1) { return { familyId: rec.familyId, issueJti: nachfolgerJti }; } // Bereits eingeloest. Innerhalb des engen Kulanzfensters begrenzt tolerieren // (parallele Tabs) - als bedingtes UPDATE, damit die Obergrenze unter Last // wirklich haelt. const fensterAb = new Date(Date.now() - REFRESH_REUSE_GRACE_MS); const toleriert = await prisma.refreshTokenRecord.updateMany({ where: { jti, revokedAt: null, usedAt: { gte: fensterAb }, reuseCount: { lt: REFRESH_REUSE_MAX }, }, data: { reuseCount: { increment: 1 } }, }); if (toleriert.count === 1) { // IDEMPOTENT: denselben, bereits ausgestellten Nachfolger zurueckgeben - // NICHT erneut rotieren (Pentest R168-01). // // Vorher entstand bei jeder Kulanz-Wiedervorlage eine frische Linie mit // eigenem, zurueckgesetztem Zaehler. Ein Angreifer mit gestohlenem Token // konnte damit aus dem erkennbaren Replay-Zustand in eine eigene, // sauber weiterrotierende Sitzung entkommen, die nie wieder mit der des // Opfers kollidiert - also dauerhaft unsichtbar blieb. // // Jetzt laufen parallele Tabs auf DIESELBE Linie zusammen. Wer den Token // spaeter erneut vorlegt (also ausserhalb des Fensters), kollidiert // zwangslaeufig und loest den Familien-Widerruf aus. const aktuell = await prisma.refreshTokenRecord.findUnique({ where: { jti } }); if (aktuell?.replacedByJti) { const nachfolger = await prisma.refreshTokenRecord.findUnique({ where: { jti: aktuell.replacedByJti }, }); if (nachfolger?.revokedAt) { const err: any = new Error('Refresh-Token wurde invalidiert (Logout/Rechteänderung)'); err.code = 'REFRESH_REVOKED'; throw err; } return { familyId: rec.familyId, reuseJti: aktuell.replacedByJti }; } // Kein Nachfolger hinterlegt: nicht ersatzweise rotieren (das waere genau // der Fork). Fail-closed als Replay behandeln. } // Weder frei noch tolerierbar: War der Token zwischenzeitlich widerrufen // (z. B. durch einen parallelen Replay-Fund), ist das kein neuer Vorfall. const aktuell = await prisma.refreshTokenRecord.findUnique({ where: { jti } }); if (aktuell?.revokedAt) { const err: any = new Error('Refresh-Token wurde invalidiert (Logout/Rechteänderung)'); err.code = 'REFRESH_REVOKED'; throw err; } // Replay: gesamte Familie widerrufen - der Angreifer verliert den Zugang, // der legitime Nutzer muss sich neu anmelden und merkt es dadurch. await revokeFamily(rec.familyId, 'Replay eines bereits eingelösten Refresh-Tokens'); const err: any = new Error('Refresh-Token wurde bereits verwendet – Sitzung aus Sicherheitsgründen beendet'); err.code = 'REFRESH_REPLAY'; throw err; } // 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. // Bestehende Hashes mit Faktor 10 bleiben gültig (bcrypt kodiert den Faktor im Hash). const BCRYPT_COST = 12; // Dummy-Hash mit Cost 12 für Timing-Attack-Schutz: bei nicht-existierendem User // führen wir trotzdem ein bcrypt.compare() durch, damit die Antwortzeit nicht // verrät, ob die E-Mail existiert. Konstanter Hash hat keine Bedeutung außer // dem Timing-Angleich. const DUMMY_BCRYPT_HASH = '$2a$12$CwTycUXWue0Thq9StjUM0uJ8gQKwqKjq8lZ3TZ9qg8aJ0A9hPn4Wy'; /** * Upgrade eines bestehenden Passwort-Hashes auf aktuellen BCRYPT_COST. * Wird nach erfolgreichem Login aufgerufen. Alte User (z.B. admin mit Cost 10 * aus der Installation) werden so lazy auf Cost 12 migriert – damit sich die * Antwortzeit beim Login der Dummy-Zeit bei ungültigen Usern angleicht. */ async function maybeUpgradePasswordHash( table: 'user' | 'customer', id: number, plaintextPassword: string, currentHash: string, ): Promise { const match = currentHash.match(/^\$2[aby]\$(\d+)\$/); const currentCost = match ? parseInt(match[1], 10) : 0; if (currentCost === BCRYPT_COST) return; try { const newHash = await bcrypt.hash(plaintextPassword, BCRYPT_COST); if (table === 'user') { await prisma.user.update({ where: { id }, data: { password: newHash } }); } else { await prisma.customer.update({ where: { id }, data: { portalPasswordHash: newHash } }); } } catch (err) { // Nicht kritisch – Login war erfolgreich, Rehash kann beim nächsten Login nachgeholt werden console.warn('[maybeUpgradePasswordHash] Fehler beim Rehash:', err); } } // Mitarbeiter-Login export async function login(email: string, password: string) { const user = await prisma.user.findUnique({ where: { email }, include: { roles: { include: { role: { include: { permissions: { include: { permission: true, }, }, }, }, }, }, }, }); if (!user || !user.isActive) { // Timing-Attack-Schutz: Dummy-bcrypt-compare damit die Antwortzeit bei // nicht-existierendem/deaktiviertem User der eines gültigen Users entspricht. await bcrypt.compare(password, DUMMY_BCRYPT_HASH); throw new Error('Ungültige Anmeldedaten'); } const isValid = await bcrypt.compare(password, user.password); if (!isValid) { throw new Error('Ungültige Anmeldedaten'); } // Lazy-Upgrade: ältere Cost-10-Hashes auf aktuellen BCRYPT_COST rehashen. // Async, nicht blockierend für die Response. maybeUpgradePasswordHash('user', user.id, password, user.password).catch(() => {}); // Collect all permissions from all roles const permissions = new Set(); for (const userRole of user.roles) { for (const rolePerm of userRole.role.permissions) { permissions.add( `${rolePerm.permission.resource}:${rolePerm.permission.action}` ); } } const payload: JwtPayload = { userId: user.id, email: user.email, permissions: Array.from(permissions), customerId: user.customerId ?? undefined, isCustomerPortal: false, }; await pruneExpiredRefreshTokens(); const accessToken = signAccessToken(payload); // Neue Sitzungsfamilie (Replay-Schutz, R164-02) const refreshToken = await issueRefreshToken(payload, { userId: user.id, isCustomerPortal: false }); return { accessToken, refreshToken, user: { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, permissions: Array.from(permissions), customerId: user.customerId, isCustomerPortal: false, // Wird von der Audit-Middleware gelesen, um planmaessige // Dienstkonto-Anmeldungen als Routine einzustufen (R182). isServiceAccount: user.isServiceAccount, }, }; } // Kundenportal-Login export async function customerLogin(email: string, password: string) { console.log('[CustomerLogin] Versuch mit E-Mail:', email); const customer = await prisma.customer.findUnique({ where: { portalEmail: email }, include: { // Kunden, die dieser Kunde vertreten kann representingFor: { where: { isActive: true }, include: { customer: { select: { id: true, customerNumber: true, firstName: true, lastName: true, companyName: true, type: true, }, }, }, }, }, }); console.log('[CustomerLogin] Kunde gefunden:', customer ? `ID ${customer.id}, portalEnabled: ${customer.portalEnabled}, hasPasswordHash: ${!!customer.portalPasswordHash}` : 'NEIN'); if (!customer || !customer.portalEnabled || !customer.portalPasswordHash) { console.log('[CustomerLogin] Abbruch: Kunde nicht gefunden oder Portal nicht aktiviert'); // Timing-Attack-Schutz (siehe login()) await bcrypt.compare(password, DUMMY_BCRYPT_HASH); throw new Error('Ungültige Anmeldedaten'); } const isValid = await bcrypt.compare(password, customer.portalPasswordHash); console.log('[CustomerLogin] Passwort-Check:', isValid ? 'OK' : 'FALSCH'); if (!isValid) { throw new Error('Ungültige Anmeldedaten'); } // Einmalpasswort-Check: wurde es per "Zugangsdaten versenden" verschickt? // Falls ja, jetzt sofort verbrauchen – Hash + Encrypted nullen, damit // weder Re-Login noch Klartext-Abruf möglich ist. Customer landet im // Force-Change-Password-Flow. const mustChangePassword = customer.portalPasswordMustChange === true; if (mustChangePassword) { await prisma.customer.update({ where: { id: customer.id }, data: { portalPasswordHash: null, portalPasswordEncrypted: null, portalLastLogin: new Date(), }, }); } else { // Lazy-Upgrade analog zu Mitarbeiter-Login maybeUpgradePasswordHash('customer', customer.id, password, customer.portalPasswordHash).catch(() => {}); // Letzte Anmeldung aktualisieren await prisma.customer.update({ where: { id: customer.id }, data: { portalLastLogin: new Date() }, }); } // IDs der Kunden sammeln, die dieser Kunde vertreten kann – // GEFILTERT auf aktive Vollmacht (isGranted: true). Ohne diesen Filter // hätte das frische JWT nach Vollmacht-Widerruf weiterhin die alte // representedCustomerIds-Liste; die UI würde dem Vertreter noch // anzeigen, dass er vertreten kann, obwohl der Live-Check beim // Datenzugriff dann 403 wirft. Pentest Runde 10 (2026-05-17), MEDIUM. const grantedCustomerIds = new Set(await getAuthorizedCustomerIds(customer.id)); const grantedRepresentingFor = customer.representingFor.filter((rep) => grantedCustomerIds.has(rep.customer.id), ); const representedCustomerIds = grantedRepresentingFor.map((rep) => rep.customer.id); // Kundenportal-Berechtigungen (eingeschränkt) const customerPermissions = [ 'contracts:read', // Eigene Verträge lesen 'customers:read', // Eigene Kundendaten lesen ]; const payload: JwtPayload = { email: customer.portalEmail!, permissions: customerPermissions, customerId: customer.id, isCustomerPortal: true, representedCustomerIds, }; await pruneExpiredRefreshTokens(); const accessToken = signAccessToken(payload); // Neue Sitzungsfamilie (Replay-Schutz, R164-02) const refreshToken = await issueRefreshToken(payload, { customerId: customer.id, isCustomerPortal: true }); return { accessToken, refreshToken, mustChangePassword, user: { id: customer.id, email: customer.portalEmail, firstName: customer.firstName, lastName: customer.lastName, permissions: customerPermissions, customerId: customer.id, isCustomerPortal: true, mustChangePassword, representedCustomers: grantedRepresentingFor.map((rep) => ({ id: rep.customer.id, customerNumber: rep.customer.customerNumber, firstName: rep.customer.firstName, lastName: rep.customer.lastName, companyName: rep.customer.companyName, type: rep.customer.type, })), }, }; } // Refresh-Token verifizieren und neuen Access-Token ausstellen. Wirft bei // ungültigem/abgelaufenem/invalidiertem Token. Greift auch tokenInvalidatedAt // vom User/Customer ab → bei Rolle-Ändern oder Logout sind alle Tokens (auch // das Refresh) sofort tot. export async function refreshAccessToken(refreshToken: string): Promise<{ accessToken: string; refreshToken: string; user: any; }> { let decoded: any; try { decoded = jwt.verify(refreshToken, process.env.JWT_SECRET as string, { algorithms: ['HS256'], }); } catch (e) { // Code erhalten, damit der Controller abgelaufen (benigne, LOW) von // manipuliert/ungültiger Signatur (verdächtig, HIGH) trennen kann. const err: any = new Error('Refresh-Token ungültig oder abgelaufen'); err.code = e instanceof jwt.TokenExpiredError ? 'REFRESH_EXPIRED' : 'REFRESH_INVALID'; throw err; } if (decoded.type !== 'refresh') { throw new Error('Falscher Token-Typ'); } // Einmalverwendung durchsetzen und Sitzungsfamilie bestimmen (R164-02). // Wirft bei Replay – danach ist die gesamte Familie widerrufen. const rotation = await consumeRefreshJti(decoded); // Bei Kulanz wird derselbe, bereits ausgestellte Nachfolger erneut signiert - // ohne neuen Datensatz, damit keine zweite Linie entsteht (R168-01). const naechsterRefreshToken = async ( payload: JwtPayload, subjekt: { userId?: number; customerId?: number; isCustomerPortal: boolean }, ): Promise => rotation.reuseJti ? signRefreshToken(payload, rotation.reuseJti, rotation.familyId) : issueRefreshToken(payload, { ...subjekt, familyId: rotation.familyId, jti: rotation.issueJti, }); const issuedAt = decoded.iat ? decoded.iat * 1000 : 0; // Mitarbeiter if (!decoded.isCustomerPortal && decoded.userId) { const user = await prisma.user.findUnique({ where: { id: decoded.userId }, include: { roles: { include: { role: { include: { permissions: { include: { permission: true } } } } } }, }, }); if (!user || !user.isActive) throw new Error('Benutzer nicht aktiv'); if (user.tokenInvalidatedAt && issuedAt < user.tokenInvalidatedAt.getTime()) { throw new Error('Refresh-Token wurde invalidiert (Logout/Rechteänderung)'); } const permissions = new Set(); for (const ur of user.roles) { for (const rp of ur.role.permissions) { permissions.add(`${rp.permission.resource}:${rp.permission.action}`); } } const payload: JwtPayload = { userId: user.id, email: user.email, permissions: Array.from(permissions), customerId: user.customerId ?? undefined, isCustomerPortal: false, }; return { accessToken: signAccessToken(payload), // Nachfolger bleibt in derselben Familie – ein Replay des Vorgaengers // sprengt damit auch alle daraus entstandenen Tokens. refreshToken: await naechsterRefreshToken(payload, { userId: user.id, isCustomerPortal: false }), user: { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, permissions: Array.from(permissions), customerId: user.customerId, isCustomerPortal: false, }, }; } // Customer-Portal if (decoded.isCustomerPortal && decoded.customerId) { const customer = await prisma.customer.findUnique({ where: { id: decoded.customerId } }); if (!customer || !customer.portalEmail) throw new Error('Portal-Konto nicht gefunden'); if (customer.portalTokenInvalidatedAt && issuedAt < customer.portalTokenInvalidatedAt.getTime()) { throw new Error('Refresh-Token wurde invalidiert'); } const portalUser = await getCustomerPortalUser(customer.id); if (!portalUser) throw new Error('Portal-Konto nicht gefunden'); const payload: JwtPayload = { email: customer.portalEmail, permissions: portalUser.permissions, customerId: customer.id, isCustomerPortal: true, representedCustomerIds: portalUser.representedCustomers?.map((c: any) => c.id), }; return { accessToken: signAccessToken(payload), refreshToken: await naechsterRefreshToken(payload, { customerId: customer.id, isCustomerPortal: true, }), user: portalUser, }; } throw new Error('Refresh-Token konnte nicht interpretiert werden'); } // Kundenportal-Passwort setzen/ändern export async function setCustomerPortalPassword(customerId: number, password: string) { console.log('[SetPortalPassword] Setze Passwort für Kunde:', customerId); const hashedPassword = await bcrypt.hash(password, BCRYPT_COST); const encryptedPassword = encrypt(password); console.log('[SetPortalPassword] Hash erstellt, Länge:', hashedPassword.length); // Manuelles Setzen ist KEIN Einmalpasswort → Flag immer zurücksetzen, // falls vorher ein OTP gesetzt war. await prisma.customer.update({ where: { id: customerId }, data: { portalPasswordHash: hashedPassword, portalPasswordEncrypted: encryptedPassword, portalPasswordMustChange: false, }, }); console.log('[SetPortalPassword] Passwort gespeichert'); } // Vom Endkunden selbst gesetztes Initial-Passwort nach OTP-Login. // Speichert neuen Hash, löscht das verbrauchte Encrypted-Feld (Klartext- // Speicherung soll bei OFF self-service nicht zurückkommen) und invalidiert // sofort alle bestehenden Sessions, damit Login mit dem neuen Passwort // gefordert wird. export async function changeInitialPortalPassword(customerId: number, newPassword: string) { const hashedPassword = await bcrypt.hash(newPassword, BCRYPT_COST); await prisma.customer.update({ where: { id: customerId }, data: { portalPasswordHash: hashedPassword, portalPasswordEncrypted: null, portalPasswordMustChange: false, portalTokenInvalidatedAt: new Date(), }, }); } export async function markPortalPasswordForChange(customerId: number) { await prisma.customer.update({ where: { id: customerId }, data: { portalPasswordMustChange: true }, }); } // 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, portalPasswordHash: true }, }); if (!customer?.portalPasswordEncrypted) { return { status: 'none' }; } let decrypted: string; try { decrypted = decrypt(customer.portalPasswordEncrypted); } catch (error) { console.error('Fehler beim Entschlüsseln des Passworts:', error); return { status: 'desync' }; } // Ohne Login-Hash ist ein Login gar nicht möglich – ein gespeichertes // Klartext-Passwort wäre irreführend (Support würde etwas vorlesen, das // nirgends funktioniert). Als inkonsistent behandeln (Pentest R143). if (!customer.portalPasswordHash) { console.warn(`[getCustomerPortalPassword] Desync: verschlüsseltes Passwort ohne Login-Hash (Kunde #${customerId})`); return { status: 'desync' }; } // Muss zum Login-Hash passen – sonst ist der gespeicherte Klartext veraltet // und würde beim Login fehlschlagen. 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: { email: string; password: string; firstName: string; lastName: string; roleIds: number[]; customerId?: number; }) { const hashedPassword = await bcrypt.hash(data.password, BCRYPT_COST); const user = await prisma.user.create({ data: { email: data.email, password: hashedPassword, firstName: data.firstName, lastName: data.lastName, customerId: data.customerId, roles: { create: data.roleIds.map((roleId) => ({ roleId })), }, }, include: { roles: { include: { role: true, }, }, }, }); return { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, roles: user.roles.map((ur) => ur.role.name), }; } export async function getUserById(id: number) { const user = await prisma.user.findUnique({ where: { id }, include: { roles: { include: { role: { include: { permissions: { include: { permission: true, }, }, }, }, }, }, }, }); if (!user) return null; console.log('auth.getUserById - user roles:', user.roles.map(ur => ur.role.name)); const permissions = new Set(); for (const userRole of user.roles) { for (const rolePerm of userRole.role.permissions) { permissions.add( `${rolePerm.permission.resource}:${rolePerm.permission.action}` ); } } console.log('auth.getUserById - permissions:', Array.from(permissions)); return { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, isActive: user.isActive, customerId: user.customerId, whatsappNumber: user.whatsappNumber, telegramUsername: user.telegramUsername, signalNumber: user.signalNumber, roles: user.roles.map((ur) => ur.role.name), permissions: Array.from(permissions), isCustomerPortal: false, }; } // Kundenportal-Benutzer laden (für /me Endpoint) export async function getCustomerPortalUser(customerId: number) { const customer = await prisma.customer.findUnique({ where: { id: customerId }, include: { representingFor: { where: { isActive: true }, include: { customer: { select: { id: true, customerNumber: true, firstName: true, lastName: true, companyName: true, type: true, }, }, }, }, }, }); if (!customer || !customer.portalEnabled) return null; const customerPermissions = [ 'contracts:read', 'customers:read', ]; // Selbe Live-Vollmacht-Filterung wie in customerLogin (Pentest Runde 10): // ohne sie zeigt /me dem Vertreter weiterhin widerrufene Beziehungen. const grantedCustomerIds = new Set(await getAuthorizedCustomerIds(customer.id)); const grantedRepresentingFor = customer.representingFor.filter((rep) => grantedCustomerIds.has(rep.customer.id), ); return { id: customer.id, email: customer.portalEmail, firstName: customer.firstName, lastName: customer.lastName, isActive: customer.portalEnabled, customerId: customer.id, permissions: customerPermissions, isCustomerPortal: true, representedCustomers: grantedRepresentingFor.map((rep) => ({ id: rep.customer.id, customerNumber: rep.customer.customerNumber, firstName: rep.customer.firstName, lastName: rep.customer.lastName, companyName: rep.customer.companyName, type: rep.customer.type, })), }; } // ==================== PASSWORT-RESET ==================== const RESET_TOKEN_EXPIRY_HOURS = 2; function generateResetToken(): string { return crypto.randomBytes(32).toString('hex'); } /** * Liefert die Basis-URL für an Kunden verschickte Links (Portal-Login, * Passwort-Reset). Reihenfolge: * 1. `portalLoginUrl` aus AppSettings (vom Admin in Settings → Kundenportal * gepflegt). Wenn HTTPS-Domain hier eingetragen, wird die in Mails * verwendet, nicht der Localhost-Default. * 2. `PUBLIC_URL`-Env (für Setups ohne Admin-UI-Konfiguration). * 3. Fallback `http://localhost:5173` (Dev-Default). * Hat Trailing-Slash-Bereinigung, sonst kommen Links wie * `https://crm.de//portal/login` zustande. */ // Pentest 59.4 Nebenbefund (2026-06-01): Consent-URL kam mit // `localhost:5173` raus, weil PUBLIC_URL nicht gesetzt war und // req.headers.origin im Hintergrund-Pfad nicht greift. Helper jetzt // EXPORT, damit auch der GDPR-Controller (sendConsentLink etc.) // dieselbe Quelle der Wahrheit nutzt – inklusive admin-konfigurierbarer // portalLoginUrl App-Setting. export async function getPublicUrl(): Promise { const fromSettings = await appSettingService.getSetting('portalLoginUrl'); const raw = (fromSettings && fromSettings.trim()) || process.env.PUBLIC_URL || 'http://localhost:5173'; return raw.replace(/\/+$/, ''); } /** * Portal-Zugangsdaten per E-Mail an den Kunden versenden. Nur durch Admin- * UI ausgelöst – nie automatisch –, weil das Klartext-Passwort im Mail- * Body steht. Login-URL zeigt auf das `/portal/login`-Frontend-Route. */ export async function sendPortalCredentialsEmail(params: { to: string; customer: { firstName: string | null; lastName: string | null; salutation: string | null; companyName: string | null }; loginEmail: string; password: string; }): Promise { const systemEmail = await getSystemEmailCredentials(); if (!systemEmail) { throw new Error('Kein System-E-Mail-Konto konfiguriert (Einstellungen → E-Mail-Provider)'); } const credentials: SmtpCredentials = { host: systemEmail.smtpServer, port: systemEmail.smtpPort, user: systemEmail.emailAddress, password: systemEmail.password, encryption: systemEmail.smtpEncryption, allowSelfSignedCerts: systemEmail.allowSelfSignedCerts, }; const loginUrl = `${await getPublicUrl()}/portal/login`; const name = params.customer.companyName?.trim() || `${params.customer.firstName || ''} ${params.customer.lastName || ''}`.trim() || 'Kunde'; // HTML-Escape – Customer-Namen können theoretisch Sonderzeichen enthalten, // die wir nicht ungefiltert in die Mail rendern wollen. const esc = (s: string) => s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); const html = `

Ihre Zugangsdaten zum Kundenportal

Hallo ${esc(name)},

anbei Ihre Zugangsdaten zum Kundenportal:

Login-URL: ${esc(loginUrl)}
E-Mail: ${esc(params.loginEmail)}
Passwort: ${esc(params.password)}

⚠️ Dieses Passwort ist ein Einmalpasswort.

Beim ersten Login werden Sie aufgefordert, ein eigenes Passwort zu vergeben. Danach ist dieses Passwort hier nicht mehr gültig – falls Sie den Vorgang abbrechen, fordern Sie bitte neue Zugangsdaten an oder nutzen die Passwort-vergessen-Funktion.


Diese Nachricht enthält sensible Zugangsdaten – bitte sicher verwahren oder nach dem Login löschen.

`; await sendEmail( credentials, systemEmail.emailAddress, { to: params.to, subject: 'Ihre Zugangsdaten zum Kundenportal', html, }, { context: 'portal-credentials', triggeredBy: 'admin-action', }, ); } /** * Passwort-Reset-Link per Email senden. * Findet User/Customer per Email. Wirft keinen Error wenn nicht gefunden * (Schutz vor User-Enumeration – Caller gibt immer success zurück). */ export async function requestPasswordReset(email: string, userType: 'admin' | 'portal'): Promise { const token = generateResetToken(); const expiresAt = new Date(Date.now() + RESET_TOKEN_EXPIRY_HOURS * 60 * 60 * 1000); let recipient: { email: string; firstName: string; lastName: string } | null = null; if (userType === 'admin') { const user = await prisma.user.findUnique({ where: { email } }); if (!user || !user.isActive) return; await prisma.user.update({ where: { id: user.id }, data: { passwordResetToken: token, passwordResetExpiresAt: expiresAt, }, }); recipient = { email: user.email, firstName: user.firstName, lastName: user.lastName }; } else { const customer = await prisma.customer.findUnique({ where: { portalEmail: email } }); if (!customer || !customer.portalEnabled) return; await prisma.customer.update({ where: { id: customer.id }, data: { portalPasswordResetToken: token, portalPasswordResetExpiresAt: expiresAt, }, }); recipient = { email: customer.portalEmail!, firstName: customer.firstName, lastName: customer.lastName, }; } if (!recipient) return; // Reset-Link + Email senden const resetUrl = `${await getPublicUrl()}/password-reset?token=${token}&type=${userType}`; const systemEmail = await getSystemEmailCredentials(); if (!systemEmail) { console.warn( `[passwordReset] Kein System-E-Mail konfiguriert – Reset-Link für ${recipient.email}: ${resetUrl}`, ); return; } const credentials: SmtpCredentials = { host: systemEmail.smtpServer, port: systemEmail.smtpPort, user: systemEmail.emailAddress, password: systemEmail.password, encryption: systemEmail.smtpEncryption, allowSelfSignedCerts: systemEmail.allowSelfSignedCerts, }; const html = `

Passwort zurücksetzen

Hallo ${recipient.firstName} ${recipient.lastName},

Sie haben angefordert, Ihr Passwort zurückzusetzen. Klicken Sie auf den folgenden Button, um ein neues Passwort zu vergeben. Der Link ist ${RESET_TOKEN_EXPIRY_HOURS} Stunden gültig.

Neues Passwort vergeben

Alternativ können Sie diesen Link in Ihren Browser kopieren:
${resetUrl}


Haben Sie diesen Reset nicht angefordert? Dann ignorieren Sie diese E-Mail einfach – Ihr Passwort bleibt unverändert.

`; await sendEmail( credentials, systemEmail.emailAddress, { to: recipient.email, subject: 'Passwort zurücksetzen', html, }, { context: 'password-reset', triggeredBy: 'self-service', }, ); } /** * Stellt fest, ob ein Reset-Token zu einem Mitarbeiter (admin) oder einem * Portal-Customer (portal) gehört. Wird vom Controller benötigt, um den * passenden Komplexitäts-Schwellwert (25 bzw. 12 Zeichen) anzuwenden, * BEVOR das Passwort tatsächlich gesetzt wird. Pentest Runde 13. */ export async function getPasswordResetAudience(token: string): Promise<'admin' | 'portal' | null> { const user = await prisma.user.findUnique({ where: { passwordResetToken: token }, select: { id: true }, }); if (user) return 'admin'; const customer = await prisma.customer.findUnique({ where: { portalPasswordResetToken: token }, select: { id: true }, }); if (customer) return 'portal'; return null; } /** * Passwort-Reset bestätigen: Token prüfen, Passwort setzen, Token löschen. * Invalidiert alle bestehenden JWT-Sessions des Users. */ export async function confirmPasswordReset(token: string, newPassword: string): Promise { // Erst beim User suchen const user = await prisma.user.findUnique({ where: { passwordResetToken: token } }); if (user) { if (!user.passwordResetExpiresAt || user.passwordResetExpiresAt < new Date()) { throw new Error('Der Link ist abgelaufen. Bitte fordere einen neuen an.'); } const hash = await bcrypt.hash(newPassword, BCRYPT_COST); await prisma.user.update({ where: { id: user.id }, data: { password: hash, passwordResetToken: null, passwordResetExpiresAt: null, // Alle bestehenden Sessions kicken tokenInvalidatedAt: new Date(), }, }); return; } // Sonst beim Customer (Portal) const customer = await prisma.customer.findUnique({ where: { portalPasswordResetToken: token } }); if (customer) { if (!customer.portalPasswordResetExpiresAt || customer.portalPasswordResetExpiresAt < new Date()) { throw new Error('Der Link ist abgelaufen. Bitte fordere einen neuen an.'); } const hash = await bcrypt.hash(newPassword, BCRYPT_COST); await prisma.customer.update({ where: { id: customer.id }, data: { portalPasswordHash: hash, // Pentest Runde 6 (MITTEL-01): Beim Self-Service-Reset speichern wir // KEINEN Klartext mehr. Encrypted-Feld ist nur für Admin-generierte // Einmalpasswörter sinnvoll (damit Admin sie in der UI sehen + per // Mail versenden kann); für ein vom Kunden selbst gesetztes Passwort // ist Klartext-Speicherung ein unnötiges Recover-Risiko bei DB+Key-Leak. portalPasswordEncrypted: null, portalPasswordResetToken: null, portalPasswordResetExpiresAt: null, // Alle bestehenden Portal-Sessions kicken portalTokenInvalidatedAt: new Date(), // OTP-Flow-Flag ist nach selbstgesetztem Passwort definitiv aus portalPasswordMustChange: false, }, }); return; } throw new Error('Ungültiger oder bereits verwendeter Link.'); }