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);
@@ -1796,6 +1796,38 @@ function ContractsTab({
);
}
// Passwort-Komplexität muss zur Backend-Regel in
// backend/src/utils/passwordGenerator.ts:validatePasswordComplexity passen.
function passwordMeetsComplexity(pw: string): boolean {
return (
pw.length >= 12 &&
/[a-z]/.test(pw) &&
/[A-Z]/.test(pw) &&
/[0-9]/.test(pw) &&
/[^A-Za-z0-9]/.test(pw)
);
}
// Live-Hinweis welche Komplexitäts-Anforderungen noch fehlen
function PasswordComplexityHint({ password }: { password: string }) {
const checks = [
{ ok: password.length >= 12, label: '≥ 12 Zeichen' },
{ ok: /[a-z]/.test(password), label: 'Kleinbuchstabe' },
{ ok: /[A-Z]/.test(password), label: 'Großbuchstabe' },
{ ok: /[0-9]/.test(password), label: 'Ziffer' },
{ ok: /[^A-Za-z0-9]/.test(password), label: 'Sonderzeichen' },
];
return (
<ul className="mt-2 text-xs space-y-0.5">
{checks.map((c) => (
<li key={c.label} className={c.ok ? 'text-green-600' : 'text-gray-500'}>
{c.ok ? '✓' : '○'} {c.label}
</li>
))}
</ul>
);
}
// Gespeichertes Passwort anzeigen
function StoredPasswordDisplay({ customerId }: { customerId: number }) {
const [showStoredPassword, setShowStoredPassword] = useState(false);
@@ -1898,10 +1930,35 @@ function PortalTab({
onSuccess: () => {
setNewPassword('');
queryClient.invalidateQueries({ queryKey: ['customer-portal', customerId] });
alert('Passwort wurde gesetzt');
toast.success('Passwort wurde gesetzt');
},
onError: (error: Error) => {
alert(error.message);
toast.error(error.message);
},
});
// Passwort generieren (16 Zeichen, komplex) ins Input-Feld füllen
const generatePasswordMutation = useMutation({
mutationFn: () => customerApi.generatePortalPassword(customerId),
onSuccess: (res) => {
const generated = res.data?.password || '';
setNewPassword(generated);
setShowPassword(true);
toast.success('Komplexes Passwort generiert jetzt „Setzen" klicken.');
},
onError: (error: Error) => {
toast.error(error.message);
},
});
// Zugangsdaten per E-Mail an den Kunden senden
const sendCredentialsMutation = useMutation({
mutationFn: () => customerApi.sendPortalCredentials(customerId),
onSuccess: (res) => {
toast.success(res.message || 'Zugangsdaten gesendet');
},
onError: (error: Error) => {
toast.error(error.message);
},
});
@@ -2003,7 +2060,7 @@ function PortalTab({
type={showPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Mindestens 6 Zeichen"
placeholder="Mind. 12 Zeichen, Groß/Klein/Zahl/Sonderzeichen"
disabled={!canEdit}
/>
<button
@@ -2014,15 +2071,48 @@ function PortalTab({
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<Button
variant="secondary"
onClick={() => generatePasswordMutation.mutate()}
disabled={!canEdit || generatePasswordMutation.isPending}
title='Komplexes Passwort generieren (16 Zeichen, Groß/Klein/Zahl/Sonderzeichen). Wird ins Feld geschrieben danach "Setzen" klicken.'
>
{generatePasswordMutation.isPending ? 'Generieren...' : 'Generieren'}
</Button>
<Button
onClick={() => setPasswordMutation.mutate(newPassword)}
disabled={!canEdit || newPassword.length < 6 || setPasswordMutation.isPending}
disabled={!canEdit || !passwordMeetsComplexity(newPassword) || setPasswordMutation.isPending}
title={passwordMeetsComplexity(newPassword) ? 'Passwort speichern' : 'Komplexität nicht erfüllt'}
>
{setPasswordMutation.isPending ? 'Speichern...' : 'Setzen'}
</Button>
</div>
{/* Komplexitäts-Hinweise: zeigt live welche Anforderungen erfüllt sind */}
{newPassword.length > 0 && !passwordMeetsComplexity(newPassword) && (
<PasswordComplexityHint password={newPassword} />
)}
{portal?.hasPassword && (
<StoredPasswordDisplay customerId={customerId} />
<>
<StoredPasswordDisplay customerId={customerId} />
<div className="mt-3">
<Button
variant="secondary"
size="sm"
onClick={() => {
if (confirm(
'Aktuelles Portal-Passwort und Login-URL per E-Mail an den Kunden senden?\n\n' +
'Hinweis: Das Passwort wird im Klartext in der E-Mail enthalten sein.'
)) {
sendCredentialsMutation.mutate();
}
}}
disabled={!canEdit || sendCredentialsMutation.isPending}
title="Login-URL + E-Mail + Passwort an die Kunden-E-Mail versenden"
>
{sendCredentialsMutation.isPending ? 'Sende...' : 'Zugangsdaten per E-Mail versenden'}
</Button>
</div>
</>
)}
</div>
)}
+8
View File
@@ -168,6 +168,14 @@ export const customerApi = {
const res = await api.get<ApiResponse<{ password: string | null }>>(`/customers/${customerId}/portal/password`);
return res.data;
},
generatePortalPassword: async (customerId: number) => {
const res = await api.post<ApiResponse<{ password: string }>>(`/customers/${customerId}/portal/password/generate`);
return res.data;
},
sendPortalCredentials: async (customerId: number) => {
const res = await api.post<ApiResponse<void>>(`/customers/${customerId}/portal/send-credentials`);
return res.data;
},
// Vertreter-Verwaltung
getRepresentatives: async (customerId: number) => {
const res = await api.get<ApiResponse<CustomerRepresentative[]>>(`/customers/${customerId}/representatives`);