141 lines
5.8 KiB
JavaScript
141 lines
5.8 KiB
JavaScript
#!/usr/bin/env node
|
||
// Local recovery only: requires filesystem/container access to the controller.
|
||
import Database from 'better-sqlite3';
|
||
import { createHash } from 'node:crypto';
|
||
import { resolve } from 'node:path';
|
||
import { emitKeypressEvents } from 'node:readline';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { hashPassword } from '../src/lib/server/auth/passwords.js';
|
||
|
||
/** @param {string} prompt @returns {Promise<string>} */
|
||
function secret(prompt) {
|
||
return new Promise((resolve, reject) => {
|
||
let value = '';
|
||
process.stdout.write(prompt);
|
||
emitKeypressEvents(process.stdin);
|
||
process.stdin.setRawMode(true);
|
||
process.stdin.resume();
|
||
const cleanup = () => {
|
||
process.stdin.off('keypress', listener);
|
||
process.stdin.setRawMode(false);
|
||
process.stdin.pause();
|
||
process.stdout.write('\n');
|
||
};
|
||
/** @param {string | undefined} text @param {import('node:readline').Key | undefined} key */
|
||
const listener = (text, key) => {
|
||
if (key?.ctrl && key.name === 'c') {
|
||
cleanup();
|
||
reject(new Error('Cancelled.'));
|
||
} else if (key?.name === 'return' || key?.name === 'enter') {
|
||
cleanup();
|
||
resolve(value);
|
||
} else if (key?.name === 'backspace') value = [...value].slice(0, -1).join('');
|
||
else if (
|
||
text &&
|
||
!key?.ctrl &&
|
||
!key?.meta &&
|
||
// eslint-disable-next-line no-control-regex -- Reject terminal control characters while collecting password input.
|
||
!/[\x00-\x1f\x7f]/.test(text) &&
|
||
[...value].length < 128
|
||
)
|
||
value += text;
|
||
};
|
||
process.stdin.on('keypress', listener);
|
||
});
|
||
}
|
||
|
||
/** @param {import('better-sqlite3').Database} database @param {string} username */
|
||
function administrator(database, username) {
|
||
const user = /** @type {{ id: string, username: string } | undefined} */ (
|
||
database
|
||
.prepare(
|
||
"SELECT id,username FROM auth_users WHERE username=? AND role='admin' AND disabled=0"
|
||
)
|
||
.get(username)
|
||
);
|
||
if (!user) throw new Error('An enabled administrator with that username was not found.');
|
||
return user;
|
||
}
|
||
|
||
/**
|
||
* Local recovery shared with regression tests; the CLI only accepts passwords on its TTY.
|
||
* @param {import('better-sqlite3').Database} database
|
||
* @param {string} username
|
||
* @param {string} password
|
||
*/
|
||
export async function recoverAdministrator(database, username, password) {
|
||
const user = administrator(database, username);
|
||
const passwordHash = await hashPassword(password);
|
||
const digest = (/** @type {string} */ value) =>
|
||
createHash('sha256').update(value).digest('hex');
|
||
database
|
||
.transaction(() => {
|
||
const changed = database
|
||
.prepare(
|
||
"UPDATE auth_users SET password_hash=?,must_change_password=0,updated_at=? WHERE id=? AND role='admin' AND disabled=0"
|
||
)
|
||
.run(passwordHash, Date.now(), user.id);
|
||
if (changed.changes !== 1)
|
||
throw new Error('Administrator changed during recovery. Try again.');
|
||
database.prepare('DELETE FROM auth_sessions WHERE user_id=?').run(user.id);
|
||
// Older databases may not have device credentials yet; recovery still works.
|
||
if (
|
||
database
|
||
.prepare(
|
||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='auth_login_devices'"
|
||
)
|
||
.get()
|
||
) {
|
||
database.prepare('DELETE FROM auth_login_devices WHERE user_id=?').run(user.id);
|
||
}
|
||
database
|
||
.prepare('DELETE FROM auth_rate_limits WHERE key GLOB ? OR key IN (?,?)')
|
||
.run(
|
||
`login:account:${digest(user.username.toLowerCase())}:ip:*`,
|
||
digest(`login:account:${user.username.toLowerCase()}`),
|
||
digest(`password:account:${user.id.toLowerCase()}`)
|
||
);
|
||
database
|
||
.prepare(
|
||
'INSERT INTO auth_audit(actor_id,action,target_id,details,created_at) VALUES(NULL,?,?,?,?)'
|
||
)
|
||
.run('password.recovered', user.id, '{"source":"local-cli"}', Date.now());
|
||
})
|
||
.immediate();
|
||
}
|
||
|
||
async function main() {
|
||
const username = process.argv[2]?.toLowerCase();
|
||
if (process.argv.length !== 3 || !username || !/^[a-z0-9][a-z0-9_.-]{2,63}$/.test(username))
|
||
throw new Error('Usage: node scripts/reset-admin.mjs <administrator-username>');
|
||
if (!process.stdin.isTTY)
|
||
throw new Error(
|
||
'Run this command in an interactive terminal. Passwords are never accepted as command arguments.'
|
||
);
|
||
const database = new Database(process.env.DB_PATH || './data/cherry.db', {
|
||
fileMustExist: true
|
||
});
|
||
database.pragma('busy_timeout=5000');
|
||
try {
|
||
administrator(database, username);
|
||
const password = await secret('New administrator password (8–128 characters): ');
|
||
const confirmation = await secret('Repeat password: ');
|
||
if (password !== confirmation) throw new Error('Passwords did not match.');
|
||
await recoverAdministrator(database, username, password);
|
||
console.log(
|
||
'Administrator password reset. Sessions and trusted login devices revoked; account rate limits cleared.'
|
||
);
|
||
} finally {
|
||
database.close();
|
||
}
|
||
}
|
||
|
||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||
try {
|
||
await main();
|
||
} catch (cause) {
|
||
console.error(cause instanceof Error ? cause.message : 'Administrator recovery failed.');
|
||
process.exitCode = 1;
|
||
}
|
||
}
|