This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user