diff --git a/apps/api/src/cli/index.ts b/apps/api/src/cli/index.ts index 0971736..6798408 100644 --- a/apps/api/src/cli/index.ts +++ b/apps/api/src/cli/index.ts @@ -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 { + 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 { + 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='); 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();