3e1fc3eab2
Portal-Customer-Schwellwert bleibt 12 (Handy-Eingabe → längere PWs
erhöhen Reuse-Risiko). Mitarbeiter/Admin nutzen Passwort-Manager,
für die kostet die Länge nichts.
passwordGenerator.ts:
- STAFF_MIN_PASSWORD_LENGTH = 25, PORTAL_MIN_PASSWORD_LENGTH = 12
- validatePasswordComplexity({ minLength }) parametrisiert
Mitarbeiter-Pfade auf 25:
- createUser, register, setUserPassword
- confirmPasswordReset: Audience aus Token bestimmen
(getPasswordResetAudience), User → 25, Customer → 12. Kein
Body-Hint, damit kein Downgrade-Trick möglich.
Portal-Pfade unverändert (default 12):
- setPortalPassword, changeInitialPortalPassword
Seed-Admin:
- 28-char Zufallspasswort (statt 16) mit allen 4 Klassen garantiert
- SEED_ADMIN_PASSWORD-ENV nur akzeptiert wenn ≥ 25 Zeichen,
sonst Log-Warnung + Random-Fallback
Frontend:
- UserList: Hinweis "Mind. 25 Zeichen". Update + PW gleichzeitig →
zwei API-Calls (PUT + POST /users/:id/password) statt
Password im Body durchzuschmuggeln (Backend strippt es eh)
- PasswordResetConfirm: Hinweis "Mind. 12 (Mitarbeiter: 25)"
- userApi.setPassword(id, password) neu
Live-verifiziert:
- POST /users/6/password "Hallo123!Test" (12) → 400 "mindestens 25"
- POST /users/6/password "MeinExtremLangesPW2026!Test" → 200,
Login mit neuem PW → success
- POST /customers/3/portal/password "Hallo123!Test" (12) → 200
- POST /users createUser mit 12-char-PW → 400 "mindestens 25"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
850 lines
28 KiB
TypeScript
850 lines
28 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';
|
||
|
||
// 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<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,
|
||
};
|
||
|
||
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<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),
|
||
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<string | null> {
|
||
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<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');
|
||
}
|
||
|
||
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<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 = `${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 = `${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.');
|
||
}
|