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>
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:
- 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.
- 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.
- 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).
- 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 (JWThttpOnlycookie, 1d) and mobile (15m access JWT + 30d opaque refresh token).requireAuthaccepts either viatoken.extractToken(). - TOTP is opt-in per user (
users.totp_secret/totp_enabled), demanded on every login. Web uses a staged 5-minstage:'totp'challenge; mobile uses a single-request401 { totpRequired }. No recovery codes exist today. - Device tracking exists only on mobile (
mobile_refresh_tokensrows withdevice_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 +revokeAllForUserfor 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 atoken_hash UNIQUEindex, 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 andbcrypt.compareeach, 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:
- shows the existing trusted devices,
- requires revoking ≥1 before continuing,
- completes via
POST /auth/me/trusted-devices(trust current device), and - offers Cancel, which returns without creating any trust entry.
6. API additions
Auth (login paths)
POST /auth/login— after password verify, if a valid unrevokedrg_trustcookie matches a livetrusted_devicesrow for this user → skip TOTP, issue the session, logauth.login.trusted_device, stamplast_used_at. Otherwise unchanged ({ totpRequired, challenge }).POST /auth/login/totp— gains optionaltrustDevice+deviceName, and accepts a recovery code as an alternative to the TOTP code (single-use). On success withtrustDevice, mint the opaque trust token, set therg_trustcookie, insert the row (subject to the cap →409signal).POST /auth/mobile/login— gainstrustDevice/recoveryCode; returns atrustTokenthe 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/generate— password 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).
7. Cookie / refresh / JWT interaction
- New
rg_trustcookie:httpOnly,sameSite=Lax,secureper-request (reusecookieSecure),path=/,maxAge30d, 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 adminadmin.trusted_device.revoke/.revoke_all,admin.user.totp.reset— each with actor, target user, and device id indetail.
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
- Schema + models —
trusted_devices(sha256, cap-checked) +recovery_codes(bcrypt);.db.js/.model.jspairs mirroringmobileSessions. - Session service — trust-token mint/sha256/verify + recovery-code
generate/bcrypt-verify/consume helpers (pure, DB-free); shared
assertUnderTrustCap(). - Web login — trust-cookie skip in
/auth/login;trustDevice/ recovery handling + cap409in/auth/login/totp; set/clearrg_trust. - Mobile login —
trustDevice/trustToken/recoveryCode, same cap. - Self-service + admin backend —
/auth/me/trusted-devices*+ recovery-code endpoints;/admin/users/:id/trusted-devices*+ MFA reset (all admin-gated). - Invalidation wiring — password change/reset & TOTP-disable revoke trust + delete recovery codes.
- Web client UI — "Trust this device" checkbox; cap-reached TOTP-styled modal (revoke-to-continue / cancel); user Trusted Devices + Recovery Codes screens.
- Admin front-end UI — admin Trusted Devices management & revocation screens (per-user list, revoke one / revoke all, MFA reset), wired to step 5.
- Android — record the app-side trust/recovery flow in
docs/android/PLAN.md; app implementation sequenced after the backend lands. - OpenAPI + docs —
#swagger.*on every new/modified route + regenerateserver/swagger/swagger-output.json; updateBACKEND_DESIGN.md§3/§4/§6. - Automated tests — trusted-device login skip (valid / missing / expired /
revoked), token mint+hash, recovery-code single-use consume + wrong-code
backoff, cap
409behavior, 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.