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:
parent
1bda3ac1aa
commit
881f19ed05
1 changed files with 49 additions and 5 deletions
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue