feat: add TOTP multifactor authentication
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { connect, type Database } from '@tursodatabase/database';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
audit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/audit', () => ({
|
||||
recordIsacAudit: mocks.audit,
|
||||
}));
|
||||
|
||||
import { applyAuthorityChange } from './authority-change';
|
||||
import type { AuthUser } from './session';
|
||||
|
||||
const operator = {
|
||||
id: 'operator-1',
|
||||
aal: 2,
|
||||
authLevel: 2,
|
||||
aal2VerifiedAt: new Date().toISOString(),
|
||||
} as AuthUser;
|
||||
|
||||
async function createDatabase(): Promise<Database> {
|
||||
const database = await connect(':memory:');
|
||||
await database.exec(`
|
||||
CREATE TABLE users(id TEXT PRIMARY KEY, role TEXT NOT NULL);
|
||||
CREATE TABLE roles(id TEXT PRIMARY KEY);
|
||||
CREATE TABLE permissions(id TEXT PRIMARY KEY, name TEXT NOT NULL);
|
||||
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 user_permissions(user_id TEXT, permission_id 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 sessions(id TEXT PRIMARY KEY, user_id TEXT);
|
||||
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);
|
||||
INSERT INTO users VALUES('operator-1','user'),('target-1','user');
|
||||
INSERT INTO roles VALUES('role-admin'),('role-user');
|
||||
INSERT INTO permissions VALUES('permission-system-admin','system.admin'),('permission-users-view','users.view');
|
||||
INSERT INTO role_permissions VALUES('role-admin','permission-system-admin'),('role-user','permission-users-view');
|
||||
INSERT INTO user_roles VALUES('operator-1','role-admin');
|
||||
INSERT INTO mfa_totp_factors VALUES('operator-factor','operator-1','active');
|
||||
INSERT INTO sessions VALUES('target-session','target-1');
|
||||
`);
|
||||
return database;
|
||||
}
|
||||
|
||||
const access = (roleIds: string[], permissionIds: string[]) => ({
|
||||
roleIds,
|
||||
permissionIds,
|
||||
});
|
||||
|
||||
describe('shared authority changes', () => {
|
||||
let database: Database;
|
||||
|
||||
beforeEach(async () => {
|
||||
database = await createDatabase();
|
||||
mocks.audit.mockReset();
|
||||
mocks.audit.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(async () => database.close());
|
||||
|
||||
it('cannot revert an administrator grant by removing the final active authority', async () => {
|
||||
await expect(
|
||||
applyAuthorityChange({
|
||||
database,
|
||||
operator,
|
||||
targetId: 'operator-1',
|
||||
...access([], []),
|
||||
expectedCurrent: access(['role-admin'], []),
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 409 });
|
||||
|
||||
const role = await (
|
||||
await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')
|
||||
).get('operator-1');
|
||||
expect(role.role_id).toBe('role-admin');
|
||||
});
|
||||
|
||||
it('requires an MFA-ready target when reversion restores administrator authority', async () => {
|
||||
await expect(
|
||||
applyAuthorityChange({
|
||||
database,
|
||||
operator,
|
||||
targetId: 'target-1',
|
||||
...access(['role-admin'], []),
|
||||
expectedCurrent: access([], []),
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
|
||||
const role = await (
|
||||
await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')
|
||||
).get('target-1');
|
||||
expect(role).toBeUndefined();
|
||||
});
|
||||
|
||||
it('requires recent AAL2 authentication for administrator reversion', async () => {
|
||||
await database.exec(`
|
||||
INSERT INTO mfa_totp_factors VALUES('target-factor','target-1','active');
|
||||
INSERT INTO mfa_recovery_codes VALUES('target-code','target-1',NULL,NULL);
|
||||
`);
|
||||
|
||||
await expect(
|
||||
applyAuthorityChange({
|
||||
database,
|
||||
operator: {
|
||||
...operator,
|
||||
aal2VerifiedAt: new Date(Date.now() - 11 * 60_000).toISOString(),
|
||||
} as never,
|
||||
targetId: 'target-1',
|
||||
...access(['role-admin'], []),
|
||||
expectedCurrent: access([], []),
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('revokes sessions and appends reversion audit evidence in the transaction', async () => {
|
||||
await database.exec(`
|
||||
INSERT INTO mfa_totp_factors VALUES('target-factor','target-1','active');
|
||||
INSERT INTO mfa_recovery_codes VALUES('target-code','target-1',NULL,NULL);
|
||||
`);
|
||||
const result = await applyAuthorityChange({
|
||||
database,
|
||||
operator,
|
||||
targetId: 'target-1',
|
||||
...access(['role-admin'], []),
|
||||
expectedCurrent: access([], []),
|
||||
audit: {
|
||||
actorId: 'operator-1',
|
||||
category: 'audit_control',
|
||||
action: 'audit.reverted',
|
||||
entityId: 'event-1',
|
||||
before: access([], []),
|
||||
after: {
|
||||
originalEventId: 'event-1',
|
||||
restored: access(['role-admin'], []),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.after).toEqual(access(['role-admin'], []));
|
||||
expect(
|
||||
await (
|
||||
await database.prepare('SELECT COUNT(*) AS count FROM sessions')
|
||||
).get(),
|
||||
).toMatchObject({ count: 0 });
|
||||
expect(mocks.audit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'audit.reverted',
|
||||
entityId: 'event-1',
|
||||
database,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back authority and session changes when audit append fails', async () => {
|
||||
await database.exec(`
|
||||
INSERT INTO mfa_totp_factors VALUES('target-factor','target-1','active');
|
||||
INSERT INTO mfa_recovery_codes VALUES('target-code','target-1',NULL,NULL);
|
||||
`);
|
||||
mocks.audit.mockRejectedValueOnce(new Error('audit failed'));
|
||||
|
||||
await expect(
|
||||
applyAuthorityChange({
|
||||
database,
|
||||
operator,
|
||||
targetId: 'target-1',
|
||||
...access(['role-admin'], []),
|
||||
expectedCurrent: access([], []),
|
||||
}),
|
||||
).rejects.toThrow('audit failed');
|
||||
|
||||
const role = await (
|
||||
await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')
|
||||
).get('target-1');
|
||||
const sessions = await (
|
||||
await database.prepare('SELECT COUNT(*) AS count FROM sessions')
|
||||
).get();
|
||||
expect(role).toBeUndefined();
|
||||
expect(Number(sessions.count)).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user