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:
137
client/src/components/security/TrustedDevicesPanel.jsx
Normal file
137
client/src/components/security/TrustedDevicesPanel.jsx
Normal 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 you’ve trusted skip the authenticator step at login (your password is still required).
|
||||
Revoke any you don’t 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user