diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts index 51f5283e..ce2924df 100644 --- a/backend/src/controllers/auth.controller.ts +++ b/backend/src/controllers/auth.controller.ts @@ -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 { 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, diff --git a/backend/src/controllers/customer.controller.ts b/backend/src/controllers/customer.controller.ts index 66dc9273..5633745a 100644 --- a/backend/src/controllers/customer.controller.ts +++ b/backend/src/controllers/customer.controller.ts @@ -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 { + 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 { + 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 { 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; } diff --git a/backend/src/controllers/user.controller.ts b/backend/src/controllers/user.controller.ts index 6ab0a816..4ea0e4dc 100644 --- a/backend/src/controllers/user.controller.ts +++ b/backend/src/controllers/user.controller.ts @@ -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 { @@ -51,7 +52,18 @@ export async function getUser(req: Request, res: Response): Promise { export async function createUser(req: Request, res: Response): Promise { 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 { 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. diff --git a/backend/src/routes/customer.routes.ts b/backend/src/routes/customer.routes.ts index 95100d82..41c50de5 100644 --- a/backend/src/routes/customer.routes.ts +++ b/backend/src/routes/customer.routes.ts @@ -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); diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts index 0ccf9dcf..f285c209 100644 --- a/backend/src/services/auth.service.ts +++ b/backend/src/services/auth.service.ts @@ -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 { + 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, '"'); + + const html = ` +
+

Ihre Zugangsdaten zum Kundenportal

+

Hallo ${esc(name)},

+

anbei Ihre Zugangsdaten zum Kundenportal:

+ + + + + + + +
Login-URL:${esc(loginUrl)}
E-Mail:${esc(params.loginEmail)}
Passwort:${esc(params.password)}
+

+ Bitte ändern Sie Ihr Passwort nach dem ersten Login (im Portal unter „Mein Konto"). +

+
+

+ Diese Nachricht enthält sensible Zugangsdaten – bitte sicher verwahren oder nach + dem Login löschen. +

+
+ `; + + 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 diff --git a/backend/src/utils/passwordGenerator.ts b/backend/src/utils/passwordGenerator.ts index 3364ab3a..30bc032e 100644 --- a/backend/src/utils/passwordGenerator.ts +++ b/backend/src/utils/passwordGenerator.ts @@ -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); diff --git a/frontend/src/pages/customers/CustomerDetail.tsx b/frontend/src/pages/customers/CustomerDetail.tsx index 6ad3ae18..63c1f614 100644 --- a/frontend/src/pages/customers/CustomerDetail.tsx +++ b/frontend/src/pages/customers/CustomerDetail.tsx @@ -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 ( +
    + {checks.map((c) => ( +
  • + {c.ok ? '✓' : '○'} {c.label} +
  • + ))} +
+ ); +} + // 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} /> + + {/* Komplexitäts-Hinweise: zeigt live welche Anforderungen erfüllt sind */} + {newPassword.length > 0 && !passwordMeetsComplexity(newPassword) && ( + + )} {portal?.hasPassword && ( - + <> + +
+ +
+ )} )} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index e182cf4e..d3945408 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -168,6 +168,14 @@ export const customerApi = { const res = await api.get>(`/customers/${customerId}/portal/password`); return res.data; }, + generatePortalPassword: async (customerId: number) => { + const res = await api.post>(`/customers/${customerId}/portal/password/generate`); + return res.data; + }, + sendPortalCredentials: async (customerId: number) => { + const res = await api.post>(`/customers/${customerId}/portal/send-credentials`); + return res.data; + }, // Vertreter-Verwaltung getRepresentatives: async (customerId: number) => { const res = await api.get>(`/customers/${customerId}/representatives`);