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

@@ -0,0 +1,62 @@
import { useState } from 'react'
// Renders a freshly generated batch of recovery codes ONCE, with copy + download.
// The backend never returns these again, so the copy stresses saving them now.
export default function RecoveryCodesDisplay({ codes, onDone }) {
const [copied, setCopied] = useState(false)
const text = (codes || []).join('\n')
async function copy() {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
/* clipboard blocked — the codes are visible to copy manually */
}
}
function download() {
const blob = new Blob([`${text}\n`], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'recovery-codes.txt'
a.click()
URL.revokeObjectURL(url)
}
return (
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 18, marginTop: 8 }}>
<p className="sans" style={{ margin: '0 0 12px', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Save these recovery codes somewhere safe. Each can be used <strong>once</strong> to sign in if you
lose your authenticator. <strong>They will not be shown again.</strong>
</p>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))',
gap: 8,
fontFamily: 'monospace',
fontSize: '0.95rem',
marginBottom: 14,
}}
>
{(codes || []).map((c) => (
<div key={c} style={{ padding: '8px 10px', border: '1px solid var(--line-soft)', borderRadius: 6, letterSpacing: '0.06em', textAlign: 'center', color: 'var(--head)' }}>
{c}
</div>
))}
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={copy} className="pill">{copied ? 'Copied!' : 'Copy'}</button>
<button onClick={download} className="pill">Download</button>
{onDone && (
<button onClick={onDone} className="btn btn-primary btn-sq" style={{ marginLeft: 'auto' }}>
Ive saved them
</button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,86 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../../api/client.js'
import RecoveryCodesDisplay from './RecoveryCodesDisplay.jsx'
// Self-service recovery (backup) codes. Shows how many remain and lets the user
// regenerate a fresh set (password step-up). Shown only when 2FA is enabled.
// `hasPassword` decides whether the current-password field is required — an
// SSO-only account with no password may regenerate while authenticated.
export default function RecoveryCodesPanel({ hasPassword = true }) {
const [remaining, setRemaining] = useState(null)
const [currentPassword, setCurrentPassword] = useState('')
const [codes, setCodes] = useState(null) // freshly generated batch, shown once
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const load = useCallback(async () => {
try {
const { remaining: n } = await api.recoveryCodesStatus()
setRemaining(n)
} catch {
/* non-fatal — the panel still offers regeneration */
}
}, [])
useEffect(() => {
load()
}, [load])
async function regenerate() {
setBusy(true)
setError('')
try {
const { recoveryCodes } = await api.generateRecoveryCodes(hasPassword ? currentPassword : undefined)
setCodes(recoveryCodes)
setCurrentPassword('')
await load()
} catch (err) {
setError(err.message || 'Could not generate recovery codes.')
} finally {
setBusy(false)
}
}
return (
<div style={{ marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }}>
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
Recovery codes
</h2>
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
Single-use codes that let you sign in if you lose your authenticator. Regenerating replaces any
codes you still have.
</p>
{remaining != null && !codes && (
<p className="sans" style={{ color: remaining > 0 ? '#7fd0a4' : '#e0b352', fontSize: '0.86rem' }}>
{remaining > 0 ? `${remaining} unused code${remaining === 1 ? '' : 's'} remaining.` : 'No unused recovery codes left — regenerate a set.'}
</p>
)}
{codes ? (
<RecoveryCodesDisplay codes={codes} onDone={() => setCodes(null)} />
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 10 }}>
{hasPassword && (
<label style={{ display: 'block', maxWidth: 260 }}>
<span className="field-label">Current password</span>
<input
type="password"
autoComplete="current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="input"
/>
</label>
)}
<div>
<button onClick={regenerate} disabled={busy || (hasPassword && !currentPassword)} className="btn btn-sq">
{busy ? 'Generating…' : 'Generate new codes'}
</button>
</div>
</div>
)}
{error && <p className="sans" style={{ marginTop: 14, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
</div>
)
}

View File

@@ -0,0 +1,124 @@
import { useState } from 'react'
import { api } from '../../api/client.js'
// Shown when a user tries to trust a device but is already at the trusted-device
// cap. Styled like the TOTP entry flow (centered card on a dim overlay). The user
// MUST revoke at least one existing device before they can continue — there is no
// silent pruning — or they can cancel and leave the device untrusted.
//
// Props:
// devices — the existing trusted devices (from the 409 / trustLimitReached payload)
// onTrusted — called after the current device is successfully trusted (post-revoke)
// onCancel — called when the user backs out without trusting this device
export default function TrustLimitModal({ devices: initialDevices, onTrusted, onCancel }) {
const [devices, setDevices] = useState(initialDevices || [])
const [revokedAny, setRevokedAny] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
async function revoke(id) {
setBusy(true)
setError('')
try {
await api.revokeTrustedDevice(id)
setDevices((list) => list.filter((d) => d.id !== id))
setRevokedAny(true)
} catch {
setError('Could not revoke that device. Please try again.')
} finally {
setBusy(false)
}
}
async function trustNow() {
setBusy(true)
setError('')
try {
await api.trustThisDevice()
onTrusted?.()
} catch (err) {
// Still at the cap somehow (a race) — surface it and let them revoke more.
if (err.status === 409 && err.body?.devices) {
setDevices(err.body.devices)
setError('Still at the limit — revoke another device.')
} else {
setError('Could not trust this device. Please try again.')
}
} finally {
setBusy(false)
}
}
return (
<div style={overlay} role="dialog" aria-modal="true" aria-label="Trusted-device limit reached">
<div style={card}>
<h2 className="display" style={{ margin: '0 0 8px', fontSize: '1.15rem', color: 'var(--head)' }}>
Trusted-device limit reached
</h2>
<p className="sans" style={{ margin: '0 0 16px', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
You can trust up to {Math.max(devices.length, 1)} devices. Revoke one below to make room, then
continue or cancel to leave this device untrusted.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16, maxHeight: 240, overflowY: 'auto' }}>
{devices.map((d) => (
<div key={d.id} style={row}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>
{d.deviceName || d.platform || 'Device'}
</div>
<div className="sans dim" style={{ fontSize: '0.74rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{d.userAgent || '—'}
</div>
</div>
<button onClick={() => revoke(d.id)} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
Revoke
</button>
</div>
))}
{devices.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.84rem', margin: 0 }}>All devices revoked. You can trust this one now.</p>
)}
</div>
{error && <p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.84rem' }}>{error}</p>}
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<button onClick={trustNow} disabled={busy || !revokedAny} className="btn btn-primary btn-sq">
{busy ? 'Working…' : 'Trust this device'}
</button>
<button onClick={onCancel} disabled={busy} className="pill">
Cancel
</button>
</div>
</div>
</div>
)
}
const overlay = {
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
zIndex: 1000,
}
const card = {
width: '100%',
maxWidth: 460,
background: 'var(--panel, #1a1a1f)',
border: '1px solid var(--line)',
borderRadius: 12,
padding: 24,
}
const row = {
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '10px 14px',
border: '1px solid var(--line)',
borderRadius: 8,
}

View File

@@ -0,0 +1,137 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../../api/client.js'
import TrustLimitModal from './TrustLimitModal.jsx'
// Self-service list of the devices allowed to skip the TOTP step at login (MFA
// "Trust this device"). Uses the role-agnostic /auth/me/trusted-devices surface, so
// the same panel serves players and staff. Shown only when 2FA is enabled — trust
// is meaningless without a second factor to skip.
function fmtDate(s) {
if (!s) return '—'
const d = new Date(s)
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
export default function TrustedDevicesPanel() {
const [devices, setDevices] = useState(null)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [capModal, setCapModal] = useState(null) // { devices } when the cap is hit
const load = useCallback(async () => {
try {
setDevices(await api.myTrustedDevices())
} catch {
setError('Could not load your trusted devices.')
}
}, [])
useEffect(() => {
load()
}, [load])
async function trustThis() {
setBusy(true)
setMsg('')
setError('')
try {
await api.trustThisDevice()
setMsg('This device is now trusted.')
await load()
} catch (err) {
if (err.status === 409 && err.body?.error === 'trusted_device_limit') {
setCapModal({ devices: err.body.devices || [] })
} else {
setError('Could not trust this device.')
}
} finally {
setBusy(false)
}
}
async function revoke(id) {
setBusy(true)
setMsg('')
setError('')
try {
await api.revokeTrustedDevice(id)
await load()
} catch {
setError('Could not revoke that device.')
} finally {
setBusy(false)
}
}
async function revokeAll() {
if (!window.confirm('Untrust every device? Each will require the full two-factor step at the next login.')) return
setBusy(true)
setMsg('')
setError('')
try {
await api.revokeAllTrustedDevices()
setMsg('All devices untrusted.')
await load()
} catch {
setError('Could not untrust devices.')
} finally {
setBusy(false)
}
}
if (!devices) return null
return (
<div style={{ marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }}>
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
Trusted devices
</h2>
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
Devices youve trusted skip the authenticator step at login (your password is still required).
Revoke any you dont recognize.
</p>
{devices.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
{devices.map((d) => (
<div 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 {fmtDate(d.lastUsedAt)} · expires {fmtDate(d.expiresAt)}
</div>
</div>
<button onClick={() => revoke(d.id)} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
Revoke
</button>
</div>
))}
</div>
) : (
<p className="sans dim" style={{ fontSize: '0.86rem', margin: '14px 0' }}>No trusted devices yet.</p>
)}
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={trustThis} disabled={busy} className="btn btn-sq">Trust this device</button>
{devices.length > 0 && (
<button onClick={revokeAll} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
Untrust all
</button>
)}
</div>
{msg && <p className="sans" style={{ marginTop: 14, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>}
{error && <p className="sans" style={{ marginTop: 14, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
{capModal && (
<TrustLimitModal
devices={capModal.devices}
onTrusted={() => { setCapModal(null); setMsg('This device is now trusted.'); load() }}
onCancel={() => setCapModal(null)}
/>
)}
</div>
)
}