diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index de5c6796..eb8c2011 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -238,7 +238,9 @@ async function main() { const pick = (s: string) => s[Math.floor(Math.random() * s.length)]; // mind. einen aus jeder Klasse + Rest zufällig const chars = [pick(upper), pick(lower), pick(digits), pick(special)]; - for (let i = chars.length; i < 16; i++) chars.push(pick(all)); + // 28 Zeichen → Komplexität + komfortable Marge über dem 25-Zeichen- + // Mitarbeiter-Schwellwert (Pentest Runde 13). + for (let i = chars.length; i < 28; i++) chars.push(pick(all)); // Fisher-Yates Shuffle (sonst stehen die garantierten Klassen-Zeichen am Anfang) for (let i = chars.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); @@ -248,7 +250,7 @@ async function main() { } const envPassword = process.env.SEED_ADMIN_PASSWORD; - const adminPlainPassword = envPassword && envPassword.length >= 12 + const adminPlainPassword = envPassword && envPassword.length >= 25 ? envPassword : generateInitialPassword(); const hashedPassword = await bcrypt.hash(adminPlainPassword, 12); @@ -269,9 +271,12 @@ async function main() { console.log('========================================================'); console.log(' Admin-User: admin@admin.com'); - if (envPassword) { + if (envPassword && envPassword.length >= 25) { console.log(' Passwort: aus SEED_ADMIN_PASSWORD'); } else { + if (envPassword && envPassword.length < 25) { + console.log(' ⚠️ SEED_ADMIN_PASSWORD < 25 Zeichen, wird ignoriert!'); + } console.log(` Initial-Passwort: ${adminPlainPassword}`); console.log(' ⚠️ Dieses Passwort wird hier EINMAL ausgegeben!'); console.log(' Bitte sofort nach dem ersten Login ändern.'); diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts index 8b4aaffa..0bbdd58b 100644 --- a/backend/src/controllers/auth.controller.ts +++ b/backend/src/controllers/auth.controller.ts @@ -3,7 +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'; +import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH, PORTAL_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js'; // Refresh-Token-Cookie-Konfiguration. Der Cookie: // - httpOnly → kein JavaScript-Zugriff → bei XSS nicht klaubar @@ -244,7 +244,11 @@ export async function confirmPasswordReset(req: Request, res: Response): Promise return; } - const complexity = validatePasswordComplexity(password); + // Audience anhand des Tokens bestimmen, damit Admin-Reset 25 Zeichen + // verlangt und Portal-Customer-Reset weiterhin 12 reicht. + const audience = await authService.getPasswordResetAudience(token); + const minLength = audience === 'admin' ? STAFF_MIN_PASSWORD_LENGTH : PORTAL_MIN_PASSWORD_LENGTH; + const complexity = validatePasswordComplexity(password, { minLength }); if (!complexity.ok) { res.status(400).json({ success: false, @@ -377,7 +381,8 @@ export async function register(req: Request, res: Response): Promise { return; } - const complexity = validatePasswordComplexity(password); + // Mitarbeiter-Anlage: 25-Zeichen-Schwellwert + const complexity = validatePasswordComplexity(password, { minLength: STAFF_MIN_PASSWORD_LENGTH }); if (!complexity.ok) { res.status(400).json({ success: false, diff --git a/backend/src/controllers/user.controller.ts b/backend/src/controllers/user.controller.ts index fea13261..3101ecbc 100644 --- a/backend/src/controllers/user.controller.ts +++ b/backend/src/controllers/user.controller.ts @@ -4,7 +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'; +import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js'; // Users export async function getUsers(req: Request, res: Response): Promise { @@ -54,7 +54,7 @@ export async function createUser(req: Request, res: Response): Promise { // Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz) const data = pickUserCreate(req.body) as any; if (data?.password) { - const c = validatePasswordComplexity(data.password); + const c = validatePasswordComplexity(data.password, { minLength: STAFF_MIN_PASSWORD_LENGTH }); if (!c.ok) { res.status(400).json({ success: false, @@ -160,7 +160,7 @@ export async function setUserPassword(req: Request, res: Response): Promise { + 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. diff --git a/backend/src/utils/passwordGenerator.ts b/backend/src/utils/passwordGenerator.ts index 30bc032e..d941f8da 100644 --- a/backend/src/utils/passwordGenerator.ts +++ b/backend/src/utils/passwordGenerator.ts @@ -99,12 +99,24 @@ export interface PasswordComplexityResult { errors: string[]; } -export function validatePasswordComplexity(pw: unknown): PasswordComplexityResult { +// Mindestlängen nach Kontext (Pentest Runde 13 / 2026-05-18): +// Endkunden tippen ihr Portal-Passwort auch auf dem Handy ein – 12 ist hier +// der Endkunden-Floor. Mitarbeiter/Admin nutzen Passwort-Manager → 25 +// Zeichen entsprechen der aktuellen BSI-Empfehlung für lange Passphrasen +// mit Komplexität. +export const PORTAL_MIN_PASSWORD_LENGTH = 12; +export const STAFF_MIN_PASSWORD_LENGTH = 25; + +export function validatePasswordComplexity( + pw: unknown, + opts: { minLength?: number } = {}, +): PasswordComplexityResult { + const minLength = opts.minLength ?? PORTAL_MIN_PASSWORD_LENGTH; 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 (pw.length < minLength) errors.push(`mindestens ${minLength} 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'); @@ -118,8 +130,8 @@ export function validatePasswordComplexity(pw: unknown): PasswordComplexityResul * 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); +export function assertPasswordComplexity(pw: unknown, opts: { minLength?: number } = {}): void { + const r = validatePasswordComplexity(pw, opts); if (!r.ok) { throw new Error('Passwort erfüllt Mindestanforderungen nicht: ' + r.errors.join(', ')); } diff --git a/docs/todo.md b/docs/todo.md index 2f0ec1a0..267fd16d 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,42 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🔐 Mitarbeiter-Passwörter auf 25 Zeichen (BSI-Empfehlung)** + - 12 Zeichen sind heute der untere akzeptable Rand. NIST/OWASP/BSI + empfehlen 14-25+ Zeichen. Mitarbeiter/Admin nutzen Passwort-Manager + → Länge kostet nichts, Sicherheitsgewinn ist real. + - **Schwellwerte**: `STAFF_MIN_PASSWORD_LENGTH = 25`, + `PORTAL_MIN_PASSWORD_LENGTH = 12` (Endkunden tippen das auch auf + dem Handy ein). + - **Backend-Pfade**: + * `createUser` + `register` + `setUserPassword` → 25 Zeichen + * `setPortalPassword` + `changeInitialPortalPassword` → bleibt 12 + * `confirmPasswordReset`: Server bestimmt Audience anhand des + Tokens (`getPasswordResetAudience`) → User-Token = 25, Customer- + Token = 12. Damit kann ein Angreifer nicht durch Body-Hint + auf den schwächeren Schwellwert ausweichen. + - **Seed-Admin**: Default-Passwort jetzt 28-char Zufallspasswort + (alle 4 Klassen garantiert), via `SEED_ADMIN_PASSWORD`-ENV + überschreibbar – aber nur wenn ≥ 25 Zeichen, sonst ignoriert + mit Log-Warnung. + - **Frontend**: + * UserList: Hinweis-Text "Mind. 25 Zeichen". Update + Passwort + gleichzeitig → Frontend macht jetzt zwei Calls (PUT + neuer + `POST /users/:id/password`) statt Passwort durch Update-Body + durchzuschmuggeln. + * PasswordResetConfirm: Hinweis "Mind. 12 Zeichen (Mitarbeiter: + 25)", Server entscheidet endgültig. + * `userApi.setPassword(id, password)` neu in api.ts. + - **Live-verifiziert**: + * `POST /users/6/password "Hallo123!Test"` (12 chars) → 400 + "mindestens 25 Zeichen" + * `POST /users/6/password "MeinExtremLangesPW2026!Test"` → 200, + Login mit dem neuen PW → success + * `POST /customers/3/portal/password "Hallo123!Test"` (12) → 200 + * `POST /users {…,password:"Hallo123!Test"}` → 400 (25-char-Floor) + - **Nächster größerer Sprung** wäre **MFA für Mitarbeiter-Login** + (TOTP via Authenticator). Eigenes Thema, separate Aufgabe. + - [x] **🚨 Pentest Runde 12 – Folge-Fixes: XSS-Reste, User-PW-Endpoint, JS-Error-Leak, Seed-PW** - **M2-Reste (XSS-Strings noch in DB)**: neues idempotentes Script `prisma/cleanup-xss-and-mass-assignment.ts` läuft beim diff --git a/frontend/src/pages/PasswordResetConfirm.tsx b/frontend/src/pages/PasswordResetConfirm.tsx index 30e86994..e35e7875 100644 --- a/frontend/src/pages/PasswordResetConfirm.tsx +++ b/frontend/src/pages/PasswordResetConfirm.tsx @@ -27,8 +27,10 @@ export default function PasswordResetConfirm() { return; } - if (password.length < 6) { - setError('Das Passwort muss mindestens 6 Zeichen lang sein.'); + // Server prüft Komplexität endgültig (Mitarbeiter: 25 Zeichen, Portal- + // Kunden: 12). Frontend macht nur die naheliegenden Sanity-Checks. + if (password.length < 12) { + setError('Das Passwort muss mindestens 12 Zeichen lang sein (Mitarbeiter: 25).'); return; } @@ -124,7 +126,9 @@ export default function PasswordResetConfirm() { {showPassword ? : } -

Mindestens 6 Zeichen

+

+ Mind. 12 Zeichen (Mitarbeiter: 25), Groß-/Kleinbuchstabe, Ziffer, Sonderzeichen +

{ + userApi.setPassword(user.id, formData.password).catch((err) => { + alert(err?.response?.data?.error || 'Passwort konnte nicht gesetzt werden'); + }); + }, + }); + } else { + updateMutation.mutate(updateData); } - updateMutation.mutate(updateData); } else { createMutation.mutate({ email: formData.email, @@ -390,13 +399,18 @@ function UserModal({ required /> - setFormData({ ...formData, password: e.target.value })} - required={!user} - /> +
+ setFormData({ ...formData, password: e.target.value })} + required={!user} + /> +

+ Mind. 25 Zeichen, Groß-/Kleinbuchstabe, Ziffer, Sonderzeichen. +

+
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index b1335bc1..88101aab 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1309,6 +1309,12 @@ export const userApi = { const res = await api.put>(`/users/${id}`, data); return res.data; }, + // Passwort eines Users zurücksetzen (Admin-Funktion). Separat vom generischen + // Update, damit der Vorgang einen eigenen Audit-Eintrag bekommt. + setPassword: async (id: number, password: string) => { + const res = await api.post>(`/users/${id}/password`, { password }); + return res.data; + }, delete: async (id: number) => { const res = await api.delete>(`/users/${id}`); return res.data;