feat(auth): trusted devices, recovery codes, and admin MFA management
Add opt-in "Trust this device" so a browser/app skips the TOTP step (never the password) for 30 days, single-use bcrypt recovery codes as a 2FA-lockout fallback, and admin trusted-device/MFA-reset management — backend, web UI, OpenAPI spec, and tests. - Schema: trusted_devices (sha256 token hash, looked up by unique index) and recovery_codes (bcrypt, single-use). Both additive/idempotent. - Session service: trust-token mint/hash/resolve + cap helpers; new rg_trust httpOnly cookie (survives logout, revoked on untrust/password change/reset/ TOTP disable). JWTs stay stateless — trust is a server-side row, not a claim. - Web + mobile login accept a trusted-device token / recovery code; login/totp gains trustDevice + recoveryCode. Cap of 10/user with NO silent pruning — an over-cap trust returns 409/trustLimitReached and the client prompts to revoke. - Self-service /auth/me/trusted-devices* + recovery-codes*; admin /admin/users/:id/trusted-devices* + /mfa/reset. All actions audit-logged. - Client: "Trust this device" + recovery-code login options, one-time recovery code display, Trusted Devices + Recovery Codes account panels, a TOTP-styled revoke-to-continue cap modal, and admin per-user security controls. - OpenAPI regenerated; 33 new server tests (all suites green). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -250,6 +250,50 @@ CREATE TABLE IF NOT EXISTS revoked_sessions (
|
||||
INDEX idx_revoked_sessions_expires (expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Trusted devices for MFA (opt-in "Trust this device"). A trusted device lets a
|
||||
-- browser/app SKIP the TOTP step at login — never the password. Pattern-identical
|
||||
-- to mobile_refresh_tokens: the opaque trust token lives client-side (the rg_trust
|
||||
-- cookie on web, EncryptedSharedPreferences on mobile) and only its sha256 hash is
|
||||
-- stored here (token_hash UNIQUE, so the login path can look a device up in O(1)).
|
||||
-- sha256 (not bcrypt) because the token is a 256-bit random value looked up BY its
|
||||
-- hash — a per-row salt would break the index lookup. Trust is consulted only at
|
||||
-- the login/password step, never at token refresh, and is revoked on untrust /
|
||||
-- password change/reset / TOTP disable. Capped at 10 rows per user (enforced in
|
||||
-- application code — no silent pruning). See docs/website/TRUSTED_DEVICES_MFA.md.
|
||||
CREATE TABLE IF NOT EXISTS trusted_devices (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque trust token
|
||||
platform ENUM('web','mobile') NOT NULL DEFAULT 'web',
|
||||
device_name VARCHAR(100) NULL, -- friendly label for the Trusted Devices list
|
||||
device_hash VARCHAR(32) NULL, -- best-effort UA+IP (sessionMeta) — display only
|
||||
user_agent VARCHAR(255) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at DATETIME NULL, -- stamped when trust is honored at login
|
||||
expires_at DATETIME NOT NULL, -- created_at + 30d
|
||||
revoked_at DATETIME NULL,
|
||||
CONSTRAINT fk_td_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_td_user (user_id),
|
||||
INDEX idx_td_expires (expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Single-use recovery (backup) codes for MFA. Generated at TOTP enrollment (10 at a
|
||||
-- time, shown to the user ONCE) so a user who loses their authenticator can complete
|
||||
-- login without an admin reset. code_hash is a BCRYPT hash (not sha256): a recovery
|
||||
-- code is a human-typed, lower-entropy fallback credential — the closest analogue to
|
||||
-- a password — and there is no hash-lookup constraint (we fetch the user's <=10 rows
|
||||
-- and bcrypt.compare each, exactly like password verification). Cleared wholesale on
|
||||
-- TOTP disable / password change/reset. See docs/website/TRUSTED_DEVICES_MFA.md.
|
||||
CREATE TABLE IF NOT EXISTS recovery_codes (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
code_hash VARCHAR(72) NOT NULL, -- bcrypt hash of one recovery code
|
||||
used_at DATETIME NULL, -- single-use marker
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_rc_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_rc_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Discord bot control (Phase 1). Singleton row (id = 1) holding the bot's
|
||||
-- config — the token is encrypted at rest (bot_token_enc) the same way OAuth
|
||||
-- client secrets are, and is only ever decrypted server-side to push to the
|
||||
|
||||
Reference in New Issue
Block a user