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:
@@ -56,8 +56,12 @@ export const api = {
|
||||
getInvite: (token) => req(`/auth/invite/${encodeURIComponent(token)}`),
|
||||
acceptInvite: (token, username, password, extra = {}) =>
|
||||
req(`/auth/invite/${encodeURIComponent(token)}/accept`, { method: 'POST', body: { username, password, ...extra } }),
|
||||
loginTotp: (challenge, code) =>
|
||||
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
|
||||
// Second factor for web login. `extra` carries the optional recoveryCode (an
|
||||
// alternative to code) and the trustDevice/deviceName opt-in. On success the
|
||||
// response may include { trustLimitReached, devices } when trust was requested
|
||||
// but the device cap is reached.
|
||||
loginTotp: (challenge, code, extra = {}) =>
|
||||
req('/auth/login/totp', { method: 'POST', body: { challenge, code, ...extra } }),
|
||||
// Self-service password reset (public, token-gated). forgot always resolves the
|
||||
// same way whether or not the email exists (no enumeration); getPasswordReset
|
||||
// validates a link (200 → { username }, 404 → invalid/expired); resetPassword
|
||||
@@ -76,6 +80,20 @@ export const api = {
|
||||
// List the active ones and revoke a single device by its session id.
|
||||
mySessions: () => req('/auth/me/sessions'),
|
||||
revokeMySession: (id) => req(`/auth/me/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
// Trusted devices (MFA "Trust this device"), role-agnostic under /auth/me. These
|
||||
// are the browsers/apps allowed to skip the TOTP step at login (distinct from
|
||||
// mySessions, which are live mobile login sessions).
|
||||
myTrustedDevices: () => req('/auth/me/trusted-devices'),
|
||||
trustThisDevice: (deviceName) =>
|
||||
req('/auth/me/trusted-devices', { method: 'POST', body: { deviceName } }),
|
||||
revokeTrustedDevice: (id) =>
|
||||
req(`/auth/me/trusted-devices/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
revokeAllTrustedDevices: () => req('/auth/me/trusted-devices', { method: 'DELETE' }),
|
||||
// Recovery (backup) codes. status → remaining count; generate → a fresh set,
|
||||
// returned ONCE (password step-up for accounts that have a password).
|
||||
recoveryCodesStatus: () => req('/auth/me/account/recovery-codes/status'),
|
||||
generateRecoveryCodes: (currentPassword) =>
|
||||
req('/auth/me/account/recovery-codes/generate', { method: 'POST', body: { currentPassword } }),
|
||||
|
||||
// ----- public -----
|
||||
publicSettings: () => req('/public/settings'),
|
||||
@@ -199,6 +217,13 @@ export const api = {
|
||||
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
|
||||
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
|
||||
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
|
||||
// A user's trusted devices + MFA reset (admin only).
|
||||
userTrustedDevices: (id) => req(`/admin/users/${id}/trusted-devices`),
|
||||
revokeUserTrustedDevice: (id, deviceId) =>
|
||||
req(`/admin/users/${id}/trusted-devices/${deviceId}`, { method: 'DELETE' }),
|
||||
revokeAllUserTrustedDevices: (id) =>
|
||||
req(`/admin/users/${id}/trusted-devices`, { method: 'DELETE' }),
|
||||
resetUserMfa: (id) => req(`/admin/users/${id}/mfa/reset`, { method: 'POST' }),
|
||||
// Email invites.
|
||||
listInvites: () => req('/admin/invites'),
|
||||
createInvite: (email, role, sendEmail = true) =>
|
||||
|
||||
Reference in New Issue
Block a user