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:
@@ -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 user’s 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 user’s 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 & 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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user