fix(cli): Passwort nie mehr als Klartext-Argument, neuer reset-password-Befehl

Vom Nightbot-Nachtriage-Durchlauf gefunden (#100) und vom Nutzer bestätigt:
create-superadmin --password=… landete im Klartext in argv – für jeden
lokalen Nutzer über `ps aux` und in der Shell-History sichtbar.

- create-superadmin fragt das Passwort jetzt interaktiv ohne Echo ab (wie
  `passwd`), zweimal zur Bestätigung. Für Skripte/Automatisierung optional
  über die Umgebungsvariable KC_CLI_PASSWORD (landet nicht in argv/ps,
  nur im Prozess-Environ des ausführenden Nutzers/root).
- Neuer Befehl reset-password --email=… : bisher gab es KEINEN Weg, das
  Passwort eines bestehenden Nutzers (insb. eines ausgesperrten
  Superadmins) zurückzusetzen außer über die Selbstbedienung per E-Mail-
  Link (die bei fehlendem Postfachzugriff oder noch nicht eingerichtetem
  SMTP nicht hilft) oder direkte DB-Manipulation. Funktioniert für jeden
  Nutzer (Kunde oder Personal), setzt failed_logins/locked_until zurück,
  protokolliert im Audit-Log.

Beide Befehle scheitern jetzt sauber (Fehlermeldung + Exit-Code, kein
Stack-Trace) statt mit --password=<12+ Zeichen> zu arbeiten.

Gegen die Testdatenbank durchexerziert: Anlegen, doppelte E-Mail, zu
kurzes Passwort, Reset, unbekannte E-Mail, fehlendes TTY ohne
KC_CLI_PASSWORD – alle Pfade verhalten sich wie erwartet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kundencenter 2026-09-29 11:32:26 +02:00
parent 1bda3ac1aa
commit 881f19ed05

View file

@ -6,15 +6,59 @@ import { audit } from '../core/audit.js';
const [cmd, ...args] = process.argv.slice(2);
const opt = (n: string) => args.find((a) => a.startsWith(`--${n}=`))?.slice(n.length + 3);
const ARGON = { memoryCost: 19456, timeCost: 2, parallelism: 1 };
/** Passwort ohne Echo abfragen (wie `passwd`): landet nie in argv, `ps aux` oder der Shell-History. */
async function promptHidden(question: string): Promise<string> {
process.stdout.write(question);
const stdin = process.stdin;
if (!stdin.isTTY) throw new Error('Kein interaktives Terminal verfügbar. Für nicht-interaktive Aufrufe stattdessen KC_CLI_PASSWORD setzen (Umgebungsvariable, nicht als Argument).');
return new Promise((resolve, reject) => {
stdin.resume(); stdin.setRawMode(true);
let input = '';
const onData = (chunk: Buffer) => {
const ch = chunk.toString('utf8');
if (ch === '\n' || ch === '\r' || ch === '\u0004') { cleanup(); process.stdout.write('\n'); resolve(input); }
else if (ch === '\u0003') { cleanup(); process.stdout.write('\n'); reject(new Error('Abgebrochen')); }
else if (ch === '\u007f' || ch === '\b') input = input.slice(0, -1);
else input += ch;
};
const cleanup = () => { stdin.setRawMode(false); stdin.pause(); stdin.removeListener('data', onData); };
stdin.on('data', onData);
});
}
/** Passwort holen: interaktiv (verdeckt) abfragen, oder – für Skripte/Automatisierung – aus KC_CLI_PASSWORD lesen.
* Nie als --password=…-Argument, das in `ps aux` für alle lokalen Nutzer sichtbar wäre. */
async function readPassword(): Promise<string> {
if (process.env.KC_CLI_PASSWORD) return process.env.KC_CLI_PASSWORD;
const pw = await promptHidden('Passwort (min. 12 Zeichen): ');
const repeat = await promptHidden('Passwort wiederholen: ');
if (pw !== repeat) throw new Error('Passwörter stimmen nicht überein');
return pw;
}
if (cmd === 'create-superadmin') {
const email = opt('email')?.toLowerCase(), name = opt('name') ?? 'Superadmin', pw = opt('password');
if (!email || !pw || pw.length < 12) { console.error('Nutzung: create-superadmin --email=… --name=… --password=<min. 12 Zeichen>'); process.exit(2); }
if (await one('SELECT 1 AS x FROM users WHERE email = ?', [email])) { console.error('E-Mail existiert bereits'); process.exit(1); }
const email = opt('email')?.toLowerCase(), name = opt('name') ?? 'Superadmin';
if (!email) { console.error('Nutzung: create-superadmin --email=… [--name=…] (Passwort wird danach abgefragt, nie als Argument)'); process.exit(2); }
if (await one('SELECT 1 AS x FROM users WHERE email = ?', [email])) { console.error('E-Mail existiert bereits (für einen Reset stattdessen: reset-password --email=…)'); process.exit(1); }
const pw = await readPassword().catch((e) => { console.error((e as Error).message); process.exit(1); });
if (pw.length < 12) { console.error('Passwort zu kurz (min. 12 Zeichen)'); process.exit(2); }
const id = randomUUID();
await run("INSERT INTO users (id, email, name, password_hash, kind, staff_role, status, email_verified_at) VALUES (?,?,?,?,'staff','superadmin','active',UTC_TIMESTAMP(3))", [id, email, name, await hash(pw, { memoryCost: 19456, timeCost: 2, parallelism: 1 })]);
await run("INSERT INTO users (id, email, name, password_hash, kind, staff_role, status, email_verified_at) VALUES (?,?,?,?,'staff','superadmin','active',UTC_TIMESTAMP(3))", [id, email, name, await hash(pw, ARGON)]);
await audit({ actorType: 'system', action: 'user.create', resourceType: 'user', resourceId: id, after: { email, kind: 'staff', staffRole: 'superadmin', via: 'cli' } });
console.log('Superadmin angelegt:', email, '(2FA muss beim ersten Login eingerichtet werden)');
} else if (cmd === 'reset-password') {
// Fallback für den Fall, dass die Selbstbedienung (E-Mail-Link unter /passwort-vergessen) nicht erreichbar ist
// (z. B. Postfach ebenfalls verloren, oder SMTP noch nicht eingerichtet). Funktioniert für jeden Nutzer, nicht nur Staff.
const email = opt('email')?.toLowerCase();
if (!email) { console.error('Nutzung: reset-password --email=… (Passwort wird danach abgefragt, nie als Argument)'); process.exit(2); }
const u = await one('SELECT id FROM users WHERE email = ?', [email]);
if (!u) { console.error('Kein Nutzer mit dieser E-Mail'); process.exit(1); }
const pw = await readPassword().catch((e) => { console.error((e as Error).message); process.exit(1); });
if (pw.length < 12) { console.error('Passwort zu kurz (min. 12 Zeichen)'); process.exit(2); }
await run('UPDATE users SET password_hash = ?, failed_logins = 0, locked_until = NULL WHERE id = ?', [await hash(pw, ARGON), u.id]);
await audit({ actorType: 'system', action: 'auth.password.reset', resourceType: 'user', resourceId: u.id, after: { via: 'cli' } });
console.log('Passwort zurückgesetzt für', email, '- bestehende Sitzungen bleiben aktiv (nicht automatisch abgemeldet)');
} else if (cmd === 'connector-secrets') {
// Zugangsdaten einer Verbindung aus einer geschützten Datei setzen (Werte erscheinen nie in Argumenten/Logs)
// Nutzung: connector-secrets --name=Licensing --file=/pfad/datei.txt (Zeilen SCHLÜSSEL=Wert; LICENSING_API_USER→username, LICENSING_API_PASSWORD→password)
@ -50,7 +94,7 @@ if (cmd === 'create-superadmin') {
else if (sub === 'status') console.log(JSON.stringify(await readStatus(cfg), null, 2));
else { console.error('Nutzung: backup run | restore-test [datei] | status'); process.exitCode = 2; }
} else {
console.error('Befehle: create-superadmin, connector-secrets, import-domains, backup');
console.error('Befehle: create-superadmin, reset-password, connector-secrets, import-domains, backup');
process.exit(2);
}
await pool.end();