3e1fc3eab2
Portal-Customer-Schwellwert bleibt 12 (Handy-Eingabe → längere PWs
erhöhen Reuse-Risiko). Mitarbeiter/Admin nutzen Passwort-Manager,
für die kostet die Länge nichts.
passwordGenerator.ts:
- STAFF_MIN_PASSWORD_LENGTH = 25, PORTAL_MIN_PASSWORD_LENGTH = 12
- validatePasswordComplexity({ minLength }) parametrisiert
Mitarbeiter-Pfade auf 25:
- createUser, register, setUserPassword
- confirmPasswordReset: Audience aus Token bestimmen
(getPasswordResetAudience), User → 25, Customer → 12. Kein
Body-Hint, damit kein Downgrade-Trick möglich.
Portal-Pfade unverändert (default 12):
- setPortalPassword, changeInitialPortalPassword
Seed-Admin:
- 28-char Zufallspasswort (statt 16) mit allen 4 Klassen garantiert
- SEED_ADMIN_PASSWORD-ENV nur akzeptiert wenn ≥ 25 Zeichen,
sonst Log-Warnung + Random-Fallback
Frontend:
- UserList: Hinweis "Mind. 25 Zeichen". Update + PW gleichzeitig →
zwei API-Calls (PUT + POST /users/:id/password) statt
Password im Body durchzuschmuggeln (Backend strippt es eh)
- PasswordResetConfirm: Hinweis "Mind. 12 (Mitarbeiter: 25)"
- userApi.setPassword(id, password) neu
Live-verifiziert:
- POST /users/6/password "Hallo123!Test" (12) → 400 "mindestens 25"
- POST /users/6/password "MeinExtremLangesPW2026!Test" → 200,
Login mit neuem PW → success
- POST /customers/3/portal/password "Hallo123!Test" (12) → 200
- POST /users createUser mit 12-char-PW → 400 "mindestens 25"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
151 lines
4.6 KiB
TypeScript
151 lines
4.6 KiB
TypeScript
// ==================== PASSWORD GENERATOR ====================
|
||
// Generiert sichere, zufällige Passwörter
|
||
|
||
import { randomBytes } from 'crypto';
|
||
|
||
// Zeichensätze für Passwort-Generierung
|
||
const LOWERCASE = 'abcdefghijklmnopqrstuvwxyz';
|
||
const UPPERCASE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||
const NUMBERS = '0123456789';
|
||
const SPECIAL = '!@#$%^&*()_+-=[]{}|;:,.<>?';
|
||
|
||
// Standard-Passwortlänge
|
||
const DEFAULT_LENGTH = 16;
|
||
|
||
export interface PasswordOptions {
|
||
length?: number;
|
||
includeLowercase?: boolean;
|
||
includeUppercase?: boolean;
|
||
includeNumbers?: boolean;
|
||
includeSpecial?: boolean;
|
||
}
|
||
|
||
/**
|
||
* Generiert ein kryptografisch sicheres Passwort
|
||
*/
|
||
export function generateSecurePassword(options: PasswordOptions = {}): string {
|
||
const {
|
||
length = DEFAULT_LENGTH,
|
||
includeLowercase = true,
|
||
includeUppercase = true,
|
||
includeNumbers = true,
|
||
includeSpecial = true,
|
||
} = options;
|
||
|
||
// Zeichensatz zusammenstellen
|
||
let charset = '';
|
||
const requiredChars: string[] = [];
|
||
|
||
if (includeLowercase) {
|
||
charset += LOWERCASE;
|
||
requiredChars.push(getRandomChar(LOWERCASE));
|
||
}
|
||
if (includeUppercase) {
|
||
charset += UPPERCASE;
|
||
requiredChars.push(getRandomChar(UPPERCASE));
|
||
}
|
||
if (includeNumbers) {
|
||
charset += NUMBERS;
|
||
requiredChars.push(getRandomChar(NUMBERS));
|
||
}
|
||
if (includeSpecial) {
|
||
charset += SPECIAL;
|
||
requiredChars.push(getRandomChar(SPECIAL));
|
||
}
|
||
|
||
if (charset.length === 0) {
|
||
throw new Error('Mindestens ein Zeichensatz muss aktiviert sein');
|
||
}
|
||
|
||
// Restliche Zeichen auffüllen
|
||
const remainingLength = Math.max(0, length - requiredChars.length);
|
||
const randomChars: string[] = [];
|
||
|
||
for (let i = 0; i < remainingLength; i++) {
|
||
randomChars.push(getRandomChar(charset));
|
||
}
|
||
|
||
// Alle Zeichen mischen (Fisher-Yates Shuffle)
|
||
const allChars = [...requiredChars, ...randomChars];
|
||
for (let i = allChars.length - 1; i > 0; i--) {
|
||
const j = getRandomInt(i + 1);
|
||
[allChars[i], allChars[j]] = [allChars[j], allChars[i]];
|
||
}
|
||
|
||
return allChars.join('');
|
||
}
|
||
|
||
/**
|
||
* Generiert ein einfaches Passwort ohne Sonderzeichen (für APIs die das nicht mögen)
|
||
*/
|
||
export function generateSimplePassword(length = 12): string {
|
||
return generateSecurePassword({
|
||
length,
|
||
includeLowercase: true,
|
||
includeUppercase: true,
|
||
includeNumbers: true,
|
||
includeSpecial: false,
|
||
});
|
||
}
|
||
|
||
// ==================== 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[];
|
||
}
|
||
|
||
// 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 < 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');
|
||
// 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, opts: { minLength?: number } = {}): void {
|
||
const r = validatePasswordComplexity(pw, opts);
|
||
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);
|
||
const value = bytes.readUInt32BE(0);
|
||
return value % max;
|
||
}
|
||
|
||
// Zufälliges Zeichen aus einem Zeichensatz
|
||
function getRandomChar(charset: string): string {
|
||
return charset[getRandomInt(charset.length)];
|
||
}
|