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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, '&').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: #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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user