Mitarbeiter-Passwörter auf 25 Zeichen (BSI-Empfehlung)
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>
This commit is contained in:
@@ -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.');
|
||||
|
||||
@@ -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<void> {
|
||||
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,
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -54,7 +54,7 @@ export async function createUser(req: Request, res: Response): Promise<void> {
|
||||
// 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<void
|
||||
res.status(400).json({ success: false, error: 'Passwort erforderlich' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
const c = validatePasswordComplexity(password);
|
||||
const c = validatePasswordComplexity(password, { minLength: STAFF_MIN_PASSWORD_LENGTH });
|
||||
if (!c.ok) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
|
||||
@@ -768,6 +768,26 @@ export async function requestPasswordReset(email: string, userType: 'admin' | 'p
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stellt fest, ob ein Reset-Token zu einem Mitarbeiter (admin) oder einem
|
||||
* Portal-Customer (portal) gehört. Wird vom Controller benötigt, um den
|
||||
* passenden Komplexitäts-Schwellwert (25 bzw. 12 Zeichen) anzuwenden,
|
||||
* BEVOR das Passwort tatsächlich gesetzt wird. Pentest Runde 13.
|
||||
*/
|
||||
export async function getPasswordResetAudience(token: string): Promise<'admin' | 'portal' | null> {
|
||||
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.
|
||||
|
||||
@@ -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(', '));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">Mindestens 6 Zeichen</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Mind. 12 Zeichen (Mitarbeiter: 25), Groß-/Kleinbuchstabe, Ziffer, Sonderzeichen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
|
||||
@@ -323,10 +323,19 @@ function UserModal({
|
||||
telegramUsername: formData.telegramUsername || undefined,
|
||||
signalNumber: formData.signalNumber || undefined,
|
||||
};
|
||||
// Passwort-Setzen ist serverseitig ein eigener Endpoint (separater
|
||||
// Audit-Eintrag). Wenn beides gefragt: erst Daten, dann PW.
|
||||
if (formData.password) {
|
||||
updateData.password = formData.password;
|
||||
updateMutation.mutate(updateData, {
|
||||
onSuccess: () => {
|
||||
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
|
||||
/>
|
||||
|
||||
<Input
|
||||
label={user ? 'Neues Passwort (leer = unverändert)' : 'Passwort *'}
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
required={!user}
|
||||
/>
|
||||
<div>
|
||||
<Input
|
||||
label={user ? 'Neues Passwort (leer = unverändert)' : 'Passwort *'}
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
required={!user}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Mind. 25 Zeichen, Groß-/Kleinbuchstabe, Ziffer, Sonderzeichen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Messaging-Kanäle (für Datenschutz-Versand)</label>
|
||||
|
||||
@@ -1309,6 +1309,12 @@ export const userApi = {
|
||||
const res = await api.put<ApiResponse<User>>(`/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<ApiResponse<void>>(`/users/${id}/password`, { password });
|
||||
return res.data;
|
||||
},
|
||||
delete: async (id: number) => {
|
||||
const res = await api.delete<ApiResponse<void>>(`/users/${id}`);
|
||||
return res.data;
|
||||
|
||||
Reference in New Issue
Block a user