Passwort-Komplexität + Portal-Credentials-UX

validatePasswordComplexity (12 Zeichen, Groß/Klein/Zahl/Sonderzeichen)
zentral in passwordGenerator.ts; jetzt erzwungen in setPortalPassword,
confirmPasswordReset, register, createUser, updateUser.

Neue Endpoints:
- POST /customers/:id/portal/password/generate → 16-Zeichen Zufallspasswort
- POST /customers/:id/portal/send-credentials → Versand per Mail
  (nur wenn portalEnabled aktiv)

Frontend (CustomerDetail): Generate-Button vor Setzen, Send-Credentials
nach gesetztem Passwort, Live-Komplexitäts-Hint (✓/○) während Eingabe,
alert() durch Toast-Notifications ersetzt.

Live-verifiziert: schwaches Passwort → 400 mit Detail-Fehler, komplexes
Passwort → 200, Generator liefert 16-Zeichen-Passwort.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 18:26:11 +02:00
parent 6af1a4bbd4
commit 8a5ffbb563
8 changed files with 353 additions and 10 deletions
+13 -2
View File
@@ -3,6 +3,7 @@ import * as authService from '../services/auth.service.js';
import { AuthRequest, ApiResponse } from '../types/index.js';
import prisma from '../lib/prisma.js';
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
import { validatePasswordComplexity } from '../utils/passwordGenerator.js';
// Refresh-Token-Cookie-Konfiguration. Der Cookie:
// - httpOnly → kein JavaScript-Zugriff → bei XSS nicht klaubar
@@ -223,10 +224,11 @@ export async function confirmPasswordReset(req: Request, res: Response): Promise
return;
}
if (password.length < 6) {
const complexity = validatePasswordComplexity(password);
if (!complexity.ok) {
res.status(400).json({
success: false,
error: 'Das Passwort muss mindestens 6 Zeichen lang sein',
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
} as ApiResponse);
return;
}
@@ -355,6 +357,15 @@ export async function register(req: Request, res: Response): Promise<void> {
return;
}
const complexity = validatePasswordComplexity(password);
if (!complexity.ok) {
res.status(400).json({
success: false,
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
} as ApiResponse);
return;
}
const user = await authService.createUser({
email,
password,
+101 -2
View File
@@ -3,6 +3,7 @@ import prisma from '../lib/prisma.js';
import * as customerService from '../services/customer.service.js';
import * as authService from '../services/auth.service.js';
import { logChange } from '../services/audit.service.js';
import { validatePasswordComplexity, generateSecurePassword } from '../utils/passwordGenerator.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import {
sanitizeCustomer,
@@ -957,13 +958,111 @@ export async function updatePortalSettings(req: Request, res: Response): Promise
}
}
/**
* Generiert ein zufälliges, komplexes Passwort (16 Zeichen, gemischt).
* Setzt es NICHT direkt — wird im Frontend in den Setzen-Button-Flow gefüttert.
* Damit hat der Admin Wahlfreiheit (Generieren → ggf. anpassen → speichern).
*/
export async function generatePortalPassword(req: Request, res: Response): Promise<void> {
try {
const password = generateSecurePassword({ length: 16 });
res.json({ success: true, data: { password } } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Generieren des Passworts',
} as ApiResponse);
}
}
/**
* Verschickt die Portal-Zugangsdaten per E-Mail an die hinterlegte
* `email` (bevorzugt) oder fallback auf `portalEmail` des Kunden. Das
* Passwort wird aus dem `portalPasswordEncrypted`-Feld entschlüsselt
* (= das aktuell aktive Klartext-Passwort, das auch in der UI angezeigt wird).
*/
export async function sendPortalCredentials(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: {
id: true, firstName: true, lastName: true, salutation: true, companyName: true,
email: true, portalEmail: true, portalEnabled: true,
portalPasswordEncrypted: true, portalPasswordHash: true,
},
});
if (!customer) {
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
return;
}
if (!customer.portalEnabled) {
res.status(400).json({
success: false,
error: 'Portal ist für diesen Kunden nicht aktiviert',
} as ApiResponse);
return;
}
if (!customer.portalPasswordHash) {
res.status(400).json({
success: false,
error: 'Es ist noch kein Portal-Passwort gesetzt',
} as ApiResponse);
return;
}
const targetEmail = customer.email || customer.portalEmail;
if (!targetEmail) {
res.status(400).json({
success: false,
error: 'Kunde hat keine E-Mail-Adresse hinterlegt',
} as ApiResponse);
return;
}
const loginEmail = customer.portalEmail || customer.email!;
const plaintextPassword = await authService.getCustomerPortalPassword(customerId);
if (!plaintextPassword) {
res.status(400).json({
success: false,
error: 'Klartext-Passwort nicht verfügbar (alte Anlage ohne Encrypted-Feld bitte neu setzen)',
} as ApiResponse);
return;
}
await authService.sendPortalCredentialsEmail({
to: targetEmail,
customer,
loginEmail,
password: plaintextPassword,
});
await logChange({
req,
action: 'UPDATE',
resourceType: 'PortalSettings',
resourceId: customerId.toString(),
label: `Portal-Zugangsdaten per E-Mail versendet an ${targetEmail}`,
customerId,
});
res.json({ success: true, message: `Zugangsdaten an ${targetEmail} versendet` } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Versenden der Zugangsdaten',
} as ApiResponse);
}
}
export async function setPortalPassword(req: Request, res: Response): Promise<void> {
try {
const { password } = req.body;
if (!password || password.length < 6) {
// Komplexität: 12 Zeichen, Groß/Klein/Zahl/Sonderzeichen (zentrale Regel)
const complexity = validatePasswordComplexity(password);
if (!complexity.ok) {
res.status(400).json({
success: false,
error: 'Passwort muss mindestens 6 Zeichen lang sein',
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
} as ApiResponse);
return;
}
+23 -1
View File
@@ -4,6 +4,7 @@ import * as userService from '../services/user.service.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse } from '../types/index.js';
import { pickUserCreate, pickUserUpdate } from '../utils/sanitize.js';
import { validatePasswordComplexity } from '../utils/passwordGenerator.js';
// Users
export async function getUsers(req: Request, res: Response): Promise<void> {
@@ -51,7 +52,18 @@ export async function getUser(req: Request, res: Response): Promise<void> {
export async function createUser(req: Request, res: Response): Promise<void> {
try {
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
const user = await userService.createUser(pickUserCreate(req.body) as any);
const data = pickUserCreate(req.body) as any;
if (data?.password) {
const c = validatePasswordComplexity(data.password);
if (!c.ok) {
res.status(400).json({
success: false,
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + c.errors.join(', '),
} as ApiResponse);
return;
}
}
const user = await userService.createUser(data);
await logChange({
req, action: 'CREATE', resourceType: 'User',
resourceId: user.id.toString(),
@@ -71,6 +83,16 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
const userId = parseInt(req.params.id);
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
const data = pickUserUpdate(req.body);
if ((data as any)?.password) {
const c = validatePasswordComplexity((data as any).password);
if (!c.ok) {
res.status(400).json({
success: false,
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + c.errors.join(', '),
} as ApiResponse);
return;
}
}
// Vorherigen Stand laden für Audit inkl. Rollen, damit hasGdprAccess /
// hasDeveloperAccess (versteckte Rollen) korrekt verglichen werden.
+2
View File
@@ -37,6 +37,8 @@ router.get('/:customerId/portal', authenticate, requirePermission('customers:upd
router.put('/:customerId/portal', authenticate, requirePermission('customers:update'), customerController.updatePortalSettings);
router.post('/:customerId/portal/password', authenticate, requirePermission('customers:update'), customerController.setPortalPassword);
router.get('/:customerId/portal/password', authenticate, requirePermission('customers:update'), customerController.getPortalPassword);
router.post('/:customerId/portal/password/generate', authenticate, requirePermission('customers:update'), customerController.generatePortalPassword);
router.post('/:customerId/portal/send-credentials', authenticate, requirePermission('customers:update'), customerController.sendPortalCredentials);
// Representatives (Vertreter)
router.get('/:customerId/representatives', authenticate, requirePermission('customers:read'), customerController.getRepresentatives);
+74
View File
@@ -513,6 +513,80 @@ 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, '&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: #6b7280; font-size: 14px;">
Bitte ändern Sie Ihr Passwort nach dem ersten Login (im Portal unter „Mein Konto").
</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
+37
View File
@@ -88,6 +88,43 @@ export function generateSimplePassword(length = 12): string {
});
}
// ==================== PASSWORD COMPLEXITY VALIDATION ====================
/**
* Mindestanforderungen für vom User vergebene Passwörter.
* Generator-Output (generateSecurePassword) erfüllt diese standardmäßig.
*/
export interface PasswordComplexityResult {
ok: boolean;
errors: string[];
}
export function validatePasswordComplexity(pw: unknown): PasswordComplexityResult {
const errors: string[] = [];
if (typeof pw !== 'string') {
return { ok: false, errors: ['Passwort fehlt oder ist kein Text'] };
}
if (pw.length < 12) errors.push('mindestens 12 Zeichen');
if (!/[a-z]/.test(pw)) errors.push('mindestens einen Kleinbuchstaben');
if (!/[A-Z]/.test(pw)) errors.push('mindestens einen Großbuchstaben');
if (!/[0-9]/.test(pw)) errors.push('mindestens eine Ziffer');
// Sonderzeichen-Set bewusst breit auch Leerzeichen + Unicode-Punktuation
// zulassen, damit gängige Passwort-Manager-Outputs nicht abgelehnt werden.
if (!/[^A-Za-z0-9]/.test(pw)) errors.push('mindestens ein Sonderzeichen');
return { ok: errors.length === 0, errors };
}
/**
* Wirft mit sprechender Fehlermeldung, wenn das Passwort die Komplexität
* nicht erfüllt. Für Aufruf direkt im Controller, der die Exception fängt.
*/
export function assertPasswordComplexity(pw: unknown): void {
const r = validatePasswordComplexity(pw);
if (!r.ok) {
throw new Error('Passwort erfüllt Mindestanforderungen nicht: ' + r.errors.join(', '));
}
}
// Kryptografisch sichere Zufallszahl
function getRandomInt(max: number): number {
const bytes = randomBytes(4);