feat: add TOTP multifactor authentication
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { connect, type Database } from '@tursodatabase/database';
|
||||
|
||||
const run = promisify(execFile);
|
||||
let directory: string;
|
||||
let database: Database;
|
||||
let databasePath: string;
|
||||
|
||||
async function invoke() {
|
||||
try {
|
||||
const result = await run(
|
||||
process.execPath,
|
||||
['scripts/admin-bootstrap.mjs', 'admin@example.com'],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
TDR_DATABASE_PATH: databasePath,
|
||||
NODE_ENV: 'test',
|
||||
},
|
||||
},
|
||||
);
|
||||
return { status: 0, stdout: result.stdout, stderr: result.stderr };
|
||||
} catch (error) {
|
||||
const failure = error as {
|
||||
code?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
};
|
||||
return {
|
||||
status: Number(failure.code ?? 1),
|
||||
stdout: failure.stdout ?? '',
|
||||
stderr: failure.stderr ?? '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('administrator bootstrap preconditions', () => {
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'shd-admin-bootstrap-'));
|
||||
databasePath = join(directory, 'database.db');
|
||||
database = await connect(databasePath);
|
||||
await database.exec(`
|
||||
CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT, role TEXT, created_at TEXT);
|
||||
CREATE TABLE user_emails (user_id TEXT, email TEXT, verified_at TEXT);
|
||||
CREATE TABLE user_status (user_id TEXT PRIMARY KEY, blocked_at TEXT, banned_until TEXT);
|
||||
CREATE TABLE deleted_accounts (user_id TEXT PRIMARY KEY);
|
||||
CREATE TABLE roles (id TEXT PRIMARY KEY, name TEXT);
|
||||
CREATE TABLE permissions (id TEXT PRIMARY KEY, name TEXT);
|
||||
CREATE TABLE role_permissions (role_id TEXT, permission_id TEXT);
|
||||
CREATE TABLE user_roles (user_id TEXT, role_id TEXT, PRIMARY KEY(user_id,role_id));
|
||||
CREATE TABLE mfa_totp_factors (id TEXT PRIMARY KEY, user_id TEXT, state TEXT);
|
||||
CREATE TABLE mfa_recovery_codes (id TEXT PRIMARY KEY, user_id TEXT, consumed_at TEXT, revoked_at TEXT);
|
||||
CREATE TABLE sessions (id TEXT PRIMARY KEY, user_id TEXT);
|
||||
CREATE TABLE login_events (id TEXT PRIMARY KEY, session_id TEXT, session_ended_at TEXT, session_end_reason TEXT);
|
||||
CREATE TABLE audit_log (id TEXT PRIMARY KEY, actor_id TEXT, entity_type TEXT, entity_id TEXT, action TEXT, before_json TEXT, after_json TEXT, created_at TEXT);
|
||||
INSERT INTO users VALUES ('user-1','agent','user','2026-08-07T00:00:00.000Z');
|
||||
INSERT INTO user_emails VALUES ('user-1','admin@example.com','2026-08-07T00:00:00.000Z');
|
||||
INSERT INTO roles VALUES ('role-admin','Admin');
|
||||
INSERT INTO permissions VALUES ('permission-system-admin','system.admin');
|
||||
INSERT INTO role_permissions VALUES ('role-admin','permission-system-admin');
|
||||
`);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await database.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('rejects accounts without both active TOTP and unused recovery code', async () => {
|
||||
await database.close();
|
||||
const result = await invoke();
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain('active TOTP factor');
|
||||
database = await connect(databasePath);
|
||||
const user = await (await database.prepare('SELECT role FROM users')).get();
|
||||
expect(user.role).toBe('user');
|
||||
});
|
||||
|
||||
it('grants only after MFA readiness and records sanitized bootstrap evidence', async () => {
|
||||
await database.exec(`
|
||||
INSERT INTO mfa_totp_factors VALUES ('factor-1','user-1','active');
|
||||
INSERT INTO mfa_recovery_codes VALUES ('code-1','user-1',NULL,NULL);
|
||||
INSERT INTO sessions VALUES ('session-1','user-1');
|
||||
`);
|
||||
await database.close();
|
||||
const result = await invoke();
|
||||
expect(result.status).toBe(0);
|
||||
database = await connect(databasePath);
|
||||
const user = await (await database.prepare('SELECT role FROM users')).get();
|
||||
const role = await (
|
||||
await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')
|
||||
).get('user-1');
|
||||
const sessions = await (
|
||||
await database.prepare('SELECT COUNT(*) AS count FROM sessions')
|
||||
).get();
|
||||
const audit = await (
|
||||
await database.prepare(
|
||||
'SELECT entity_type,action,after_json FROM audit_log',
|
||||
)
|
||||
).get();
|
||||
expect(user.role).toBe('user');
|
||||
expect(role.role_id).toBe('role-admin');
|
||||
expect(Number(sessions.count)).toBe(0);
|
||||
expect(audit).toMatchObject({
|
||||
entity_type: 'role_management',
|
||||
action: 'administrator.bootstrapped',
|
||||
});
|
||||
expect(String(audit.after_json)).not.toContain('factor-1');
|
||||
expect(String(audit.after_json)).not.toContain('code-1');
|
||||
});
|
||||
|
||||
it('is idempotent for an already canonical administrator', async () => {
|
||||
await database.exec(`
|
||||
INSERT INTO mfa_totp_factors VALUES ('factor-1','user-1','active');
|
||||
INSERT INTO mfa_recovery_codes VALUES ('code-1','user-1',NULL,NULL);
|
||||
`);
|
||||
await database.close();
|
||||
expect((await invoke()).status).toBe(0);
|
||||
database = await connect(databasePath);
|
||||
const firstAuditCount = await (
|
||||
await database.prepare(
|
||||
"SELECT COUNT(*) AS count FROM audit_log WHERE action='administrator.bootstrapped'",
|
||||
)
|
||||
).get();
|
||||
await database.close();
|
||||
expect((await invoke()).status).toBe(0);
|
||||
database = await connect(databasePath);
|
||||
const secondAuditCount = await (
|
||||
await database.prepare(
|
||||
"SELECT COUNT(*) AS count FROM audit_log WHERE action='administrator.bootstrapped'",
|
||||
)
|
||||
).get();
|
||||
expect(Number(firstAuditCount.count)).toBe(1);
|
||||
expect(Number(secondAuditCount.count)).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps only the local development bootstrap command', async () => {
|
||||
const packageJson = JSON.parse(await readFile('package.json', 'utf8')) as {
|
||||
scripts: Record<string, string>;
|
||||
};
|
||||
expect(packageJson.scripts['admin:bootstrap']).toBeTruthy();
|
||||
expect(packageJson.scripts['admin:bootstrap:staging']).toBeUndefined();
|
||||
expect(packageJson.scripts['admin:bootstrap:production']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user