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'; // 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): string { return jwt.sign({ ...payload, type: 'refresh' }, process.env.JWT_SECRET as string, { expiresIn: REFRESH_TOKEN_EXPIRES_IN, }); } // 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, }; const accessToken = signAccessToken(payload); const refreshToken = signRefreshToken(payload); 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, }, }; } // 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, }; const accessToken = signAccessToken(payload); const refreshToken = signRefreshToken(payload); 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 { throw new Error('Refresh-Token ungültig oder abgelaufen'); } if (decoded.type !== 'refresh') { throw new Error('Falscher Token-Typ'); } 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), refreshToken: signRefreshToken(payload), 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: signRefreshToken(payload), 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 }, }); } // Kundenportal-Passwort im Klartext abrufen export async function getCustomerPortalPassword(customerId: number): Promise { const customer = await prisma.customer.findUnique({ where: { id: customerId }, select: { portalPasswordEncrypted: true }, }); if (!customer?.portalPasswordEncrypted) { return null; } try { return decrypt(customer.portalPasswordEncrypted); } catch (error) { console.error('Fehler beim Entschlüsseln des Passworts:', error); return null; } } 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'); } function getPublicUrl(): string { return process.env.PUBLIC_URL || 'http://localhost:5173'; } /** * 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 = `${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 = `${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.'); }