Das Gegenbuch meldet sich stuendlich an - protokolliert wurde das als Authentication/CRITICAL. Damit stand das vorhersagbarste Ereignis im System auf der hoechsten Stufe: Jeder legitime Lauf trainiert den Betreiber darauf, CRITICAL wegzuklicken, und der erste echte Vorfall erbt diesen Reflex. Dieselbe Alarm-Muedigkeits-Klasse wie R162, diesmal von uns selbst erzeugt. Nicht unterdrueckt, sondern eingestuft: Neues Kennzeichen isServiceAccount am Benutzer (Migration 20260822100000). Anmeldungen solcher Konten erscheinen weiterhin im Log - man soll sehen, dass das Gegenbuch arbeitet - aber als Routine (LOW) mit eigenem Label 'Dienstkonto ... angemeldet (planmaessig)'. Alle anderen Anmeldungen bleiben CRITICAL. Offen und dem Pentester so benannt: Der eigentliche Tripwire waere der AUSBLEIBENDE Heartbeat (Dienstkonto meldet sich N Intervalle nicht mehr) sowie Anmeldungen ausserhalb der Kadenz oder von fremder Quell-IP. Das braucht einen Zeitgeber und ist noch nicht gebaut. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1121 lines
40 KiB
TypeScript
1121 lines
40 KiB
TypeScript
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<string> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<string>();
|
||
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<string> =>
|
||
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<string>();
|
||
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<PortalPasswordReveal> {
|
||
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<string>();
|
||
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<string> {
|
||
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<void> {
|
||
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, '>').replace(/"/g, '"');
|
||
|
||
const html = `
|
||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||
<h2 style="color: #1e40af;">Ihre Zugangsdaten zum Kundenportal</h2>
|
||
<p>Hallo ${esc(name)},</p>
|
||
<p>anbei Ihre Zugangsdaten zum Kundenportal:</p>
|
||
<table style="border-collapse: collapse; margin: 16px 0;">
|
||
<tr><td style="padding: 6px 12px; color: #6b7280;">Login-URL:</td>
|
||
<td style="padding: 6px 12px;"><a href="${loginUrl}">${esc(loginUrl)}</a></td></tr>
|
||
<tr><td style="padding: 6px 12px; color: #6b7280;">E-Mail:</td>
|
||
<td style="padding: 6px 12px; font-family: monospace;">${esc(params.loginEmail)}</td></tr>
|
||
<tr><td style="padding: 6px 12px; color: #6b7280;">Passwort:</td>
|
||
<td style="padding: 6px 12px; font-family: monospace;">${esc(params.password)}</td></tr>
|
||
</table>
|
||
<p style="color: #b91c1c; font-size: 14px; font-weight: 600;">
|
||
⚠️ Dieses Passwort ist ein <u>Einmalpasswort</u>.
|
||
</p>
|
||
<p style="color: #6b7280; font-size: 14px;">
|
||
Beim ersten Login werden Sie aufgefordert, ein eigenes Passwort zu vergeben.
|
||
Danach ist dieses Passwort hier <strong>nicht mehr gültig</strong> – falls Sie den
|
||
Vorgang abbrechen, fordern Sie bitte neue Zugangsdaten an oder nutzen die
|
||
Passwort-vergessen-Funktion.
|
||
</p>
|
||
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">
|
||
<p style="color: #9ca3af; font-size: 12px;">
|
||
Diese Nachricht enthält sensible Zugangsdaten – bitte sicher verwahren oder nach
|
||
dem Login löschen.
|
||
</p>
|
||
</div>
|
||
`;
|
||
|
||
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<void> {
|
||
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 = `
|
||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||
<h2 style="color: #1e40af;">Passwort zurücksetzen</h2>
|
||
<p>Hallo ${recipient.firstName} ${recipient.lastName},</p>
|
||
<p>
|
||
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.
|
||
</p>
|
||
<p style="text-align: center; margin: 32px 0;">
|
||
<a href="${resetUrl}" style="background-color: #2563eb; color: #ffffff; padding: 14px 32px; text-decoration: none; border-radius: 8px; font-weight: bold; font-size: 16px; display: inline-block;">
|
||
Neues Passwort vergeben
|
||
</a>
|
||
</p>
|
||
<p style="color: #6b7280; font-size: 14px;">
|
||
Alternativ können Sie diesen Link in Ihren Browser kopieren:<br>
|
||
<a href="${resetUrl}" style="color: #2563eb; word-break: break-all;">${resetUrl}</a>
|
||
</p>
|
||
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">
|
||
<p style="color: #9ca3af; font-size: 12px;">
|
||
Haben Sie diesen Reset nicht angefordert? Dann ignorieren Sie diese E-Mail einfach –
|
||
Ihr Passwort bleibt unverändert.
|
||
</p>
|
||
</div>
|
||
`;
|
||
|
||
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<void> {
|
||
// 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.');
|
||
}
|