feat: add TOTP multifactor authentication
This commit is contained in:
+152
-2
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user