Files
opencrm/backend/src/services/auth.service.ts
T
duffyduckandClaude Opus 5 41671cbb96 Dienstkonto-Anmeldungen als Routine statt CRITICAL (Pentest R182)
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>
2026-08-22 20:18:54 +02:00

1121 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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.');
}