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,6 +1,9 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import RecoveryCodesDisplay from '../../components/security/RecoveryCodesDisplay.jsx'
|
||||
import TrustedDevicesPanel from '../../components/security/TrustedDevicesPanel.jsx'
|
||||
import RecoveryCodesPanel from '../../components/security/RecoveryCodesPanel.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
@@ -116,6 +119,7 @@ function TwoFactor({ account, reload }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [newCodes, setNewCodes] = useState(null) // one-time recovery codes shown after enabling
|
||||
|
||||
async function begin() {
|
||||
setBusy(true); setMsg(''); setError('')
|
||||
@@ -131,8 +135,8 @@ function TwoFactor({ account, reload }) {
|
||||
async function confirm() {
|
||||
setBusy(true); setMsg(''); setError('')
|
||||
try {
|
||||
await api.player.totpEnable(code.trim())
|
||||
setSetup(null); setCode(''); setMsg('Two-factor is now enabled.')
|
||||
const res = await api.player.totpEnable(code.trim())
|
||||
setSetup(null); setCode(''); setNewCodes(res?.recoveryCodes || null); setMsg('Two-factor is now enabled.')
|
||||
await reload()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not enable two-factor.')
|
||||
@@ -204,6 +208,11 @@ function TwoFactor({ account, reload }) {
|
||||
</div>
|
||||
)}
|
||||
<Note msg={msg} error={error} />
|
||||
{newCodes && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<RecoveryCodesDisplay codes={newCodes} onDone={() => setNewCodes(null)} />
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -416,6 +425,12 @@ export default function PlayerAccount() {
|
||||
<ChangeUsername account={account} onChanged={onUsernameChanged} />
|
||||
<ChangePassword account={account} />
|
||||
<TwoFactor account={account} reload={load} />
|
||||
{account.totp_enabled && (
|
||||
<>
|
||||
<TrustedDevicesPanel />
|
||||
<RecoveryCodesPanel hasPassword={account.has_password !== false} />
|
||||
</>
|
||||
)}
|
||||
<LinkedAccounts />
|
||||
<ActiveDevices />
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user