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
+57 -1
View File
@@ -555,7 +555,7 @@ CREATE TABLE IF NOT EXISTS equipment_types (id TEXT PRIMARY KEY, name TEXT NOT N
CREATE TABLE IF NOT EXISTS stat_definitions (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, value_kind TEXT NOT NULL, min_value REAL, max_value REAL, unit TEXT, active INTEGER NOT NULL DEFAULT 1);
CREATE TABLE IF NOT EXISTS stat_equipment_types (stat_id TEXT NOT NULL REFERENCES stat_definitions(id), equipment_type_id TEXT NOT NULL REFERENCES equipment_types(id), PRIMARY KEY(stat_id, equipment_type_id));
CREATE TABLE IF NOT EXISTS audit_log (id TEXT PRIMARY KEY, actor_id TEXT NOT NULL REFERENCES users(id), entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, action TEXT NOT NULL, before_json TEXT, after_json TEXT, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL, aal INTEGER NOT NULL DEFAULT 1 CHECK(aal IN (1,2)), aal_verified_at TEXT NOT NULL, aal2_verified_at TEXT);
CREATE TABLE IF NOT EXISTS roles (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT);
CREATE TABLE IF NOT EXISTS deleted_roles (role_id TEXT PRIMARY KEY, deleted_at TEXT NOT NULL, deleted_by TEXT NOT NULL REFERENCES users(id));
CREATE TABLE IF NOT EXISTS permissions (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT);
@@ -591,6 +591,8 @@ CREATE TABLE IF NOT EXISTS login_events (
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,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_emails (
@@ -643,6 +645,60 @@ CREATE TABLE IF NOT EXISTS deleted_accounts (
reason TEXT
);
CREATE INDEX IF NOT EXISTS login_events_user_created_idx ON login_events(user_id, created_at DESC);
CREATE TABLE IF NOT EXISTS mfa_totp_factors (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
state TEXT NOT NULL CHECK(state IN ('pending','active','revoked')),
encrypted_secret TEXT NOT NULL,
encryption_key_id TEXT NOT NULL,
issuer TEXT NOT NULL DEFAULT 'SHD.build',
account_label TEXT NOT NULL,
algorithm TEXT NOT NULL DEFAULT 'SHA1' CHECK(algorithm='SHA1'),
digits INTEGER NOT NULL DEFAULT 6 CHECK(digits=6),
period_seconds INTEGER NOT NULL DEFAULT 30 CHECK(period_seconds=30),
last_counter INTEGER,
failed_attempts INTEGER NOT NULL DEFAULT 0 CHECK(failed_attempts BETWEEN 0 AND 5),
locked_until TEXT,
expires_at TEXT,
created_at TEXT NOT NULL,
activated_at TEXT,
revoked_at TEXT,
CHECK((state='pending' AND expires_at IS NOT NULL AND activated_at IS NULL AND revoked_at IS NULL)
OR (state='active' AND activated_at IS NOT NULL AND expires_at IS NULL AND revoked_at IS NULL)
OR (state='revoked' AND revoked_at IS NOT NULL))
);
CREATE UNIQUE INDEX IF NOT EXISTS mfa_totp_factors_one_active_idx
ON mfa_totp_factors(user_id) WHERE state='active';
CREATE UNIQUE INDEX IF NOT EXISTS mfa_totp_factors_one_pending_idx
ON mfa_totp_factors(user_id) WHERE state='pending';
CREATE INDEX IF NOT EXISTS mfa_totp_factors_expiry_idx
ON mfa_totp_factors(state,expires_at) WHERE state='pending';
CREATE TABLE IF NOT EXISTS mfa_recovery_codes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
consumed_at TEXT,
revoked_at TEXT,
UNIQUE(user_id,code_hash)
);
CREATE INDEX IF NOT EXISTS mfa_recovery_codes_available_idx
ON mfa_recovery_codes(user_id,consumed_at,revoked_at);
CREATE TABLE IF NOT EXISTS authentication_transactions (
id TEXT PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT CHECK(provider IS NULL OR provider IN ('password','discord','magic_link','passkey')),
kind TEXT NOT NULL CHECK(kind IN ('login','step_up')),
return_path TEXT NOT NULL CHECK(length(return_path) BETWEEN 1 AND 2048 AND substr(return_path,1,1)='/'),
required_aal INTEGER NOT NULL DEFAULT 2 CHECK(required_aal=2),
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK(attempt_count BETWEEN 0 AND 5),
expires_at TEXT NOT NULL,
consumed_at TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS authentication_transactions_expiry_idx
ON authentication_transactions(expires_at);
INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-data-editor','Data Editor','Access to ISAC game-data management');
INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-user-operator','User Operator','View and moderate SHD.build users');
INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-access-manager','Access Manager','Manage user roles and direct permissions');