176 lines
7.6 KiB
TypeScript
176 lines
7.6 KiB
TypeScript
import Database from 'better-sqlite3';
|
|
import { createHash } from 'node:crypto';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { recoverAdministrator } from '../../scripts/reset-admin.mjs';
|
|
import { verifyPassword } from '../../src/lib/server/auth/passwords.js';
|
|
import { migrateAuthSchema } from '../../src/lib/server/auth/schema.js';
|
|
|
|
vi.setConfig({ testTimeout: 20_000 });
|
|
let database: Database.Database;
|
|
const password = 'replacement administrator passphrase';
|
|
const digest = (value: string) => createHash('sha256').update(value).digest('hex');
|
|
const pairKey = (kind: string, account: string, ip: string) =>
|
|
`${kind}:account:${digest(account.toLowerCase())}:ip:${digest(ip)}`;
|
|
|
|
beforeEach(() => {
|
|
database = new Database(':memory:');
|
|
database.pragma('foreign_keys = ON');
|
|
migrateAuthSchema(database);
|
|
database.exec(`
|
|
INSERT INTO auth_users VALUES
|
|
('admin-id','Admin_One','Admin','original-hash','admin',0,1,10,20,NULL),
|
|
('other-id','Other_Admin','Other','other-hash','admin',0,0,10,20,NULL),
|
|
('viewer-id','Viewer','Viewer','viewer-hash','viewer',0,0,10,20,NULL),
|
|
('disabled-id','Disabled_Admin','Disabled','disabled-hash','admin',1,0,10,20,NULL);
|
|
INSERT INTO auth_sessions VALUES
|
|
('admin-session','admin-id',10,1000,100),
|
|
('other-session','other-id',10,1000,100);
|
|
`);
|
|
});
|
|
|
|
afterEach(() => database.close());
|
|
|
|
function rateKeys() {
|
|
return database.prepare('SELECT key FROM auth_rate_limits ORDER BY key').all();
|
|
}
|
|
|
|
function addRates(keys: string[]) {
|
|
const insert = database.prepare('INSERT INTO auth_rate_limits VALUES (?,100,30)');
|
|
for (const key of keys) insert.run(key);
|
|
}
|
|
|
|
function addDevices() {
|
|
database.exec(`
|
|
CREATE TABLE auth_login_devices (
|
|
token_hash TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE,
|
|
expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL
|
|
);
|
|
INSERT INTO auth_login_devices VALUES
|
|
('admin-device','admin-id',1000,10),
|
|
('other-device','other-id',1000,10);
|
|
`);
|
|
}
|
|
|
|
describe('local administrator recovery', () => {
|
|
it('resets only the target administrator and clears their sessions, devices and account limits', async () => {
|
|
addDevices();
|
|
const cleared = [
|
|
pairKey('login', 'admin_one', '192.0.2.1'),
|
|
pairKey('login', 'admin_one', '192.0.2.2'),
|
|
digest('login:account:admin_one'),
|
|
digest('password:account:admin-id')
|
|
];
|
|
const preserved = [
|
|
pairKey('login', 'other_admin', '192.0.2.1'),
|
|
pairKey('setup', 'admin_one', '192.0.2.1'),
|
|
digest('login:account:other_admin'),
|
|
digest('password:account:other-id'),
|
|
digest('login:ip:192.0.2.1'),
|
|
digest('password:ip:192.0.2.1'),
|
|
digest('setup:ip:192.0.2.1')
|
|
];
|
|
addRates([...cleared, ...preserved]);
|
|
const others = database.prepare("SELECT * FROM auth_users WHERE id!='admin-id'").all();
|
|
|
|
await recoverAdministrator(database, 'ADMIN_ONE', password);
|
|
|
|
const user = database
|
|
.prepare(
|
|
"SELECT password_hash,must_change_password,updated_at FROM auth_users WHERE id='admin-id'"
|
|
)
|
|
.get() as { password_hash: string; must_change_password: number; updated_at: number };
|
|
expect(await verifyPassword(password, user.password_hash)).toBe(true);
|
|
expect(user.must_change_password).toBe(0);
|
|
expect(user.updated_at).toBeGreaterThan(20);
|
|
expect(database.prepare("SELECT * FROM auth_users WHERE id!='admin-id'").all()).toEqual(
|
|
others
|
|
);
|
|
expect(database.prepare('SELECT token_hash,user_id FROM auth_sessions').all()).toEqual([
|
|
{ token_hash: 'other-session', user_id: 'other-id' }
|
|
]);
|
|
expect(database.prepare('SELECT token_hash,user_id FROM auth_login_devices').all()).toEqual(
|
|
[{ token_hash: 'other-device', user_id: 'other-id' }]
|
|
);
|
|
expect(rateKeys()).toEqual(preserved.sort().map((key) => ({ key })));
|
|
expect(
|
|
database.prepare('SELECT actor_id,action,target_id,details FROM auth_audit').all()
|
|
).toEqual([
|
|
{
|
|
actor_id: null,
|
|
action: 'password.recovered',
|
|
target_id: 'admin-id',
|
|
details: '{"source":"local-cli"}'
|
|
}
|
|
]);
|
|
});
|
|
|
|
it('supports older databases without trusted-device tables and clears their legacy account limits', async () => {
|
|
addRates([digest('login:account:admin_one'), digest('password:account:admin-id')]);
|
|
|
|
await recoverAdministrator(database, 'admin_one', password);
|
|
|
|
expect(rateKeys()).toEqual([]);
|
|
expect(
|
|
database.prepare("SELECT * FROM auth_sessions WHERE user_id='admin-id'").all()
|
|
).toEqual([]);
|
|
expect(
|
|
database.prepare("SELECT name FROM sqlite_master WHERE name='auth_login_devices'").get()
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it('rejects disabled, non-administrator and missing accounts without making changes', async () => {
|
|
const users = database.prepare('SELECT * FROM auth_users').all();
|
|
const sessions = database.prepare('SELECT * FROM auth_sessions').all();
|
|
for (const username of ['disabled_admin', 'viewer', 'missing']) {
|
|
await expect(recoverAdministrator(database, username, password)).rejects.toThrow(
|
|
'An enabled administrator with that username was not found.'
|
|
);
|
|
}
|
|
expect(database.prepare('SELECT * FROM auth_users').all()).toEqual(users);
|
|
expect(database.prepare('SELECT * FROM auth_sessions').all()).toEqual(sessions);
|
|
expect(database.prepare('SELECT * FROM auth_audit').all()).toEqual([]);
|
|
});
|
|
|
|
it('rolls back the password, session, device and rate changes when recovery cannot be audited', async () => {
|
|
addDevices();
|
|
addRates([pairKey('login', 'admin_one', '192.0.2.1')]);
|
|
database.exec(`
|
|
CREATE TRIGGER reject_recovery BEFORE INSERT ON auth_audit
|
|
BEGIN SELECT RAISE(ABORT, 'audit unavailable'); END;
|
|
`);
|
|
const tables = ['auth_users', 'auth_sessions', 'auth_login_devices', 'auth_rate_limits'];
|
|
const before = tables.map((table) => database.prepare(`SELECT * FROM ${table}`).all());
|
|
|
|
await expect(recoverAdministrator(database, 'admin_one', password)).rejects.toThrow(
|
|
'audit unavailable'
|
|
);
|
|
|
|
expect(tables.map((table) => database.prepare(`SELECT * FROM ${table}`).all())).toEqual(
|
|
before
|
|
);
|
|
expect(database.prepare('SELECT * FROM auth_audit').all()).toEqual([]);
|
|
});
|
|
|
|
it('keeps the command restricted to interactive password entry', () => {
|
|
const command = spawnSync(process.execPath, ['scripts/reset-admin.mjs', 'admin_one'], {
|
|
encoding: 'utf8',
|
|
input: password
|
|
});
|
|
expect(command.status).toBe(1);
|
|
expect(command.stderr).toContain('Run this command in an interactive terminal.');
|
|
expect(command.stdout).not.toContain(password);
|
|
const argument = spawnSync(
|
|
process.execPath,
|
|
['scripts/reset-admin.mjs', 'admin_one', password],
|
|
{
|
|
encoding: 'utf8'
|
|
}
|
|
);
|
|
expect(argument.status).toBe(1);
|
|
expect(argument.stderr).toContain('Usage: node scripts/reset-admin.mjs');
|
|
expect(argument.stderr).not.toContain(password);
|
|
});
|
|
});
|