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

@@ -1,4 +1,4 @@
import { useMemo } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
@@ -136,6 +136,114 @@ function Houses({ scope }) {
)
}
// Admin security controls for one user: their trusted devices (view + revoke) and
// an MFA reset for a locked-out user. Every action is audit-logged server-side.
function SecurityAdmin({ userId }) {
const [devices, setDevices] = useState(null)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const load = useCallback(async () => {
try {
setDevices(await api.admin.userTrustedDevices(userId))
} catch {
setError('Could not load trusted devices.')
}
}, [userId])
useEffect(() => {
load()
}, [load])
async function revoke(deviceId) {
setBusy(true); setMsg(''); setError('')
try {
await api.admin.revokeUserTrustedDevice(userId, deviceId)
await load()
} catch {
setError('Could not revoke that device.')
} finally {
setBusy(false)
}
}
async function revokeAll() {
if (!window.confirm('Revoke ALL of this users trusted devices?')) return
setBusy(true); setMsg(''); setError('')
try {
await api.admin.revokeAllUserTrustedDevices(userId)
setMsg('All trusted devices revoked.')
await load()
} catch {
setError('Could not revoke devices.')
} finally {
setBusy(false)
}
}
async function resetMfa() {
if (!window.confirm('Reset this users two-factor? This turns TOTP off, revokes their trusted devices, and clears their recovery codes so they can sign in with their password.')) return
setBusy(true); setMsg(''); setError('')
try {
await api.admin.resetUserMfa(userId)
setMsg('Two-factor has been reset for this user.')
await load()
} catch {
setError('Could not reset two-factor.')
} finally {
setBusy(false)
}
}
const fmt = (d) => {
const t = d ? new Date(d) : null
return t && !Number.isNaN(t.getTime()) ? t.toLocaleDateString() : '—'
}
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Security &amp; two-factor</SectionTitle>
{devices == null ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Loading</p>
) : devices.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No trusted devices.</p>
) : (
<ul style={{ listStyle: 'none', margin: '0 0 14px', padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{devices.map((d) => (
<li key={d.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>
{d.deviceName || (d.platform === 'mobile' ? 'Mobile app' : 'Browser')}
</div>
<div className="sans dim" style={{ fontSize: '0.76rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{d.userAgent || '—'} · last used {fmt(d.lastUsedAt)} · expires {fmt(d.expiresAt)}
</div>
</div>
<button onClick={() => revoke(d.id)} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
Revoke
</button>
</li>
))}
</ul>
)}
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
{devices && devices.length > 0 && (
<button onClick={revokeAll} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
Revoke all trusted devices
</button>
)}
<button onClick={resetMfa} disabled={busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>
Reset two-factor
</button>
</div>
{msg && <p className="sans" style={{ marginTop: 12, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>}
{error && <p className="sans" style={{ marginTop: 12, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
</section>
)
}
function ShardSections({ scope }) {
return (
<>
@@ -187,6 +295,7 @@ export default function UserDetail() {
</div>
</div>
<SecurityAdmin userId={id} />
<ShardSections scope={scope} />
</section>
)