feat(auth): trusted devices, recovery codes, and admin MFA management
All checks were successful
PR Checks / bot-install (pull_request) Successful in 19s
PR Checks / server-tests (pull_request) Successful in 42s
PR Checks / client-build (pull_request) Successful in 9m24s

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:
2026-07-21 23:38:48 -05:00
parent 8d5bdc0d6e
commit 60ebacff2c
38 changed files with 3542 additions and 90 deletions

View File

@@ -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