feat: add TOTP multifactor authentication

This commit is contained in:
jb
2026-08-07 13:10:05 -04:00
parent 4777a3abd5
commit e67c3d4cdb
50 changed files with 6646 additions and 539 deletions
+151
View File
@@ -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();
});
});