Files
docs/website/TRUSTED_DEVICES_MFA.md
wtclaude 6e7da3acbe docs: trusted devices & MFA improvements (design + API/security/schema)
Add TRUSTED_DEVICES_MFA.md (the approved design/implementation plan) and fold
the feature into BACKEND_DESIGN §3 (trusted_devices + recovery_codes schema),
§4 (login/totp trust+recovery, /auth/me/trusted-devices*, recovery-codes*,
admin trusted-device + /mfa/reset routes), and §6 (trusted-device security
model + audit actions). Note the app-side trust/recovery flow in android PLAN §4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 23:39:05 -05:00

11 KiB

Trusted Devices & MFA Improvements — Design & Implementation Plan

Reference plan for the trusted-device + MFA hardening work. Approved 2026-07-21. This document is the contract the implementation builds against; keep it in sync with BACKEND_DESIGN.md (§3 schema, §4 API, §6 security) as code lands.

1. Goal & scope

Reduce 2FA friction without weakening the second-factor boundary, and close the 2FA-lockout gap. Four deliverables:

  1. Trusted devices — an opt-in "Trust this device" that lets a browser or the Android app skip the TOTP step (never the password) on future logins for a fixed window.
  2. Recovery / backup codes — single-use codes generated at 2FA enrollment so a user who loses their authenticator can self-recover instead of needing an admin reset.
  3. Admin-managed revocation — staff can view and revoke a user's trusted devices and reset their MFA, with full audit logging (backend endpoints and admin front-end screens).
  4. Step-up (password) for sensitive operations — reusing the existing currentPassword-verification pattern; disabling TOTP keeps its stronger current-TOTP-code requirement.

Touches website/ (server + client), docs/, and android-app/ (plan only in this pass). No link/ or servuo-plugins/ change — no wire-protocol impact.

Approved decisions

Decision Value
Trust duration 30 days (matches mobile refresh-token lifetime)
Roles eligible All roles (no staff carve-out)
Opt-in model Explicit "Trust this device" checkbox, default off
Recovery codes 10 codes, shown once, single-use
Trusted-device cap 10 per user, no silent pruning (see §5)
Trust-token hashing sha256
Recovery-code hashing bcrypt (cost 10)

2. Current state (starting point)

  • One session service (server/src/auth/session.service.js) backs web (JWT httpOnly cookie, 1d) and mobile (15m access JWT + 30d opaque refresh token). requireAuth accepts either via token.extractToken().
  • TOTP is opt-in per user (users.totp_secret / totp_enabled), demanded on every login. Web uses a staged 5-min stage:'totp' challenge; mobile uses a single-request 401 { totpRequired }. No recovery codes exist today.
  • Device tracking exists only on mobile (mobile_refresh_tokens rows with device_name / device_hash / user_agent / last_used_at). Web JWTs are stateless with no per-session row.
  • Revocation is mature: revoked_sessions (jti denylist) + tokens_valid_after (per-user cutoff) for web; per-token rows + revokeAllForUser for mobile.
  • No trusted-device or step-up concept exists anywhere.

3. Hashing rationale

The repo already splits hashing by secret entropy, and this plan follows it:

  • sha256 — every high-entropy machine-generated opaque token (mobile_refresh_tokens, mobile_auth_codes, user_invites, password_resets, SSO PKCE). Trusted-device tokens use sha256: they are 256-bit random values (nothing to brute-force) looked up by a token_hash UNIQUE index, which requires a deterministic hash — bcrypt's per-row salt would break the lookup and truncates input at 72 bytes.
  • bcrypt (bcryptjs, cost 10) — the repo uses it only for passwords, the one human-chosen low-entropy secret. Recovery codes use bcrypt: they are a human-typed, lower-entropy fallback credential that grants a login (the closest analogue to a password), and there is no hash-lookup constraint — we fetch the identified user's ≤10 code rows and bcrypt.compare each, exactly like password verification.

4. Database (additive, idempotent — matches schema.sql style)

trusted_devices

Pattern-identical to mobile_refresh_tokens; stores only the token hash.

Column Type Notes
id INT PK AUTO_INCREMENT
user_id INT NOT NULL FK → users, ON DELETE CASCADE
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
device_hash VARCHAR(32) NULL best-effort UA+IP, 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

Indices: idx_td_user (user_id), idx_td_expires (expires_at).

recovery_codes

Column Type Notes
id INT PK AUTO_INCREMENT
user_id INT NOT NULL FK → users, ON DELETE CASCADE
code_hash VARCHAR(72) NOT NULL bcrypt hash of one code
used_at DATETIME NULL single-use marker
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP

Index: idx_rc_user (user_id).

No new users column: password change/reset and TOTP-disable bulk-revoke trusted_devices rows and delete recovery_codes (consistent with revokeAllForUser), so no "trust epoch" column is needed.

5. Trusted-device cap — no silent pruning

Cap = 10. A shared assertUnderTrustCap(userId) guards both entry points (the login/TOTP trust path and the authenticated "trust this device" path). On the 11th attempt the backend refuses to create the row and returns 409 { error: 'trusted_device_limit', devices: [...] }. Login itself still succeeds — only the trust marker is withheld. The web client then renders a modal in the same visual pattern as the TOTP entry flow that:

  1. shows the existing trusted devices,
  2. requires revoking ≥1 before continuing,
  3. completes via POST /auth/me/trusted-devices (trust current device), and
  4. offers Cancel, which returns without creating any trust entry.

6. API additions

Auth (login paths)

  • POST /auth/login — after password verify, if a valid unrevoked rg_trust cookie matches a live trusted_devices row for this user → skip TOTP, issue the session, log auth.login.trusted_device, stamp last_used_at. Otherwise unchanged ({ totpRequired, challenge }).
  • POST /auth/login/totp — gains optional trustDevice + deviceName, and accepts a recovery code as an alternative to the TOTP code (single-use). On success with trustDevice, mint the opaque trust token, set the rg_trust cookie, insert the row (subject to the cap → 409 signal).
  • POST /auth/mobile/login — gains trustDevice / recoveryCode; returns a trustToken the app stores in EncryptedSharedPreferences and replays on a later login to skip TOTP. Same cap behavior.

Self-service (/auth/me/*, requireAuth, any role)

  • GET /auth/me/trusted-devices — list active trusted devices (never tokens).
  • POST /auth/me/trusted-devices — trust the current browser/device (cap-checked).
  • DELETE /auth/me/trusted-devices/:id — revoke one (ownership-scoped).
  • DELETE /auth/me/trusted-devices — revoke all ("untrust everywhere").
  • POST /auth/me/account/recovery-codes/generatepassword step-up required; returns the codes once.
  • GET /auth/me/account/recovery-codes/status — remaining count only.

Admin (requireRole('admin'))

  • GET /admin/users/:id/trusted-devices — list a user's trusted devices.
  • DELETE /admin/users/:id/trusted-devices/:deviceId — revoke one.
  • DELETE /admin/users/:id/trusted-devices — revoke all.
  • MFA reset control (revoke trust + disable TOTP + clear recovery codes).
  • New rg_trust cookie: httpOnly, sameSite=Lax, secure per-request (reuse cookieSecure), path=/, maxAge 30d, opaque 256-bit base64url, sha256-hashed server-side. Separate from the session cookie and deliberately survives logout (so the next login skips 2FA); only untrust / password-change / TOTP-disable revoke it.
  • JWTs stay stateless and unchanged — trust is a server-side cookie+row, never a JWT claim, so it remains revocable.
  • Refresh flow untouched — trust is consulted only at the login/password step, never at token refresh; the two stores stay independent.

8. Security & invalidation

  • Trust only ever gates the second factor; password is always required.
  • Recovery-code entry reuses the login brute-force stack (backoff + bot scoring + rate limits); recovery codes are single-use.
  • Password change/reset and TOTP-disable clear trust and recovery codes.
  • Audit logging via existing activity.log / activity_log: auth.login.trusted_device, account.trusted_device.add / .revoke / .revoke_all, account.recovery_codes.generate, account.recovery_code.consume, and admin admin.trusted_device.revoke / .revoke_all, admin.user.totp.reset — each with actor, target user, and device id in detail.

9. Backwards compatibility

Fully additive. With no rg_trust cookie the behavior is exactly today's (TOTP every login). Recovery codes exist only for users who generate them. No existing session or login flow changes shape. New tables via CREATE TABLE IF NOT EXISTS and columns via ALTER TABLE … ADD COLUMN IF NOT EXISTS.

10. Implementation roadmap

  1. Schema + modelstrusted_devices (sha256, cap-checked) + recovery_codes (bcrypt); .db.js / .model.js pairs mirroring mobileSessions.
  2. Session service — trust-token mint/sha256/verify + recovery-code generate/bcrypt-verify/consume helpers (pure, DB-free); shared assertUnderTrustCap().
  3. Web login — trust-cookie skip in /auth/login; trustDevice / recovery handling + cap 409 in /auth/login/totp; set/clear rg_trust.
  4. Mobile logintrustDevice / trustToken / recoveryCode, same cap.
  5. Self-service + admin backend/auth/me/trusted-devices* + recovery-code endpoints; /admin/users/:id/trusted-devices* + MFA reset (all admin-gated).
  6. Invalidation wiring — password change/reset & TOTP-disable revoke trust + delete recovery codes.
  7. Web client UI — "Trust this device" checkbox; cap-reached TOTP-styled modal (revoke-to-continue / cancel); user Trusted Devices + Recovery Codes screens.
  8. Admin front-end UI — admin Trusted Devices management & revocation screens (per-user list, revoke one / revoke all, MFA reset), wired to step 5.
  9. Android — record the app-side trust/recovery flow in docs/android/PLAN.md; app implementation sequenced after the backend lands.
  10. OpenAPI + docs#swagger.* on every new/modified route + regenerate server/swagger/swagger-output.json; update BACKEND_DESIGN.md §3/§4/§6.
  11. Automated tests — trusted-device login skip (valid / missing / expired / revoked), token mint+hash, recovery-code single-use consume + wrong-code backoff, cap 409 behavior, revocation (self + admin), invalidation on password-change / TOTP-disable, and permission checks (admin routes reject non-admins; self routes ownership-scoped); web client pure-logic tests; Android JVM DTO/repository tests.