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
+152 -2
View File
@@ -769,6 +769,138 @@ for (const statement of statements) {
await database.exec(statement);
}
// Authentication transactions created by older releases did not retain the
// primary provider. Keep those rows intact and leave their provider unknown;
// new login transactions write the validated provider at creation time.
const authenticationTransactionColumns = await (
await database.prepare("PRAGMA table_info('authentication_transactions')")
).all();
if (
authenticationTransactionColumns.length &&
!authenticationTransactionColumns.some((column) => column.name === 'provider')
) {
console.log('Adding primary providers to authentication transactions.');
await database.exec(
"ALTER TABLE authentication_transactions ADD COLUMN provider TEXT CHECK(provider IS NULL OR provider IN ('password','discord','magic_link','passkey'))",
);
}
// Move legacy administrator rows onto the canonical relationship before the
// legacy users.role projection is cleared. The operation is repeatable and
// keeps the existing account IDs and authored data unchanged.
const legacyAdministratorRows = await (
await database.prepare("SELECT id FROM users WHERE role='admin' ORDER BY id")
).all();
if (legacyAdministratorRows.length) {
const administratorRole = await (
await database.prepare("SELECT id FROM roles WHERE id='role-admin'")
).get();
if (!administratorRole)
throw new Error('Cannot migrate legacy administrators without role-admin.');
await database.exec('BEGIN IMMEDIATE');
try {
for (const user of legacyAdministratorRows) {
await (
await database.prepare(
"INSERT OR IGNORE INTO user_roles(user_id,role_id) VALUES(?,'role-admin')",
)
).run(String(user.id));
}
await database.exec("UPDATE users SET role='user' WHERE role='admin'");
const remainingLegacyAdministrators = await (
await database.prepare(
"SELECT COUNT(*) AS count FROM users WHERE role='admin'",
)
).get();
if (Number(remainingLegacyAdministrators?.count) !== 0)
throw new Error('Legacy administrator authority could not be cleared.');
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
}
// MFA rotations keep the current active factor while a replacement is pending.
// Older databases used one partial index for both states, which prevented that
// safe overlap. Drop it and install the two independent idempotent constraints.
const legacyTotpCurrentIndex = await (
await database.prepare(
"SELECT 1 FROM sqlite_master WHERE type='index' AND name='mfa_totp_factors_one_current_idx'",
)
).get();
if (legacyTotpCurrentIndex) {
await database.exec('DROP INDEX mfa_totp_factors_one_current_idx');
}
await database.exec(
"CREATE UNIQUE INDEX IF NOT EXISTS mfa_totp_factors_one_active_idx ON mfa_totp_factors(user_id) WHERE state='active'",
);
await database.exec(
"CREATE UNIQUE INDEX IF NOT EXISTS mfa_totp_factors_one_pending_idx ON mfa_totp_factors(user_id) WHERE state='pending'",
);
// Existing sessions were issued before authentication assurance levels were
// recorded. They cannot be upgraded safely, so invalidate only those legacy
// rows after adding the nullable migration columns. New sessions are written
// with a non-null AAL evidence timestamp by the auth session service.
const sessionTable = await (
await database.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='sessions'",
)
).get();
if (sessionTable) {
let sessionColumns = await (
await database.prepare("PRAGMA table_info('sessions')")
).all();
if (!sessionColumns.some((column) => column.name === 'aal')) {
await database.exec(
'ALTER TABLE sessions ADD COLUMN aal INTEGER NOT NULL DEFAULT 1 CHECK(aal IN (1,2))',
);
sessionColumns = [...sessionColumns, { name: 'aal' }];
}
if (!sessionColumns.some((column) => column.name === 'aal_verified_at')) {
await database.exec('ALTER TABLE sessions ADD COLUMN aal_verified_at TEXT');
sessionColumns = [...sessionColumns, { name: 'aal_verified_at' }];
}
if (!sessionColumns.some((column) => column.name === 'aal2_verified_at')) {
await database.exec(
'ALTER TABLE sessions ADD COLUMN aal2_verified_at TEXT',
);
}
const legacySessions = await (
await database.prepare(
'SELECT COUNT(*) AS count FROM sessions WHERE aal_verified_at IS NULL',
)
).get();
if (Number(legacySessions?.count) > 0) {
console.log(
`Invalidating ${Number(legacySessions.count)} legacy session(s) without AAL evidence.`,
);
await database.exec('DELETE FROM sessions WHERE aal_verified_at IS NULL');
}
}
const loginEventsTable = await (
await database.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='login_events'",
)
).get();
if (loginEventsTable) {
const loginEventColumns = await (
await database.prepare("PRAGMA table_info('login_events')")
).all();
if (!loginEventColumns.some((column) => column.name === 'aal')) {
await database.exec(
'ALTER TABLE login_events ADD COLUMN aal INTEGER CHECK(aal IS NULL OR aal IN (1,2))',
);
}
if (!loginEventColumns.some((column) => column.name === 'aal2_verified_at')) {
await database.exec(
'ALTER TABLE login_events ADD COLUMN aal2_verified_at TEXT',
);
}
}
await database.exec(`CREATE TRIGGER IF NOT EXISTS specialization_ability_skill_chip_links_validate_insert
BEFORE INSERT ON specialization_ability_skill_chip_links
BEGIN
@@ -2142,10 +2274,12 @@ if (
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
provider TEXT NOT NULL CHECK(provider IN ('password', 'discord', 'magic_link', 'passkey')),
succeeded INTEGER NOT NULL CHECK(succeeded IN (0, 1)),
aal INTEGER CHECK(aal IS NULL OR aal IN (1,2)),
aal2_verified_at TEXT,
created_at TEXT NOT NULL
)`);
await database.exec(`INSERT INTO login_events(id,user_id,provider,succeeded,created_at)
SELECT id,user_id,provider,succeeded,created_at FROM login_events_legacy`);
await database.exec(`INSERT INTO login_events(id,user_id,provider,succeeded,aal,aal2_verified_at,created_at)
SELECT id,user_id,provider,succeeded,NULL,NULL,created_at FROM login_events_legacy`);
await database.exec('DROP TABLE login_events_legacy');
await database.exec(
'CREATE INDEX IF NOT EXISTS login_events_user_created_idx ON login_events(user_id, created_at DESC)',
@@ -2170,6 +2304,8 @@ for (const [name, definition] of [
['session_ended_at', 'TEXT'],
['session_end_reason', 'TEXT'],
['sensitive_purged_at', 'TEXT'],
['aal', 'INTEGER CHECK(aal IS NULL OR aal IN (1,2))'],
['aal2_verified_at', 'TEXT'],
]) {
if (!loginEventColumns.some((column) => column.name === name)) {
await database.exec(
@@ -2178,6 +2314,20 @@ for (const [name, definition] of [
}
}
// Earlier releases logged the primary factor as a successful login even when
// the AAL2 challenge was still pending. Retain those events as failure
// telemetry, but prevent abandoned MFA challenges from remaining successful.
await (
await database.prepare(`
UPDATE login_events
SET succeeded=0,
failure_reason=COALESCE(failure_reason,'mfa_required'),
aal=COALESCE(aal,1)
WHERE succeeded=1
AND instr(',' || COALESCE(risk_flags,'') || ',', ',primary_authenticated_mfa_required,')>0
`)
).run();
const proposalColumns = await (
await database.prepare("PRAGMA table_info('data_change_proposals')")
).all();