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>
This commit is contained in:
@@ -543,6 +543,27 @@ Uses the existing **mobile bearer** surface, no backend changes:
|
|||||||
`401` from a bearer call, with a mutex so concurrent 401s trigger only one refresh.
|
`401` from a bearer call, with a mutex so concurrent 401s trigger only one refresh.
|
||||||
- `POST /auth/mobile/logout` `{ refreshToken?, all? }` (requires bearer) — revoke this session or all
|
- `POST /auth/mobile/logout` `{ refreshToken?, all? }` (requires bearer) — revoke this session or all
|
||||||
sessions. Called on user logout and on "sign out everywhere."
|
sessions. Called on user logout and on "sign out everywhere."
|
||||||
|
|
||||||
|
#### 4.1.1 Trusted devices & recovery codes — backend ready, app work sequenced after backend
|
||||||
|
The backend trusted-device + recovery-code feature (canonical ref: `../website/TRUSTED_DEVICES_MFA.md`)
|
||||||
|
is additive on the mobile surface; the app consumes it as follows (implemented **after** the backend
|
||||||
|
lands, alongside the account screens):
|
||||||
|
- **Login extras:** `POST /auth/mobile/login` accepts `recoveryCode` (a single-use alternative to
|
||||||
|
`code`), `trustDevice: true`, and an `X-Trust-Token` header. On the `401 { totpRequired }` screen,
|
||||||
|
offer "use a recovery code instead" and a **"Trust this device"** checkbox. When `trustDevice` is set
|
||||||
|
and accepted, the response carries `trustToken` → **store it in EncryptedSharedPreferences** (same
|
||||||
|
store as the bearer tokens, never plain prefs/logs) and send it as `X-Trust-Token` on future logins
|
||||||
|
to skip the TOTP prompt. A `{ trustLimitReached, devices }` response means show the device list and
|
||||||
|
prompt the user to revoke one (`DELETE /auth/me/trusted-devices/:id`) then retry.
|
||||||
|
- **Self-service (account screens):** `GET /auth/me/trusted-devices`, `DELETE …/:id`, `DELETE
|
||||||
|
…/trusted-devices` (untrust all); `POST /auth/me/trusted-devices` to trust the current device from an
|
||||||
|
authenticated session (returns `{ trustToken }` for native). Recovery codes: enabling TOTP returns
|
||||||
|
the one-time `recoveryCodes` (show once, offer copy/share); `GET …/recovery-codes/status` for the
|
||||||
|
remaining count; `POST …/recovery-codes/generate` (password step-up) to regenerate.
|
||||||
|
- **Invalidation:** on logout / dead-refresh sign-out / Settings→Server switch, **clear the stored
|
||||||
|
`trustToken`** along with the bearer tokens (a password change/reset or TOTP disable already revokes
|
||||||
|
it server-side).
|
||||||
|
|
||||||
### 4.2 Website-handled flows: registration, invite, forgot-password, SSO
|
### 4.2 Website-handled flows: registration, invite, forgot-password, SSO
|
||||||
These are **not** rebuilt in the app. The app links out to the website's own pages/API and the user
|
These are **not** rebuilt in the app. The app links out to the website's own pages/API and the user
|
||||||
completes them in a Custom Tab, then returns and signs in natively (§4.1):
|
completes them in a Custom Tab, then returns and signs in natively (§4.1):
|
||||||
|
|||||||
@@ -223,6 +223,28 @@ list/revoke surface: `device_name VARCHAR(100) NULL` (a friendly label) and `las
|
|||||||
NULL` (bumped on each refresh). Existing rows get them via the schema's ALTER section; the token model
|
NULL` (bumped on each refresh). Existing rows get them via the schema's ALTER section; the token model
|
||||||
is otherwise unchanged.
|
is otherwise unchanged.
|
||||||
|
|
||||||
|
### trusted_devices — MFA "Trust this device"
|
||||||
|
|
||||||
|
Lets a browser/app **skip the TOTP step** at login (never the password) for 30 days. Pattern-identical
|
||||||
|
to `mobile_refresh_tokens`: the opaque trust token lives client-side (the `rg_trust` httpOnly cookie on
|
||||||
|
web, `X-Trust-Token` / EncryptedSharedPreferences on native) and only its **sha256** hash is stored
|
||||||
|
(`token_hash CHAR(64) UNIQUE`) — sha256, not bcrypt, because a 256-bit random token is looked up **by
|
||||||
|
its hash** via the unique index (a per-row salt would break that). Columns mirror the mobile table
|
||||||
|
(`platform`, `device_name`, `device_hash`, `user_agent`, `created_at`, `last_used_at`, `expires_at`,
|
||||||
|
`revoked_at`). Capped at 10 rows/user **in application code — no silent pruning** (an over-cap trust is
|
||||||
|
refused so the client can prompt the user to revoke one first). Consulted only at the login/password
|
||||||
|
step, never at token refresh, and revoked wholesale on untrust / password change / password reset /
|
||||||
|
TOTP disable. See `docs/website/TRUSTED_DEVICES_MFA.md`.
|
||||||
|
|
||||||
|
### recovery_codes — single-use MFA backup codes
|
||||||
|
|
||||||
|
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 VARCHAR(72)` 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 (verification fetches the user's ≤10
|
||||||
|
unused rows and `bcrypt.compare`s each, like password verification). `used_at` is the single-use
|
||||||
|
marker. Cleared wholesale on TOTP disable / password change / password reset.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. API contract
|
## 4. API contract
|
||||||
@@ -233,8 +255,9 @@ accepts `Authorization: Bearer` for API testing).
|
|||||||
### /auth (auth.routes.js → auth.controller.js)
|
### /auth (auth.routes.js → auth.controller.js)
|
||||||
| Method | Path | Auth | Body | Purpose |
|
| Method | Path | Auth | Body | Purpose |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| POST | `/login` | — (rate-limited) | `{username,password}` | verify, set cookie, log `auth.login`, update `last_login_at` |
|
| POST | `/login` | — (rate-limited) | `{username,password}` | verify, set cookie, log `auth.login`, update `last_login_at`. If the account has TOTP **and this browser is a trusted device** (a valid `rg_trust` cookie bound to the user), the TOTP step is **skipped** and a session is issued directly (logs `auth.login.trusted_device`). Otherwise a 2FA account returns `{totpRequired, challenge}`. |
|
||||||
| POST | `/logout` | cookie | — | clear cookie |
|
| POST | `/login/totp` | — (rate-limited) | `{challenge, code? \| recoveryCode?, trustDevice?, deviceName?}` | complete 2FA with a TOTP **or** single-use recovery code. `trustDevice` sets the `rg_trust` cookie so future logins skip TOTP; at the device cap the session is still issued and the body carries `{trustLimitReached, devices}`. |
|
||||||
|
| POST | `/logout` | cookie | — | clear cookie (the `rg_trust` trust cookie deliberately **survives** logout) |
|
||||||
| GET | `/me` | cookie / bearer | — | current user (no hash) or 401 — client bootstraps auth state |
|
| GET | `/me` | cookie / bearer | — | current user (no hash) or 401 — client bootstraps auth state |
|
||||||
| POST | `/password/forgot` | — (rate-limited) | `{email}` | email a single-use, ~1h reset link to **every active account** on the address; **always** returns the same generic 200 (no account enumeration). Email is non-unique, so several accounts may each get a link naming their username. Logs `account.password.reset.request`. |
|
| POST | `/password/forgot` | — (rate-limited) | `{email}` | email a single-use, ~1h reset link to **every active account** on the address; **always** returns the same generic 200 (no account enumeration). Email is non-unique, so several accounts may each get a link naming their username. Logs `account.password.reset.request`. |
|
||||||
| GET | `/password/reset/:token` | — | — | validate a link → `{username}` for the form, else 404 (never distinguishes expired/used/never-existed) |
|
| GET | `/password/reset/:token` | — | — | validate a link → `{username}` for the form, else 404 (never distinguishes expired/used/never-existed) |
|
||||||
@@ -242,8 +265,13 @@ accepts `Authorization: Bearer` for API testing).
|
|||||||
| GET | `/me/account` | cookie / bearer | — | full self account (`id, username, role, email, status, totp_enabled, has_password`) |
|
| GET | `/me/account` | cookie / bearer | — | full self account (`id, username, role, email, status, totp_enabled, has_password`) |
|
||||||
| PATCH | `/me/account/username` | cookie / bearer (rate-limited) | `{username}` | change own username; re-issues the caller's session |
|
| PATCH | `/me/account/username` | cookie / bearer (rate-limited) | `{username}` | change own username; re-issues the caller's session |
|
||||||
| PATCH | `/me/account/password` | cookie / bearer (rate-limited) | `{newPassword, currentPassword?}` | change/set own password (current required unless the account has none); revokes other sessions, keeps the caller's |
|
| PATCH | `/me/account/password` | cookie / bearer (rate-limited) | `{newPassword, currentPassword?}` | change/set own password (current required unless the account has none); revokes other sessions, keeps the caller's |
|
||||||
| POST | `/me/account/totp/setup` · `…/enable` · `…/disable` | cookie / bearer | `{code}` on enable/disable | self 2FA enrollment (disable needs a valid current code, not a password) |
|
| POST | `/me/account/totp/setup` · `…/enable` · `…/disable` | cookie / bearer | `{code}` on enable/disable | self 2FA enrollment (disable needs a valid current code, not a password). **enable** returns the one-time `recoveryCodes`; **disable** clears the user's trusted devices + recovery codes |
|
||||||
| GET | `/me/account/identities` · DELETE `…/:provider` | cookie / bearer | — | list / unlink own SSO identities |
|
| GET | `/me/account/identities` · DELETE `…/:provider` | cookie / bearer | — | list / unlink own SSO identities |
|
||||||
|
| GET | `/me/trusted-devices` | cookie / bearer | — | list own active trusted devices (never tokens) |
|
||||||
|
| POST | `/me/trusted-devices` | cookie / bearer (rate-limited) | `{deviceName?}` | trust the current device; web gets an httpOnly `rg_trust` cookie, native gets `{trustToken}`. **409 `{error:'trusted_device_limit', devices}`** at the cap |
|
||||||
|
| DELETE | `/me/trusted-devices` · `…/:id` | cookie / bearer | — | untrust all / one (ownership-scoped) |
|
||||||
|
| GET | `/me/account/recovery-codes/status` | cookie / bearer | — | remaining unused code count (never the codes) |
|
||||||
|
| POST | `/me/account/recovery-codes/generate` | cookie / bearer (rate-limited, **password step-up**) | `{currentPassword?}` | regenerate the one-time recovery codes (returned once); refused when 2FA is off |
|
||||||
| POST | `/me/devices` | cookie / bearer | `{endpoint, transport?, platform?}` | register a push endpoint; **rejects a disallowed endpoint 400** (SSRF guard). Idempotent per (user, endpoint) |
|
| POST | `/me/devices` | cookie / bearer | `{endpoint, transport?, platform?}` | register a push endpoint; **rejects a disallowed endpoint 400** (SSRF guard). Idempotent per (user, endpoint) |
|
||||||
| GET | `/me/devices` · DELETE `…/:id` | cookie / bearer | — | list / unregister own push devices |
|
| GET | `/me/devices` · DELETE `…/:id` | cookie / bearer | — | list / unregister own push devices |
|
||||||
| GET | `/me/notifications/streams` | cookie / bearer | — | the subscribable catalog (`personal`/`requiresLinkedAccount` flags) |
|
| GET | `/me/notifications/streams` | cookie / bearer | — | the subscribable catalog (`personal`/`requiresLinkedAccount` flags) |
|
||||||
@@ -375,6 +403,9 @@ Public content GETs pass through the **siteMode** gate (§5).
|
|||||||
| GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}` |
|
| GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}` |
|
||||||
| GET | `/activity?limit=&offset=` | paginated activity log |
|
| GET | `/activity?limit=&offset=` | paginated activity log |
|
||||||
| GET | `/users` · POST `/users` · PUT `/users/:id` · DELETE `/users/:id` | user mgmt (can't delete self / last admin; password hashed on write) |
|
| GET | `/users` · POST `/users` · PUT `/users/:id` · DELETE `/users/:id` | user mgmt (can't delete self / last admin; password hashed on write) |
|
||||||
|
| GET | `/users/:id/trusted-devices` | list a user's active trusted devices (never tokens) |
|
||||||
|
| DELETE | `/users/:id/trusted-devices` · `…/:deviceId` | revoke all / one of a user's trusted devices (logs `admin.trusted_device.revoke[_all]`) |
|
||||||
|
| POST | `/users/:id/mfa/reset` | recover a locked-out user: disable TOTP + revoke all trusted devices + clear recovery codes (logs `admin.user.totp.reset`) |
|
||||||
|
|
||||||
Every admin write logs to `activity_log`.
|
Every admin write logs to `activity_log`.
|
||||||
|
|
||||||
@@ -405,7 +436,8 @@ who"; `activity_log` provides the history feed.
|
|||||||
## 6. Auth & security
|
## 6. Auth & security
|
||||||
|
|
||||||
- **JWT** signed with `JWT_SECRET`, `expiresIn=JWT_EXPIRES_IN` (default `1d`); payload `{id,username,role}`.
|
- **JWT** signed with `JWT_SECRET`, `expiresIn=JWT_EXPIRES_IN` (default `1d`); payload `{id,username,role}`.
|
||||||
- **Cookie**: `httpOnly`, `sameSite=Lax`, `path=/`, and **`secure` decided per-request** (`COOKIE_SECURE=auto` → `secure: req.secure`). This is the key to dual access: the cookie is `Secure` when reached through Pangolin (HTTPS, `X-Forwarded-Proto: https`) but **not** `Secure` when reached directly over the LAN IP on plain HTTP — so login works in both. `COOKIE_SECURE=true|false` can force it. Requires `trust proxy` (below). `localhost:5173` (Vite) and `localhost:3000` are same-site, so the cookie flows in dev too.
|
- **Cookie**: `httpOnly`, `sameSite=Lax`, `path=/`, and **`secure` decided per-request** (`COOKIE_SECURE=auto` → `secure: req.secure`).
|
||||||
|
- **Trusted-device MFA.** A second, separate httpOnly cookie (`rg_trust`, default 30d) — opaque, sha256-hashed server-side in `trusted_devices` — lets a browser/app **skip the TOTP step** (never the password) on future logins. It is a server-side, per-row-revocable record (never a JWT claim), so the stateless session JWT is unchanged and trust stays revocable. It only ever gates the **second factor**; it deliberately outlives logout, and is cleared on untrust / password change / password reset / TOTP disable. **Recovery codes** (bcrypt, single-use) are the 2FA-lockout fallback. All admin trusted-device/MFA actions and the self actions (`auth.login.trusted_device`, `account.trusted_device.*`, `account.recovery_code*`, `admin.trusted_device.*`, `admin.user.totp.reset`) are audit-logged. See `docs/website/TRUSTED_DEVICES_MFA.md`. This is the key to dual access: the cookie is `Secure` when reached through Pangolin (HTTPS, `X-Forwarded-Proto: https`) but **not** `Secure` when reached directly over the LAN IP on plain HTTP — so login works in both. `COOKIE_SECURE=true|false` can force it. Requires `trust proxy` (below). `localhost:5173` (Vite) and `localhost:3000` are same-site, so the cookie flows in dev too.
|
||||||
- **bcrypt** hashing (cost 10+); plaintext passwords never stored, logged, or returned.
|
- **bcrypt** hashing (cost 10+); plaintext passwords never stored, logged, or returned.
|
||||||
- **Rate limiting** (`express-rate-limit`) on `/auth/login` and `/public/contact`.
|
- **Rate limiting** (`express-rate-limit`) on `/auth/login` and `/public/contact`.
|
||||||
- **Validation** (`express-validator`) on all writes; centralized error handler.
|
- **Validation** (`express-validator`) on all writes; centralized error handler.
|
||||||
|
|||||||
210
website/TRUSTED_DEVICES_MFA.md
Normal file
210
website/TRUSTED_DEVICES_MFA.md
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
# 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/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_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 + models** — `trusted_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 login** — `trustDevice` / `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.
|
||||||
Reference in New Issue
Block a user