diff --git a/client/src/api/client.js b/client/src/api/client.js
index b88a920..2942301 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -56,8 +56,12 @@ export const api = {
getInvite: (token) => req(`/auth/invite/${encodeURIComponent(token)}`),
acceptInvite: (token, username, password, extra = {}) =>
req(`/auth/invite/${encodeURIComponent(token)}/accept`, { method: 'POST', body: { username, password, ...extra } }),
- loginTotp: (challenge, code) =>
- req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
+ // Second factor for web login. `extra` carries the optional recoveryCode (an
+ // alternative to code) and the trustDevice/deviceName opt-in. On success the
+ // response may include { trustLimitReached, devices } when trust was requested
+ // but the device cap is reached.
+ loginTotp: (challenge, code, extra = {}) =>
+ req('/auth/login/totp', { method: 'POST', body: { challenge, code, ...extra } }),
// Self-service password reset (public, token-gated). forgot always resolves the
// same way whether or not the email exists (no enumeration); getPasswordReset
// validates a link (200 → { username }, 404 → invalid/expired); resetPassword
@@ -76,6 +80,20 @@ export const api = {
// List the active ones and revoke a single device by its session id.
mySessions: () => req('/auth/me/sessions'),
revokeMySession: (id) => req(`/auth/me/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }),
+ // Trusted devices (MFA "Trust this device"), role-agnostic under /auth/me. These
+ // are the browsers/apps allowed to skip the TOTP step at login (distinct from
+ // mySessions, which are live mobile login sessions).
+ myTrustedDevices: () => req('/auth/me/trusted-devices'),
+ trustThisDevice: (deviceName) =>
+ req('/auth/me/trusted-devices', { method: 'POST', body: { deviceName } }),
+ revokeTrustedDevice: (id) =>
+ req(`/auth/me/trusted-devices/${encodeURIComponent(id)}`, { method: 'DELETE' }),
+ revokeAllTrustedDevices: () => req('/auth/me/trusted-devices', { method: 'DELETE' }),
+ // Recovery (backup) codes. status → remaining count; generate → a fresh set,
+ // returned ONCE (password step-up for accounts that have a password).
+ recoveryCodesStatus: () => req('/auth/me/account/recovery-codes/status'),
+ generateRecoveryCodes: (currentPassword) =>
+ req('/auth/me/account/recovery-codes/generate', { method: 'POST', body: { currentPassword } }),
// ----- public -----
publicSettings: () => req('/public/settings'),
@@ -199,6 +217,13 @@ export const api = {
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
+ // A user's trusted devices + MFA reset (admin only).
+ userTrustedDevices: (id) => req(`/admin/users/${id}/trusted-devices`),
+ revokeUserTrustedDevice: (id, deviceId) =>
+ req(`/admin/users/${id}/trusted-devices/${deviceId}`, { method: 'DELETE' }),
+ revokeAllUserTrustedDevices: (id) =>
+ req(`/admin/users/${id}/trusted-devices`, { method: 'DELETE' }),
+ resetUserMfa: (id) => req(`/admin/users/${id}/mfa/reset`, { method: 'POST' }),
// Email invites.
listInvites: () => req('/admin/invites'),
createInvite: (email, role, sendEmail = true) =>
diff --git a/client/src/components/security/RecoveryCodesDisplay.jsx b/client/src/components/security/RecoveryCodesDisplay.jsx
new file mode 100644
index 0000000..45f0f26
--- /dev/null
+++ b/client/src/components/security/RecoveryCodesDisplay.jsx
@@ -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 (
+
+
+ Save these recovery codes somewhere safe. Each can be used once to sign in if you
+ lose your authenticator. They will not be shown again.
+
+
+ {(codes || []).map((c) => (
+
+ {c}
+
+ ))}
+
+
+ {copied ? 'Copied!' : 'Copy'}
+ Download
+ {onDone && (
+
+ I’ve saved them
+
+ )}
+
+
+ )
+}
diff --git a/client/src/components/security/RecoveryCodesPanel.jsx b/client/src/components/security/RecoveryCodesPanel.jsx
new file mode 100644
index 0000000..6a1cb89
--- /dev/null
+++ b/client/src/components/security/RecoveryCodesPanel.jsx
@@ -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 (
+
+
+ Recovery codes
+
+
+ Single-use codes that let you sign in if you lose your authenticator. Regenerating replaces any
+ codes you still have.
+
+
+ {remaining != null && !codes && (
+
0 ? '#7fd0a4' : '#e0b352', fontSize: '0.86rem' }}>
+ {remaining > 0 ? `${remaining} unused code${remaining === 1 ? '' : 's'} remaining.` : 'No unused recovery codes left — regenerate a set.'}
+
+ )}
+
+ {codes ? (
+
setCodes(null)} />
+ ) : (
+
+ {hasPassword && (
+
+ Current password
+ setCurrentPassword(e.target.value)}
+ className="input"
+ />
+
+ )}
+
+
+ {busy ? 'Generating…' : 'Generate new codes'}
+
+
+
+ )}
+
+ {error && {error}
}
+
+ )
+}
diff --git a/client/src/components/security/TrustLimitModal.jsx b/client/src/components/security/TrustLimitModal.jsx
new file mode 100644
index 0000000..7f64f42
--- /dev/null
+++ b/client/src/components/security/TrustLimitModal.jsx
@@ -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 (
+
+
+
+ Trusted-device limit reached
+
+
+ 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.
+
+
+
+ {devices.map((d) => (
+
+
+
+ {d.deviceName || d.platform || 'Device'}
+
+
+ {d.userAgent || '—'}
+
+
+
revoke(d.id)} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
+ Revoke
+
+
+ ))}
+ {devices.length === 0 && (
+
All devices revoked. You can trust this one now.
+ )}
+
+
+ {error &&
{error}
}
+
+
+
+ {busy ? 'Working…' : 'Trust this device'}
+
+
+ Cancel
+
+
+
+
+ )
+}
+
+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,
+}
diff --git a/client/src/components/security/TrustedDevicesPanel.jsx b/client/src/components/security/TrustedDevicesPanel.jsx
new file mode 100644
index 0000000..bfd0df0
--- /dev/null
+++ b/client/src/components/security/TrustedDevicesPanel.jsx
@@ -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 (
+
+
+ Trusted devices
+
+
+ Devices you’ve trusted skip the authenticator step at login (your password is still required).
+ Revoke any you don’t recognize.
+
+
+ {devices.length > 0 ? (
+
+ {devices.map((d) => (
+
+
+
+ {d.deviceName || (d.platform === 'mobile' ? 'Mobile app' : 'Browser')}
+
+
+ {d.userAgent || '—'} · last used {fmtDate(d.lastUsedAt)} · expires {fmtDate(d.expiresAt)}
+
+
+
revoke(d.id)} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
+ Revoke
+
+
+ ))}
+
+ ) : (
+
No trusted devices yet.
+ )}
+
+
+ Trust this device
+ {devices.length > 0 && (
+
+ Untrust all
+
+ )}
+
+
+ {msg &&
{msg}
}
+ {error &&
{error}
}
+
+ {capModal && (
+
{ setCapModal(null); setMsg('This device is now trusted.'); load() }}
+ onCancel={() => setCapModal(null)}
+ />
+ )}
+
+ )
+}
diff --git a/client/src/contexts/AuthContext.jsx b/client/src/contexts/AuthContext.jsx
index 665fc13..c92f164 100644
--- a/client/src/contexts/AuthContext.jsx
+++ b/client/src/contexts/AuthContext.jsx
@@ -38,11 +38,14 @@ export function AuthProvider({ children }) {
return data
}, [])
- // Step 2 for TOTP users: exchange the challenge + code for a real session.
- const loginTotp = useCallback(async (challenge, code) => {
- const data = await api.loginTotp(challenge, code)
+ // Step 2 for TOTP users: exchange the challenge + a second factor (TOTP code or a
+ // recovery code) for a real session. `extra` carries recoveryCode + the
+ // trustDevice/deviceName opt-in. Returns the full payload ({ user,
+ // trustLimitReached?, devices? }) so the caller can handle the device-cap prompt.
+ const loginTotp = useCallback(async (challenge, code, extra) => {
+ const data = await api.loginTotp(challenge, code, extra)
setUser(data.user)
- return data.user
+ return data
}, [])
// Step 2 for SSO logins whose account has 2FA on. The pending challenge lives in
diff --git a/client/src/routes/admin/AdminLogin.jsx b/client/src/routes/admin/AdminLogin.jsx
index 5cf470c..bea6c35 100644
--- a/client/src/routes/admin/AdminLogin.jsx
+++ b/client/src/routes/admin/AdminLogin.jsx
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Link, useNavigate, useLocation } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx'
import ProviderIcon from '../../components/ProviderIcon.jsx'
+import TrustLimitModal from '../../components/security/TrustLimitModal.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
import { api } from '../../api/client.js'
@@ -52,6 +53,9 @@ export default function AdminLogin() {
const [challenge, setChallenge] = useState('')
const [code, setCode] = useState('')
const [ssoTotp, setSsoTotp] = useState(false)
+ const [trustDevice, setTrustDevice] = useState(false)
+ const [useRecovery, setUseRecovery] = useState(false)
+ const [trustLimit, setTrustLimit] = useState(null) // { devices, dest } when the cap is hit
// SSO providers to offer (empty if none configured) + any error the callback
// bounced us back with (?sso_error=...).
@@ -122,16 +126,23 @@ export default function AdminLogin() {
const { returnTo } = await ssoLoginTotp(code)
navigate(returnTo || '/admin', { replace: true })
} else {
- const u = await loginTotp(challenge, code)
- navigate(destFor(u), { replace: true })
+ const entered = code.trim()
+ const data = await loginTotp(challenge, useRecovery ? '' : entered, {
+ recoveryCode: useRecovery ? entered : undefined,
+ trustDevice,
+ })
+ const to = destFor(data.user)
+ if (data.trustLimitReached) {
+ setTrustLimit({ devices: data.devices || [], dest: to })
+ setBusy(false)
+ return
+ }
+ navigate(to, { replace: true })
}
} catch (err) {
const expired = err.status === 401 && /expired/i.test(err.message)
- setError(
- expired
- ? 'Your verification session expired. Please sign in again.'
- : 'Invalid verification code.',
- )
+ const badRecovery = useRecovery ? 'That recovery code is not valid.' : 'Invalid verification code.'
+ setError(expired ? 'Your verification session expired. Please sign in again.' : badRecovery)
setBusy(false)
if (expired) {
setStage('creds')
@@ -223,22 +234,40 @@ export default function AdminLogin() {
>
) : (
-
- Authentication code
- setCode(e.target.value)}
- className="input"
- />
-
- Enter the code from your authenticator app.
-
-
+ <>
+
+ {useRecovery ? 'Recovery code' : 'Authentication code'}
+ setCode(e.target.value)}
+ className="input"
+ />
+
+ {useRecovery ? 'Enter one of your saved single-use recovery codes.' : 'Enter the code from your authenticator app.'}
+
+
+ {!ssoTotp && (
+
+ setTrustDevice(e.target.checked)} />
+ Trust this device for 30 days (skip the code next time)
+
+ )}
+ {!ssoTotp && (
+ { setUseRecovery((v) => !v); setCode('') }}
+ className="sans"
+ style={{ display: 'block', marginBottom: 22, background: 'none', border: 'none', padding: 0, color: 'var(--accent)', cursor: 'pointer', fontSize: '0.8rem' }}
+ >
+ {useRecovery ? 'Use an authenticator code instead' : 'Use a recovery code instead'}
+
+ )}
+ >
)}
{(error || (stage === 'creds' && ssoError)) && (
@@ -304,6 +333,14 @@ export default function AdminLogin() {
+
+ {trustLimit && (
+ navigate(trustLimit.dest, { replace: true })}
+ onCancel={() => navigate(trustLimit.dest, { replace: true })}
+ />
+ )}
)
}
diff --git a/client/src/routes/admin/views/AccountAdmin.jsx b/client/src/routes/admin/views/AccountAdmin.jsx
index 4d3b46b..acdd108 100644
--- a/client/src/routes/admin/views/AccountAdmin.jsx
+++ b/client/src/routes/admin/views/AccountAdmin.jsx
@@ -1,6 +1,9 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import ProviderIcon from '../../../components/ProviderIcon.jsx'
+import RecoveryCodesDisplay from '../../../components/security/RecoveryCodesDisplay.jsx'
+import TrustedDevicesPanel from '../../../components/security/TrustedDevicesPanel.jsx'
+import RecoveryCodesPanel from '../../../components/security/RecoveryCodesPanel.jsx'
import { api } from '../../../api/client.js'
// Link/unlink external SSO identities to this account. Linking redirects through
@@ -127,6 +130,7 @@ export default function AccountAdmin() {
const [code, setCode] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
+ const [newCodes, setNewCodes] = useState(null) // one-time recovery codes shown after enabling
async function load() {
try {
@@ -164,9 +168,10 @@ export default function AccountAdmin() {
setMsg('')
setError('')
try {
- await api.admin.totpEnable(code.trim())
+ const res = await api.admin.totpEnable(code.trim())
setSetup(null)
setCode('')
+ setNewCodes(res?.recoveryCodes || null)
setMsg('Two-factor authentication is now enabled.')
await load()
} catch (err) {
@@ -302,6 +307,21 @@ export default function AccountAdmin() {
{msg && {msg}
}
{error && {error}
}
+ {/* One-time recovery codes shown right after enabling 2FA. */}
+ {newCodes && (
+
+ setNewCodes(null)} />
+
+ )}
+
+ {/* Trusted devices + recovery-code management, only relevant with 2FA on. */}
+ {enabled && (
+ <>
+
+
+ >
+ )}
+
)
diff --git a/client/src/routes/admin/views/UserDetail.jsx b/client/src/routes/admin/views/UserDetail.jsx
index b4068e7..cd552cf 100644
--- a/client/src/routes/admin/views/UserDetail.jsx
+++ b/client/src/routes/admin/views/UserDetail.jsx
@@ -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 (
+
+ Security & two-factor
+ {devices == null ? (
+ Loading…
+ ) : devices.length === 0 ? (
+ No trusted devices.
+ ) : (
+
+ {devices.map((d) => (
+
+
+
+ {d.deviceName || (d.platform === 'mobile' ? 'Mobile app' : 'Browser')}
+
+
+ {d.userAgent || '—'} · last used {fmt(d.lastUsedAt)} · expires {fmt(d.expiresAt)}
+
+
+ revoke(d.id)} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
+ Revoke
+
+
+ ))}
+
+ )}
+
+
+ {devices && devices.length > 0 && (
+
+ Revoke all trusted devices
+
+ )}
+
+ Reset two-factor
+
+
+
+ {msg && {msg}
}
+ {error && {error}
}
+
+ )
+}
+
function ShardSections({ scope }) {
return (
<>
@@ -187,6 +295,7 @@ export default function UserDetail() {
+
)
diff --git a/client/src/routes/player/PlayerAccount.jsx b/client/src/routes/player/PlayerAccount.jsx
index a4b3799..a4c73db 100644
--- a/client/src/routes/player/PlayerAccount.jsx
+++ b/client/src/routes/player/PlayerAccount.jsx
@@ -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 }) {
)}
+ {newCodes && (
+
+ setNewCodes(null)} />
+
+ )}
)
}
@@ -416,6 +425,12 @@ export default function PlayerAccount() {
+ {account.totp_enabled && (
+ <>
+
+
+ >
+ )}
>
diff --git a/client/src/routes/player/PlayerLogin.jsx b/client/src/routes/player/PlayerLogin.jsx
index 84823ed..f12ad5b 100644
--- a/client/src/routes/player/PlayerLogin.jsx
+++ b/client/src/routes/player/PlayerLogin.jsx
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useLocation } from 'react-router-dom'
import ProviderIcon from '../../components/ProviderIcon.jsx'
+import TrustLimitModal from '../../components/security/TrustLimitModal.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { api } from '../../api/client.js'
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
@@ -34,6 +35,12 @@ export default function PlayerLogin() {
const [challenge, setChallenge] = useState('')
const [code, setCode] = useState('')
const [ssoTotp, setSsoTotp] = useState(false)
+ const [trustDevice, setTrustDevice] = useState(false)
+ const [useRecovery, setUseRecovery] = useState(false)
+ // When trust was requested at login but the device cap is reached: show the
+ // revoke-to-continue modal, then navigate on resolve. `pendingDest` holds where
+ // to go once the prompt is dealt with.
+ const [trustLimit, setTrustLimit] = useState(null) // { devices, dest }
const [providers, setProviders] = useState([])
const [canRegister, setCanRegister] = useState(false)
@@ -111,12 +118,25 @@ export default function PlayerLogin() {
}
navigate(returnTo || '/account', { replace: true })
} else {
- const u = await loginTotp(challenge, code)
- navigate(destFor(u), { replace: true })
+ const entered = code.trim()
+ const data = await loginTotp(challenge, useRecovery ? '' : entered, {
+ recoveryCode: useRecovery ? entered : undefined,
+ trustDevice,
+ })
+ const to = destFor(data.user)
+ // Trust was requested but the device cap is reached: the session is already
+ // issued, so prompt to revoke one before trusting, then navigate.
+ if (data.trustLimitReached) {
+ setTrustLimit({ devices: data.devices || [], dest: to })
+ setBusy(false)
+ return
+ }
+ navigate(to, { replace: true })
}
} catch (err) {
const expired = err.status === 401 && /expired/i.test(err.message)
- setError(expired ? 'Your verification session expired. Please sign in again.' : 'Invalid verification code.')
+ const badRecovery = useRecovery ? 'That recovery code is not valid.' : 'Invalid verification code.'
+ setError(expired ? 'Your verification session expired. Please sign in again.' : badRecovery)
setBusy(false)
if (expired) {
setStage('creds')
@@ -169,13 +189,42 @@ export default function PlayerLogin() {
>
) : (
-
- Authentication code
- setCode(e.target.value)} className="input" />
-
- Enter the code from your authenticator app.
-
-
+ <>
+
+ {useRecovery ? 'Recovery code' : 'Authentication code'}
+ setCode(e.target.value)}
+ className="input"
+ />
+
+ {useRecovery ? 'Enter one of your saved single-use recovery codes.' : 'Enter the code from your authenticator app.'}
+
+
+ {/* Trust-this-device only applies to real authenticator/recovery login,
+ not the SSO 2FA bounce (which has no trust cookie flow here). */}
+ {!ssoTotp && (
+
+ setTrustDevice(e.target.checked)} />
+ Trust this device for 30 days (skip the code next time)
+
+ )}
+ {!ssoTotp && (
+ { setUseRecovery((v) => !v); setCode('') }}
+ className="sans"
+ style={{ display: 'block', marginBottom: 22, background: 'none', border: 'none', padding: 0, color: 'var(--accent)', cursor: 'pointer', fontSize: '0.8rem' }}
+ >
+ {useRecovery ? 'Use an authenticator code instead' : 'Use a recovery code instead'}
+
+ )}
+ >
)}
{(error || (stage === 'creds' && ssoError)) && (
@@ -208,6 +257,14 @@ export default function PlayerLogin() {
)}
+
+ {trustLimit && (
+ navigate(trustLimit.dest, { replace: true })}
+ onCancel={() => navigate(trustLimit.dest, { replace: true })}
+ />
+ )}
)
}
diff --git a/server/.env.example b/server/.env.example
index cde5572..c6bdd04 100644
--- a/server/.env.example
+++ b/server/.env.example
@@ -27,6 +27,15 @@ JWT_EXPIRES_IN=1d
COOKIE_SECURE=auto
COOKIE_NAME=rg_token
+# Trusted-device MFA ("Trust this device"). The trust cookie's name, how long a
+# device stays trusted (skips the TOTP step, never the password), the per-user cap
+# (no silent pruning — an over-cap trust is refused), and how many single-use
+# recovery codes are generated at 2FA enrollment.
+TRUST_COOKIE_NAME=rg_trust
+TRUSTED_DEVICE_TTL_DAYS=30
+MAX_TRUSTED_DEVICES=10
+RECOVERY_CODE_COUNT=10
+
# Encryption key for secrets stored at rest (OAuth client secrets in auth_providers).
# Any string — hashed to a 256-bit AES-GCM key. REQUIRED in production; in dev an
# insecure key is derived from JWT_SECRET if unset (with a warning).
diff --git a/server/db/schema.sql b/server/db/schema.sql
index 07a5122..a86eff6 100644
--- a/server/db/schema.sql
+++ b/server/db/schema.sql
@@ -250,6 +250,50 @@ CREATE TABLE IF NOT EXISTS revoked_sessions (
INDEX idx_revoked_sessions_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+-- Trusted devices for MFA (opt-in "Trust this device"). A trusted device lets a
+-- browser/app SKIP the TOTP step at login — never the password. Pattern-identical
+-- to mobile_refresh_tokens: the opaque trust token lives client-side (the rg_trust
+-- cookie on web, EncryptedSharedPreferences on mobile) and only its sha256 hash is
+-- stored here (token_hash UNIQUE, so the login path can look a device up in O(1)).
+-- sha256 (not bcrypt) because the token is a 256-bit random value looked up BY its
+-- hash — a per-row salt would break the index lookup. Trust is consulted only at
+-- the login/password step, never at token refresh, and is revoked on untrust /
+-- password change/reset / TOTP disable. Capped at 10 rows per user (enforced in
+-- application code — no silent pruning). See docs/website/TRUSTED_DEVICES_MFA.md.
+CREATE TABLE IF NOT EXISTS trusted_devices (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ user_id INT NOT NULL,
+ token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque trust token
+ platform ENUM('web','mobile') NOT NULL DEFAULT 'web',
+ device_name VARCHAR(100) NULL, -- friendly label for the Trusted Devices list
+ device_hash VARCHAR(32) NULL, -- best-effort UA+IP (sessionMeta) — display only
+ user_agent VARCHAR(255) NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ last_used_at DATETIME NULL, -- stamped when trust is honored at login
+ expires_at DATETIME NOT NULL, -- created_at + 30d
+ revoked_at DATETIME NULL,
+ CONSTRAINT fk_td_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ INDEX idx_td_user (user_id),
+ INDEX idx_td_expires (expires_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- Single-use recovery (backup) codes for MFA. Generated at TOTP enrollment (10 at a
+-- time, shown to the user ONCE) so a user who loses their authenticator can complete
+-- login without an admin reset. code_hash is a BCRYPT hash (not sha256): a recovery
+-- code is a human-typed, lower-entropy fallback credential — the closest analogue to
+-- a password — and there is no hash-lookup constraint (we fetch the user's <=10 rows
+-- and bcrypt.compare each, exactly like password verification). Cleared wholesale on
+-- TOTP disable / password change/reset. See docs/website/TRUSTED_DEVICES_MFA.md.
+CREATE TABLE IF NOT EXISTS recovery_codes (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ user_id INT NOT NULL,
+ code_hash VARCHAR(72) NOT NULL, -- bcrypt hash of one recovery code
+ used_at DATETIME NULL, -- single-use marker
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT fk_rc_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ INDEX idx_rc_user (user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
-- Discord bot control (Phase 1). Singleton row (id = 1) holding the bot's
-- config — the token is encrypted at rest (bot_token_enc) the same way OAuth
-- client secrets are, and is only ever decrypted server-side to push to the
diff --git a/server/src/auth/session.service.js b/server/src/auth/session.service.js
index af396bc..3046325 100644
--- a/server/src/auth/session.service.js
+++ b/server/src/auth/session.service.js
@@ -27,6 +27,7 @@ const crypto = require('crypto')
const token = require('./token')
const revokedSessions = require('../model/revokedSessions/revokedSessions.model')
+const trustedDevices = require('../model/trustedDevices/trustedDevices.model')
const users = require('../model/users/users.model')
const log = require('../utils/logger')('session')
@@ -198,6 +199,63 @@ function sessionMeta(req) {
return { ip, userAgent, deviceHash }
}
+// ── Trusted devices (MFA "Trust this device") ──────────────────────────────
+// A trusted device lets a login SKIP the TOTP step (never the password). The
+// opaque trust token lives client-side (rg_trust cookie on web, X-Trust-Token /
+// EncryptedSharedPreferences on native); only its sha256 hash is stored, so — like
+// the mobile refresh token — the server side is revocable and never holds the raw
+// secret. These functions mint/hash/resolve; the controller sets the cookie and
+// the trustedDevices model persists the row. sha256 (not bcrypt): the token is a
+// 256-bit random value looked up BY its hash via a UNIQUE index.
+
+const TRUSTED_DEVICE_TTL_DAYS = Number(process.env.TRUSTED_DEVICE_TTL_DAYS) || 30
+
+// Hash a raw trust token to the value stored in the DB. Separate name from
+// hashRefreshToken so intent is explicit at call sites, though the algorithm is
+// the same deterministic sha256.
+function hashTrustToken(raw) {
+ return crypto.createHash('sha256').update(String(raw)).digest('hex')
+}
+
+// Mint a fresh opaque trust token + its hash + expiry. `meta` (from sessionMeta)
+// supplies the best-effort device fingerprint stored for display. `now` injectable
+// for tests. Does NOT touch cookies or the DB.
+function mintTrustToken(meta = {}, now = Date.now()) {
+ const trustToken = crypto.randomBytes(32).toString('base64url') // 256 bits, opaque
+ const expiresAt = new Date(now + TRUSTED_DEVICE_TTL_DAYS * 24 * 60 * 60 * 1000)
+ return {
+ trustToken,
+ trustHash: hashTrustToken(trustToken),
+ deviceHash: meta.deviceHash || null,
+ userAgent: meta.userAgent || null,
+ expiresAt,
+ }
+}
+
+// Resolve the trust token on an incoming request to its still-valid DB row (or
+// null). The caller MUST confirm row.user_id matches the user who just passed the
+// password step before honoring it — a trust token is scoped to the account that
+// created it. Never throws on a DB hiccup here; the caller falls back to TOTP.
+async function resolveTrustedDevice(req) {
+ const raw = token.extractTrustToken(req)
+ if (!raw) return null
+ return trustedDevices.findValidByHash(hashTrustToken(raw))
+}
+
+// Stamp a trusted device as used (called when its trust was honored to skip TOTP).
+async function honorTrustedDevice(id) {
+ if (!id) return false
+ await trustedDevices.touchLastUsed(id)
+ return true
+}
+
+// True if the user already holds the maximum number of trusted devices. Callers
+// refuse a new trust (signaling the client to revoke one first) rather than
+// pruning silently. See docs/website/TRUSTED_DEVICES_MFA.md §5.
+async function trustDeviceCapReached(userId) {
+ return trustedDevices.isAtCap(userId)
+}
+
// ── Revocation / invalidation ──────────────────────────────────────────────
// Web/cookie sessions are JWTs, so revocation is enforced by requireAuth reading
// two server-side stores these functions write:
@@ -264,4 +322,10 @@ module.exports = {
refreshMobileSession,
validateBearerToken,
hashRefreshToken,
+ // Trusted devices (MFA "Trust this device").
+ hashTrustToken,
+ mintTrustToken,
+ resolveTrustedDevice,
+ honorTrustedDevice,
+ trustDeviceCapReached,
}
diff --git a/server/src/auth/token.js b/server/src/auth/token.js
index 60e4c6b..3714fae 100644
--- a/server/src/auth/token.js
+++ b/server/src/auth/token.js
@@ -15,6 +15,11 @@ const log = require('../utils/logger')('auth')
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '1d'
const COOKIE_NAME = process.env.COOKIE_NAME || 'rg_token'
+// Separate cookie carrying the opaque trusted-device token (MFA "Trust this
+// device"). Distinct from the session cookie so it deliberately OUTLIVES logout —
+// a trusted browser skips the TOTP step on its next login (never the password).
+const TRUST_COOKIE_NAME = process.env.TRUST_COOKIE_NAME || 'rg_trust'
+const TRUSTED_DEVICE_TTL_DAYS = Number(process.env.TRUSTED_DEVICE_TTL_DAYS) || 30
// Lifetime of the short-lived "password verified, awaiting TOTP" token.
const TOTP_CHALLENGE_TTL = process.env.TOTP_CHALLENGE_TTL || '5m'
@@ -128,8 +133,36 @@ function extractToken(req) {
return null
}
+// ── Trusted-device cookie (MFA "Trust this device") ────────────────────────
+// Rough max-age (ms) for the trust cookie: TRUSTED_DEVICE_TTL_DAYS days.
+function trustCookieMaxAge() {
+ return TRUSTED_DEVICE_TTL_DAYS * 24 * 60 * 60 * 1000
+}
+
+// Same hardening as the session cookie (httpOnly, sameSite=Lax, per-request
+// Secure), but its own name and a 30-day max-age. httpOnly keeps it out of JS.
+function setTrustCookie(req, res, trustToken) {
+ res.cookie(TRUST_COOKIE_NAME, trustToken, { ...cookieOptions(req), maxAge: trustCookieMaxAge() })
+}
+
+function clearTrustCookie(req, res) {
+ res.clearCookie(TRUST_COOKIE_NAME, cookieOptions(req))
+}
+
+// Read the opaque trust token from its cookie (web) or the X-Trust-Token header
+// (native clients, which store it in EncryptedSharedPreferences rather than a
+// cookie jar). Returns null when absent.
+function extractTrustToken(req) {
+ if (req.cookies && req.cookies[TRUST_COOKIE_NAME]) return req.cookies[TRUST_COOKIE_NAME]
+ const header = req.headers && req.headers['x-trust-token']
+ if (header && String(header).trim()) return String(header).trim()
+ return null
+}
+
module.exports = {
COOKIE_NAME,
+ TRUST_COOKIE_NAME,
+ TRUSTED_DEVICE_TTL_DAYS,
JWT_EXPIRES_IN,
resolveJwtSecret,
signToken,
@@ -144,4 +177,8 @@ module.exports = {
setAuthCookie,
clearAuthCookie,
extractToken,
+ trustCookieMaxAge,
+ setTrustCookie,
+ clearTrustCookie,
+ extractTrustToken,
}
diff --git a/server/src/model/recoveryCodes/recoveryCodes.db.js b/server/src/model/recoveryCodes/recoveryCodes.db.js
new file mode 100644
index 0000000..238a83e
--- /dev/null
+++ b/server/src/model/recoveryCodes/recoveryCodes.db.js
@@ -0,0 +1,63 @@
+const { query } = require('../../utils/db')
+
+// SQL for the recovery_codes table. Each row is one bcrypt-hashed, single-use
+// backup code. The raw codes are shown to the user exactly once at generation and
+// never stored in the clear.
+
+// Bulk-insert freshly generated code hashes for a user. `hashes` is an array of
+// bcrypt strings. One multi-row INSERT keeps generation atomic-ish and cheap.
+async function insertMany(userId, hashes) {
+ if (!hashes || hashes.length === 0) return 0
+ const values = hashes.map(() => '(?, ?)').join(', ')
+ const params = []
+ for (const h of hashes) params.push(userId, h)
+ const res = await query(
+ `INSERT INTO recovery_codes (user_id, code_hash) VALUES ${values}`,
+ params,
+ )
+ return Number(res.affectedRows || 0)
+}
+
+// All not-yet-used codes for a user (hashes included — this is the verify path,
+// server-side only). Ordered by id so verification is deterministic.
+async function listUnusedForUser(userId) {
+ return query(
+ 'SELECT id, code_hash FROM recovery_codes WHERE user_id = ? AND used_at IS NULL ORDER BY id',
+ [userId],
+ )
+}
+
+// Count a user's remaining (unused) codes — for the status endpoint (never the
+// codes themselves).
+async function countUnusedForUser(userId) {
+ const rows = await query(
+ 'SELECT COUNT(*) AS n FROM recovery_codes WHERE user_id = ? AND used_at IS NULL',
+ [userId],
+ )
+ return Number(rows[0]?.n || 0)
+}
+
+// Mark one code row used (single-use). Guarded on used_at IS NULL so a race can
+// only consume it once. Returns rows changed.
+async function markUsed(id) {
+ const res = await query(
+ 'UPDATE recovery_codes SET used_at = NOW() WHERE id = ? AND used_at IS NULL',
+ [id],
+ )
+ return Number(res.affectedRows || 0)
+}
+
+// Delete every code for a user. Used both when regenerating (replace the set) and
+// on TOTP disable / password change/reset. Returns rows removed.
+async function deleteAllForUser(userId) {
+ const res = await query('DELETE FROM recovery_codes WHERE user_id = ?', [userId])
+ return Number(res.affectedRows || 0)
+}
+
+module.exports = {
+ insertMany,
+ listUnusedForUser,
+ countUnusedForUser,
+ markUsed,
+ deleteAllForUser,
+}
diff --git a/server/src/model/recoveryCodes/recoveryCodes.model.js b/server/src/model/recoveryCodes/recoveryCodes.model.js
new file mode 100644
index 0000000..a4bc32c
--- /dev/null
+++ b/server/src/model/recoveryCodes/recoveryCodes.model.js
@@ -0,0 +1,86 @@
+// Recovery (backup) code store. Logic layer over recoveryCodes.db, doing the
+// bcrypt hashing itself — the same pattern as users.model hashing passwords (a
+// recovery code is a human-typed, lower-entropy fallback credential, so bcrypt,
+// not sha256; see docs/website/TRUSTED_DEVICES_MFA.md §3). Codes are generated in
+// batches, shown to the user once, and consumed single-use at login.
+
+const crypto = require('crypto')
+const bcrypt = require('bcryptjs')
+
+const db = require('./recoveryCodes.db')
+
+const SALT_ROUNDS = 10
+const CODE_COUNT = Number(process.env.RECOVERY_CODE_COUNT) || 10
+// 10 chars from a 32-symbol alphabet ≈ 50 bits of entropy per code. Crockford-ish
+// base32 minus visually ambiguous glyphs (no I, L, O, U) to keep hand-entry clean.
+const ALPHABET = '23456789ABCDEFGHJKMNPQRSTVWXYZ'
+const CODE_LEN = 10
+
+// Canonical form used for hashing + comparison: uppercase, alphanumerics only.
+// Display adds a dash for readability; input is normalized back to this before
+// bcrypt.compare so 'abcde-fghij', 'ABCDEFGHIJ', etc. all verify.
+function normalize(code) {
+ return String(code || '').toUpperCase().replace(/[^0-9A-Z]/g, '')
+}
+
+// One random code in canonical form (no separator).
+function generateCode() {
+ const bytes = crypto.randomBytes(CODE_LEN)
+ let out = ''
+ for (let i = 0; i < CODE_LEN; i++) out += ALPHABET[bytes[i] % ALPHABET.length]
+ return out
+}
+
+// Present a canonical code to the user with a mid-string dash (display only).
+function formatForDisplay(code) {
+ const mid = Math.floor(code.length / 2)
+ return `${code.slice(0, mid)}-${code.slice(mid)}`
+}
+
+// Generate a fresh batch, REPLACING any existing codes for the user (regeneration
+// invalidates the old set). Returns the plaintext codes for one-time display — the
+// only time they exist outside the user's hands.
+async function generateForUser(userId, count = CODE_COUNT) {
+ const plain = Array.from({ length: count }, generateCode)
+ const hashes = await Promise.all(plain.map((c) => bcrypt.hash(c, SALT_ROUNDS)))
+ await db.deleteAllForUser(userId)
+ await db.insertMany(userId, hashes)
+ return plain.map(formatForDisplay)
+}
+
+// Verify + consume a recovery code (single-use). Normalizes input, bcrypt-compares
+// against the user's unused codes, and marks the first match used. Returns true iff
+// a code was consumed. Timing is dominated by bcrypt regardless of match position.
+async function consumeForUser(userId, rawCode) {
+ const candidate = normalize(rawCode)
+ if (!candidate) return false
+ const rows = await db.listUnusedForUser(userId)
+ for (const row of rows) {
+ // eslint-disable-next-line no-await-in-loop
+ if (await bcrypt.compare(candidate, row.code_hash)) {
+ // eslint-disable-next-line no-await-in-loop
+ const changed = await db.markUsed(row.id)
+ return changed > 0 // lost the race to consume this exact code → treat as fail
+ }
+ }
+ return false
+}
+
+// Remaining (unused) code count — for the status endpoint. Never returns codes.
+async function remainingForUser(userId) {
+ return db.countUnusedForUser(userId)
+}
+
+// Clear every code for a user (TOTP disable / password change/reset).
+async function clearForUser(userId) {
+ return db.deleteAllForUser(userId)
+}
+
+module.exports = {
+ CODE_COUNT,
+ normalize,
+ generateForUser,
+ consumeForUser,
+ remainingForUser,
+ clearForUser,
+}
diff --git a/server/src/model/trustedDevices/trustedDevices.db.js b/server/src/model/trustedDevices/trustedDevices.db.js
new file mode 100644
index 0000000..31ea1c1
--- /dev/null
+++ b/server/src/model/trustedDevices/trustedDevices.db.js
@@ -0,0 +1,103 @@
+const { query } = require('../../utils/db')
+
+// SQL for the trusted_devices table. The opaque trust token lives client-side; the
+// DB stores only its sha256 hash (token_hash). Mirrors mobileSessions.db — a
+// trusted device is the MFA analogue of a live session: it lets a login skip the
+// TOTP step, and is revocable per-row.
+
+// Insert a new trusted-device row. expiresAt is a JS Date (or ms epoch). last_used_at
+// is seeded to now (the device was just trusted at a successful login).
+async function insert({ userId, tokenHash, platform = 'web', deviceName = null, deviceHash = null, userAgent = null, expiresAt }) {
+ const res = await query(
+ `INSERT INTO trusted_devices (user_id, token_hash, platform, device_name, device_hash, user_agent, expires_at, last_used_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, NOW())`,
+ [userId, tokenHash, platform, deviceName, deviceHash, userAgent, new Date(expiresAt)],
+ )
+ return res.insertId
+}
+
+// Look up a trusted device by token hash only if it is still usable: not revoked
+// and not past expiry. Returns the row (incl. user_id) or null. Used by the login
+// path to decide whether TOTP can be skipped.
+async function findValidByHash(tokenHash) {
+ const rows = await query(
+ `SELECT * FROM trusted_devices
+ WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > NOW()
+ LIMIT 1`,
+ [tokenHash],
+ )
+ return rows[0] || null
+}
+
+// Stamp last_used_at when a device's trust is honored at login. Idempotent.
+async function touchLastUsed(id) {
+ const res = await query(
+ 'UPDATE trusted_devices SET last_used_at = NOW() WHERE id = ?',
+ [id],
+ )
+ return Number(res.affectedRows || 0)
+}
+
+// List a user's currently-active (unrevoked, unexpired) trusted devices — newest
+// first. Never returns the token hash. Powers the self-service "Trusted Devices"
+// list and the admin per-user view. Works for any user id (self or admin target).
+async function listActiveForUser(userId) {
+ return query(
+ `SELECT id, platform, device_name, device_hash, user_agent, created_at, last_used_at, expires_at
+ FROM trusted_devices
+ WHERE user_id = ? AND revoked_at IS NULL AND expires_at > NOW()
+ ORDER BY last_used_at DESC, created_at DESC`,
+ [userId],
+ )
+}
+
+// Count a user's currently-active trusted devices. Used to enforce the per-user cap
+// (no silent pruning — the caller refuses an over-cap insert instead).
+async function countActiveForUser(userId) {
+ const rows = await query(
+ 'SELECT COUNT(*) AS n FROM trusted_devices WHERE user_id = ? AND revoked_at IS NULL AND expires_at > NOW()',
+ [userId],
+ )
+ return Number(rows[0]?.n || 0)
+}
+
+// Revoke one of a user's trusted devices by row id (ownership-scoped, so both self
+// and admin-for-target go through the same guarded query). Idempotent; returns
+// rows changed.
+async function revokeByIdForUser(id, userId) {
+ const res = await query(
+ 'UPDATE trusted_devices SET revoked_at = NOW() WHERE id = ? AND user_id = ? AND revoked_at IS NULL',
+ [id, userId],
+ )
+ return Number(res.affectedRows || 0)
+}
+
+// Revoke every active trusted device for a user ("untrust everywhere", and the
+// invalidation hook on password change/reset / TOTP disable). Returns rows changed.
+async function revokeAllForUser(userId) {
+ const res = await query(
+ 'UPDATE trusted_devices SET revoked_at = NOW() WHERE user_id = ? AND revoked_at IS NULL',
+ [userId],
+ )
+ return Number(res.affectedRows || 0)
+}
+
+// Housekeeping: delete rows that are long dead (expired or revoked). Returns rows
+// removed. Same opportunistic-prune approach as mobile_refresh_tokens.
+async function pruneExpired() {
+ const res = await query(
+ 'DELETE FROM trusted_devices WHERE expires_at < NOW() OR revoked_at IS NOT NULL',
+ )
+ return Number(res.affectedRows || 0)
+}
+
+module.exports = {
+ insert,
+ findValidByHash,
+ touchLastUsed,
+ listActiveForUser,
+ countActiveForUser,
+ revokeByIdForUser,
+ revokeAllForUser,
+ pruneExpired,
+}
diff --git a/server/src/model/trustedDevices/trustedDevices.model.js b/server/src/model/trustedDevices/trustedDevices.model.js
new file mode 100644
index 0000000..5e37b9b
--- /dev/null
+++ b/server/src/model/trustedDevices/trustedDevices.model.js
@@ -0,0 +1,69 @@
+// Trusted-device store. Thin logic layer over trustedDevices.db — mirrors the
+// mobileSessions model split (.db = SQL, .model = the API the rest of the app
+// calls). The opaque trust token lives client-side; only its sha256 hash is
+// persisted (hashing is done by the session service so caller + store agree, the
+// same seam as mobile refresh tokens).
+
+const db = require('./trustedDevices.db')
+
+// Max active trusted devices per user. Enforced by assertUnderCap (no silent
+// pruning — an over-cap trust attempt is refused so the client can prompt the user
+// to revoke one first). See docs/website/TRUSTED_DEVICES_MFA.md §5.
+const MAX_TRUSTED_DEVICES = Number(process.env.MAX_TRUSTED_DEVICES) || 10
+
+// Persist a newly trusted device (by token hash). Returns the row id.
+async function store({ userId, tokenHash, platform, deviceName, deviceHash, userAgent, expiresAt }) {
+ return db.insert({ userId, tokenHash, platform, deviceName, deviceHash, userAgent, expiresAt })
+}
+
+// Return the stored row for a still-valid (unrevoked, unexpired) trust token, else
+// null. Used by the login path to decide whether the TOTP step can be skipped.
+async function findValidByHash(tokenHash) {
+ return db.findValidByHash(tokenHash)
+}
+
+// Stamp last_used_at when a device's trust is honored at login.
+async function touchLastUsed(id) {
+ return db.touchLastUsed(id)
+}
+
+// List a user's active trusted devices (self-service list + admin per-user view).
+async function listActiveForUser(userId) {
+ return db.listActiveForUser(userId)
+}
+
+// True if the user is at/over the trusted-device cap. Callers refuse the insert and
+// signal the client to revoke one first, rather than pruning silently.
+async function isAtCap(userId) {
+ const n = await db.countActiveForUser(userId)
+ return n >= MAX_TRUSTED_DEVICES
+}
+
+// Revoke one of a user's trusted devices by row id (ownership-scoped). Returns rows
+// changed (0 if it wasn't theirs / already gone — treat idempotently).
+async function revokeByIdForUser(id, userId) {
+ return db.revokeByIdForUser(id, userId)
+}
+
+// Revoke all of a user's trusted devices ("untrust everywhere" + the invalidation
+// hook on password change/reset / TOTP disable). Returns rows changed.
+async function revokeAllForUser(userId) {
+ return db.revokeAllForUser(userId)
+}
+
+// Drop expired/revoked rows.
+async function pruneExpired() {
+ return db.pruneExpired()
+}
+
+module.exports = {
+ MAX_TRUSTED_DEVICES,
+ store,
+ findValidByHash,
+ touchLastUsed,
+ listActiveForUser,
+ isAtCap,
+ revokeByIdForUser,
+ revokeAllForUser,
+ pruneExpired,
+}
diff --git a/server/src/router/v1/admin/account.controller.js b/server/src/router/v1/admin/account.controller.js
index a2cf5ab..ef090b9 100644
--- a/server/src/router/v1/admin/account.controller.js
+++ b/server/src/router/v1/admin/account.controller.js
@@ -6,8 +6,11 @@ const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const userIdentities = require('../../../model/userIdentities/userIdentities.model')
const mobileSessions = require('../../../model/mobileSessions/mobileSessions.model')
+const trustedDevices = require('../../../model/trustedDevices/trustedDevices.model')
+const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
const sessionService = require('../../../auth/session.service')
-const { setAuthCookie } = require('../../../auth/token')
+const { establishTrust } = require('../auth/trustDevice.helper')
+const { setAuthCookie, setTrustCookie, clearTrustCookie } = require('../../../auth/token')
const usernamePolicy = require('../../../auth/usernamePolicy')
const loginProtection = require('../../../middleware/loginProtection')
const botScore = require('../../../middleware/botScore')
@@ -108,6 +111,12 @@ async function changePassword(req, res) {
if (session && session.createdAt) {
await users.setSessionCutoff(req.user.id, new Date(session.createdAt - 1000))
}
+ // A password change is a security event: drop every trusted device and every
+ // recovery code so a compromised-then-changed account can't be re-entered with
+ // a stale second-factor bypass. Clear this browser's trust cookie too.
+ await trustedDevices.revokeAllForUser(req.user.id)
+ await recoveryCodes.clearForUser(req.user.id)
+ clearTrustCookie(req, res)
await activity.log({ req, action: 'account.password.change' })
log.info('account password changed', { id: req.user.id })
return res.json({ ok: true })
@@ -150,9 +159,14 @@ async function totpEnable(req, res) {
return res.status(400).json({ message: 'That code is not valid. Try again.' })
}
await users.enableTotp(user.id)
+ // Issue the initial batch of single-use recovery codes, shown to the user ONCE
+ // right here (the only time they leave the server in the clear). Generation
+ // replaces any prior set, so re-enrolling always starts clean.
+ const codes = await recoveryCodes.generateForUser(user.id)
await activity.log({ req, action: 'account.totp.enable' })
+ await activity.log({ req, action: 'account.recovery_codes.generate', detail: { count: codes.length } })
log.info('totp enabled', { id: user.id, username: user.username })
- return res.json({ totp_enabled: true })
+ return res.json({ totp_enabled: true, recoveryCodes: codes })
} catch (err) {
log.error('totpEnable', err)
return res.status(500).json({ message: 'Internal Server Error' })
@@ -171,6 +185,11 @@ async function totpDisable(req, res) {
return res.status(400).json({ message: 'That code is not valid. Try again.' })
}
await users.disableTotp(user.id)
+ // With 2FA off, both the trusted-device bypass and recovery codes are moot and
+ // must not linger — drop them so re-enabling later starts from a clean slate.
+ await trustedDevices.revokeAllForUser(user.id)
+ await recoveryCodes.clearForUser(user.id)
+ clearTrustCookie(req, res)
await activity.log({ req, action: 'account.totp.disable' })
log.info('totp disabled', { id: user.id, username: user.username })
return res.json({ totp_enabled: false })
@@ -246,6 +265,129 @@ async function revokeSession(req, res) {
}
}
+// ── Trusted devices (self-service) ─────────────────────────────────────────
+// Shape a trusted_devices row for the client (never the token hash).
+function toTrustedDevice(r) {
+ return {
+ id: r.id,
+ platform: r.platform,
+ deviceName: r.device_name || null,
+ userAgent: r.user_agent || null,
+ createdAt: r.created_at,
+ lastUsedAt: r.last_used_at || r.created_at,
+ expiresAt: r.expires_at,
+ }
+}
+
+// List the current user's active trusted devices (Trusted Devices screen).
+async function listTrustedDevices(req, res) {
+ try {
+ const rows = await trustedDevices.listActiveForUser(req.user.id)
+ return res.json(rows.map(toTrustedDevice))
+ } catch (err) {
+ log.error('listTrustedDevices', err)
+ return res.status(500).json({ message: 'Internal Server Error' })
+ }
+}
+
+// Trust the CURRENT device/browser from an authenticated session. This is the
+// "revoke one, then retry" completion after a cap-reached prompt, and a general
+// self-service way to trust the device you're on. Web receives the token as the
+// httpOnly rg_trust cookie; native (bearer) sessions get it in the JSON body.
+async function trustThisDevice(req, res) {
+ try {
+ const isMobile = (req.session?.authMethod || req.authMethod) === 'mobile'
+ const result = await establishTrust(req, req.user, {
+ platform: isMobile ? 'mobile' : 'web',
+ deviceName: req.body.deviceName || null,
+ })
+ if (!result.ok && result.capReached) {
+ return res.status(409).json({ error: 'trusted_device_limit', devices: result.devices.map(toTrustedDevice) })
+ }
+ if (!isMobile) {
+ setTrustCookie(req, res, result.trustToken)
+ return res.json({ trusted: true })
+ }
+ return res.json({ trusted: true, trustToken: result.trustToken })
+ } catch (err) {
+ log.error('trustThisDevice', err)
+ return res.status(500).json({ message: 'Internal Server Error' })
+ }
+}
+
+// Revoke one of the current user's trusted devices by id (ownership-scoped).
+async function revokeTrustedDevice(req, res) {
+ const id = Number(req.params.id)
+ try {
+ const n = await trustedDevices.revokeByIdForUser(id, req.user.id)
+ if (n) {
+ await activity.log({ req, action: 'account.trusted_device.revoke', detail: { deviceId: id } })
+ log.info('trusted device revoked (self)', { id, userId: req.user.id })
+ }
+ return res.json({ revoked: n > 0 })
+ } catch (err) {
+ log.error('revokeTrustedDevice', err)
+ return res.status(500).json({ message: 'Internal Server Error' })
+ }
+}
+
+// Revoke ALL of the current user's trusted devices ("untrust everywhere"), and
+// clear this browser's trust cookie.
+async function revokeAllTrustedDevices(req, res) {
+ try {
+ const n = await trustedDevices.revokeAllForUser(req.user.id)
+ clearTrustCookie(req, res)
+ await activity.log({ req, action: 'account.trusted_device.revoke_all', detail: { count: n } })
+ log.info('all trusted devices revoked (self)', { userId: req.user.id, count: n })
+ return res.json({ revoked: n })
+ } catch (err) {
+ log.error('revokeAllTrustedDevices', err)
+ return res.status(500).json({ message: 'Internal Server Error' })
+ }
+}
+
+// ── Recovery codes (self-service) ──────────────────────────────────────────
+// Remaining (unused) code count — never the codes themselves.
+async function recoveryCodesStatus(req, res) {
+ try {
+ const remaining = await recoveryCodes.remainingForUser(req.user.id)
+ return res.json({ remaining })
+ } catch (err) {
+ log.error('recoveryCodesStatus', err)
+ return res.status(500).json({ message: 'Internal Server Error' })
+ }
+}
+
+// Regenerate the recovery-code set, returning the new codes ONCE. Password
+// step-up: an account that has a password must supply and match currentPassword
+// (SSO-only accounts with no password may proceed while authenticated, mirroring
+// changePassword). Refuses when 2FA is off (codes only exist alongside TOTP).
+async function generateRecoveryCodes(req, res) {
+ try {
+ const raw = await users.getRawById(req.user.id)
+ if (!raw) return res.status(401).json({ message: 'Unauthorized' })
+ if (!raw.totp_enabled) {
+ return res.status(400).json({ message: 'Enable two-factor before generating recovery codes.' })
+ }
+ if (raw.password_hash) {
+ const ok = await users.validatePassword(raw, req.body.currentPassword || '')
+ if (!ok) {
+ loginProtection.recordFailure(req.ip)
+ botScore.recordLoginFailure(req.ip)
+ log.warn('generateRecoveryCodes wrong current password', { id: req.user.id, ip: req.ip })
+ return res.status(400).json({ message: 'Your current password is incorrect.' })
+ }
+ }
+ const codes = await recoveryCodes.generateForUser(req.user.id)
+ await activity.log({ req, action: 'account.recovery_codes.generate', detail: { count: codes.length } })
+ log.info('recovery codes regenerated', { id: req.user.id })
+ return res.json({ recoveryCodes: codes })
+ } catch (err) {
+ log.error('generateRecoveryCodes', err)
+ return res.status(500).json({ message: 'Internal Server Error' })
+ }
+}
+
module.exports = {
getAccount,
changeUsername,
@@ -257,4 +399,10 @@ module.exports = {
unlinkIdentity,
listSessions,
revokeSession,
+ listTrustedDevices,
+ trustThisDevice,
+ revokeTrustedDevice,
+ revokeAllTrustedDevices,
+ recoveryCodesStatus,
+ generateRecoveryCodes,
}
diff --git a/server/src/router/v1/admin/admin.controller.js b/server/src/router/v1/admin/admin.controller.js
index 5edaeff..d7707cb 100644
--- a/server/src/router/v1/admin/admin.controller.js
+++ b/server/src/router/v1/admin/admin.controller.js
@@ -3,6 +3,8 @@ const wiki = require('../../../model/wiki/wiki.model')
const settings = require('../../../model/settings/settings.model')
const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
+const trustedDevices = require('../../../model/trustedDevices/trustedDevices.model')
+const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
const announceJobs = require('../../../model/announceJobs/announceJobs.model')
const newsGump = require('../../../utils/newsGump')
const pushDispatch = require('../../../utils/pushDispatch')
@@ -651,6 +653,88 @@ async function deleteUser(req, res) {
}
}
+// ── Admin: a user's trusted devices & MFA (admin only) ─────────────────────
+// Staff-facing view/revocation of another user's trusted devices, plus an MFA
+// reset for a locked-out user. All actions are audit-logged with the acting admin
+// (via activity.log's req) and the target user id.
+function toAdminTrustedDevice(r) {
+ return {
+ id: r.id,
+ platform: r.platform,
+ deviceName: r.device_name || null,
+ userAgent: r.user_agent || null,
+ createdAt: r.created_at,
+ lastUsedAt: r.last_used_at || r.created_at,
+ expiresAt: r.expires_at,
+ }
+}
+
+async function listUserTrustedDevices(req, res) {
+ const id = Number(req.params.id)
+ try {
+ const target = await users.getById(id)
+ if (!target) return res.status(404).json({ message: 'Not found' })
+ const rows = await trustedDevices.listActiveForUser(id)
+ return res.json(rows.map(toAdminTrustedDevice))
+ } catch (err) {
+ log.error('listUserTrustedDevices', err)
+ return res.status(500).json({ message: 'Internal Server Error' })
+ }
+}
+
+async function revokeUserTrustedDevice(req, res) {
+ const id = Number(req.params.id)
+ const deviceId = Number(req.params.deviceId)
+ try {
+ const target = await users.getById(id)
+ if (!target) return res.status(404).json({ message: 'Not found' })
+ const n = await trustedDevices.revokeByIdForUser(deviceId, id)
+ if (n) {
+ await activity.log({ req, action: 'admin.trusted_device.revoke', detail: { userId: id, deviceId } })
+ log.info('admin revoked trusted device', { adminId: req.user.id, userId: id, deviceId })
+ }
+ return res.json({ revoked: n > 0 })
+ } catch (err) {
+ log.error('revokeUserTrustedDevice', err)
+ return res.status(500).json({ message: 'Internal Server Error' })
+ }
+}
+
+async function revokeAllUserTrustedDevices(req, res) {
+ const id = Number(req.params.id)
+ try {
+ const target = await users.getById(id)
+ if (!target) return res.status(404).json({ message: 'Not found' })
+ const n = await trustedDevices.revokeAllForUser(id)
+ await activity.log({ req, action: 'admin.trusted_device.revoke_all', detail: { userId: id, count: n } })
+ log.info('admin revoked all trusted devices', { adminId: req.user.id, userId: id, count: n })
+ return res.json({ revoked: n })
+ } catch (err) {
+ log.error('revokeAllUserTrustedDevices', err)
+ return res.status(500).json({ message: 'Internal Server Error' })
+ }
+}
+
+// Reset a locked-out user's MFA: turn TOTP off, drop every trusted device, and
+// clear their recovery codes. Lets an admin recover a user who lost their
+// authenticator; the user can then sign in with their password alone and re-enroll.
+async function resetUserMfa(req, res) {
+ const id = Number(req.params.id)
+ try {
+ const target = await users.getById(id)
+ if (!target) return res.status(404).json({ message: 'Not found' })
+ await users.disableTotp(id)
+ await trustedDevices.revokeAllForUser(id)
+ await recoveryCodes.clearForUser(id)
+ await activity.log({ req, action: 'admin.user.totp.reset', detail: { userId: id } })
+ log.info('admin reset user MFA', { adminId: req.user.id, userId: id })
+ return res.json({ ok: true })
+ } catch (err) {
+ log.error('resetUserMfa', err)
+ return res.status(500).json({ message: 'Internal Server Error' })
+ }
+}
+
module.exports = {
dashboard,
setSiteMode,
@@ -685,4 +769,8 @@ module.exports = {
createUser,
updateUser,
deleteUser,
+ listUserTrustedDevices,
+ revokeUserTrustedDevice,
+ revokeAllUserTrustedDevices,
+ resetUserMfa,
}
diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js
index 20c8821..276cc55 100644
--- a/server/src/router/v1/admin/admin.routes.js
+++ b/server/src/router/v1/admin/admin.routes.js
@@ -1283,6 +1283,68 @@ adminRouter.delete(
ctrl.deleteUser,
)
+// ── A user's trusted devices & MFA (admin only) ───────────────────────
+adminRouter.get(
+ '/users/:id/trusted-devices',
+ // #swagger.tags = ['Admin · Users']
+ // #swagger.summary = 'List a user’s trusted devices (admin only)'
+ // #swagger.description = 'Active (unrevoked, unexpired) trusted devices for the target user — the browsers/apps allowed to skip that user’s TOTP step. Never returns tokens.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
+ /* #swagger.responses[200] = { description: 'Trusted devices', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/TrustedDevice" } } } } } */
+ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ param('id').isInt(),
+ validate,
+ ctrl.listUserTrustedDevices,
+)
+adminRouter.delete(
+ '/users/:id/trusted-devices',
+ // #swagger.tags = ['Admin · Users']
+ // #swagger.summary = 'Revoke all of a user’s trusted devices (admin only)'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
+ /* #swagger.responses[200] = { description: 'Revoked count', content: { "application/json": { schema: { type: "object", properties: { revoked: { type: "integer" } } } } } } */
+ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ param('id').isInt(),
+ validate,
+ ctrl.revokeAllUserTrustedDevices,
+)
+adminRouter.delete(
+ '/users/:id/trusted-devices/:deviceId',
+ // #swagger.tags = ['Admin · Users']
+ // #swagger.summary = 'Revoke one of a user’s trusted devices (admin only)'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
+ // #swagger.parameters['deviceId'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Trusted-device id.' }
+ /* #swagger.responses[200] = { description: 'Revoked (idempotent)', content: { "application/json": { schema: { type: "object", properties: { revoked: { type: "boolean" } } } } } } */
+ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ param('id').isInt(),
+ param('deviceId').isInt({ min: 1 }),
+ validate,
+ ctrl.revokeUserTrustedDevice,
+)
+adminRouter.post(
+ '/users/:id/mfa/reset',
+ // #swagger.tags = ['Admin · Users']
+ // #swagger.summary = 'Reset a user’s MFA (admin only)'
+ // #swagger.description = 'Recovers a locked-out user: turns TOTP off, revokes every trusted device, and clears their recovery codes. The user can then sign in with their password alone and re-enroll.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
+ /* #swagger.responses[200] = { description: 'MFA reset', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
+ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ param('id').isInt(),
+ validate,
+ ctrl.resetUserMfa,
+)
+
// ── User → shard (uo-link) footprint (admin only) ─────────────────────
// Backs the /admin/users/:id detail page: a user's linked game accounts and,
// scoped to those accounts, their vendor sales / houses / online characters.
diff --git a/server/src/router/v1/auth/auth.controller.js b/server/src/router/v1/auth/auth.controller.js
index c91d780..3adcd88 100644
--- a/server/src/router/v1/auth/auth.controller.js
+++ b/server/src/router/v1/auth/auth.controller.js
@@ -1,8 +1,10 @@
const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const settings = require('../../../model/settings/settings.model')
-const { setAuthCookie, clearAuthCookie } = require('../../../auth/token')
+const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
+const { setAuthCookie, clearAuthCookie, setTrustCookie } = require('../../../auth/token')
const sessionService = require('../../../auth/session.service')
+const { establishTrust } = require('./trustDevice.helper')
const totp = require('../../../utils/totp')
const botScore = require('../../../middleware/botScore')
const loginProtection = require('../../../middleware/loginProtection')
@@ -27,14 +29,14 @@ function needsTotp(user) {
// the cookie, clear the IP's failure backoff, and record the login. authMethod
// records how this session was authenticated ('local' password, or 'totp' after
// the second factor) — carried in the session token for downstream visibility.
-async function issueSession(req, res, user, authMethod = 'local') {
+async function issueSession(req, res, user, authMethod = 'local', extra = undefined) {
loginProtection.recordSuccess(req.ip)
await users.recordLogin(user.id, req.ip)
const { token } = sessionService.createSession(user, authMethod)
setAuthCookie(req, res, token)
await activity.log({ req, userId: user.id, action: 'auth.login' })
log.info('login success', { username: user.username, id: user.id, ip: req.ip, authMethod })
- return res.json({ user: { id: user.id, username: user.username, role: user.role } })
+ return res.json({ user: { id: user.id, username: user.username, role: user.role }, ...(extra || {}) })
}
async function login(req, res) {
@@ -68,9 +70,23 @@ async function login(req, res) {
}
// Password is correct. If this user has TOTP on, do NOT issue a session yet —
- // hand back a short-lived, signed "password verified" challenge and require
- // the code. If TOTP is off, log them straight in.
+ // unless this browser is a trusted device, in which case the second factor is
+ // skipped (the password was still required above). Otherwise hand back a
+ // short-lived, signed "password verified" challenge and require the code.
if (needsTotp(user)) {
+ // Trusted-device skip: honor a valid trust token bound to THIS user. Any DB
+ // hiccup falls through to the normal TOTP challenge (fail closed to TOTP).
+ try {
+ const device = await sessionService.resolveTrustedDevice(req)
+ if (device && device.user_id === user.id) {
+ await sessionService.honorTrustedDevice(device.id)
+ await activity.log({ req, userId: user.id, action: 'auth.login.trusted_device' })
+ log.info('login via trusted device (TOTP skipped)', { username: user.username, id: user.id, ip: req.ip })
+ return issueSession(req, res, user, 'totp')
+ }
+ } catch (err) {
+ log.error('trusted-device check failed; falling back to TOTP', err)
+ }
const challenge = sessionService.createPartialSession(user)
log.info('password ok, awaiting TOTP', { username: user.username, id: user.id, ip: req.ip })
return res.json({ totpRequired: true, challenge })
@@ -134,23 +150,56 @@ async function register(req, res) {
}
}
-// Second step for TOTP users: verify the challenge token + code, then issue the
-// session. A wrong code counts as a failed attempt (backoff + bot score).
+// Second step for TOTP users: verify the challenge token + a second factor, then
+// issue the session. The second factor is either the current authenticator `code`
+// OR a single-use `recoveryCode` (for users who lost their authenticator). A wrong
+// factor counts as a failed attempt (backoff + bot score). If `trustDevice` is set,
+// this browser is remembered so future logins skip the TOTP step — unless the user
+// is at the trusted-device cap, in which case the session is still issued and the
+// response carries a { trustLimitReached, devices } prompt to revoke one first.
async function loginTotp(req, res) {
- const { challenge, code } = req.body
+ const { challenge, code, recoveryCode, trustDevice, deviceName } = req.body
const decoded = sessionService.upgradeSessionAfterTotp(challenge)
if (!decoded) {
return res.status(401).json({ message: 'Your verification session expired. Please sign in again.' })
}
try {
const user = await users.getRawById(decoded.id)
- if (!user || !user.totp_enabled || !totp.verifyCode(user.totp_secret, code)) {
+ if (!user || !user.totp_enabled) {
botScore.recordLoginFailure(req.ip)
loginProtection.recordFailure(req.ip)
log.warn('TOTP verify failed', { id: decoded.id, ip: req.ip })
return res.status(401).json({ message: 'Invalid verification code.' })
}
- return issueSession(req, res, user, 'totp')
+
+ // Accept a TOTP code, or fall back to consuming a single-use recovery code.
+ let verified = Boolean(code) && totp.verifyCode(user.totp_secret, code)
+ let viaRecovery = false
+ if (!verified && recoveryCode) {
+ verified = await recoveryCodes.consumeForUser(user.id, recoveryCode)
+ viaRecovery = verified
+ }
+ if (!verified) {
+ botScore.recordLoginFailure(req.ip)
+ loginProtection.recordFailure(req.ip)
+ log.warn('TOTP verify failed', { id: user.id, ip: req.ip, recovery: Boolean(recoveryCode) })
+ return res.status(401).json({ message: 'Invalid verification code.' })
+ }
+ if (viaRecovery) {
+ await activity.log({ req, userId: user.id, action: 'account.recovery_code.consume' })
+ log.info('login via recovery code', { id: user.id, ip: req.ip })
+ }
+
+ // Optionally remember this browser as a trusted device.
+ let trustLimit = null
+ if (trustDevice) {
+ const result = await establishTrust(req, user, { platform: 'web', deviceName: deviceName || null })
+ if (result.ok) setTrustCookie(req, res, result.trustToken)
+ else if (result.capReached) trustLimit = result.devices
+ }
+
+ const extra = trustLimit ? { trustLimitReached: true, devices: trustLimit } : undefined
+ return issueSession(req, res, user, 'totp', extra)
} catch (err) {
log.error('loginTotp error', err)
return res.status(500).json({ message: 'Internal Server Error' })
diff --git a/server/src/router/v1/auth/auth.routes.js b/server/src/router/v1/auth/auth.routes.js
index 3775e66..4d4a9ea 100644
--- a/server/src/router/v1/auth/auth.routes.js
+++ b/server/src/router/v1/auth/auth.routes.js
@@ -94,16 +94,21 @@ authRouter.post(
authRouter.post(
'/login/totp',
// #swagger.tags = ['Auth']
- // #swagger.summary = 'Complete login with a TOTP code'
- // #swagger.description = 'Second step for 2FA accounts. Exchange the challenge from /login plus the current authenticator code for a session cookie.'
+ // #swagger.summary = 'Complete login with a TOTP or recovery code'
+ // #swagger.description = 'Second step for 2FA accounts. Exchange the challenge from /login plus either the current authenticator code OR a single-use recovery code for a session cookie. Set trustDevice to remember this browser and skip TOTP on future logins (30 days); if the trusted-device limit is reached the session is still issued and the response carries { trustLimitReached, devices } so the user can revoke one first.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpLoginRequest" } } } } */
- /* #swagger.responses[200] = { description: 'Session issued', content: { "application/json": { schema: { $ref: "#/components/schemas/LoginResponse" } } } } */
+ /* #swagger.responses[200] = { description: 'Session issued (optionally with a trusted-device-limit prompt)', content: { "application/json": { schema: { $ref: "#/components/schemas/LoginResponse" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[401] = { description: 'Invalid code or expired challenge', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many attempts (rate limited / backoff)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
...loginGuards,
body('challenge').isString().notEmpty(),
- body('code').isString().trim().isLength({ min: 6, max: 8 }),
+ // Either a TOTP code or a recovery code satisfies the second factor; the
+ // controller rejects the request when neither verifies.
+ body('code').optional({ values: 'falsy' }).isString().trim().isLength({ min: 6, max: 8 }),
+ body('recoveryCode').optional({ values: 'falsy' }).isString().trim().isLength({ min: 8, max: 32 }),
+ body('trustDevice').optional().isBoolean(),
+ body('deviceName').optional({ values: 'falsy' }).isString().trim().isLength({ max: 100 }),
validate,
loginTotp,
)
diff --git a/server/src/router/v1/auth/me.routes.js b/server/src/router/v1/auth/me.routes.js
index 212dbe5..a9a715e 100644
--- a/server/src/router/v1/auth/me.routes.js
+++ b/server/src/router/v1/auth/me.routes.js
@@ -22,6 +22,7 @@ const { requireAuth } = require('../../../auth/session.middleware')
const noindex = require('../../../middleware/noindex')
const validate = require('../../../middleware/validate')
const { accountChangeLimiter } = require('../../../middleware/rateLimit')
+const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection')
const meRouter = express.Router()
@@ -164,4 +165,83 @@ meRouter.delete(
account.revokeSession,
)
+// ── Trusted devices (self-service, MFA "Trust this device") ────────────────
+// Distinct from /sessions (mobile login sessions): these are the devices allowed
+// to SKIP the TOTP step at login. List, trust-current, revoke one, untrust all.
+meRouter.get(
+ '/trusted-devices',
+ // #swagger.tags = ['Auth · Me']
+ // #swagger.summary = 'List trusted devices (self)'
+ // #swagger.description = 'Active (unrevoked, unexpired) trusted devices — the browsers/apps allowed to skip the TOTP step at login. Never returns tokens.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.responses[200] = { description: 'Active trusted devices', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/TrustedDevice" } } } } } */
+ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ account.listTrustedDevices,
+)
+meRouter.post(
+ '/trusted-devices',
+ // #swagger.tags = ['Auth · Me']
+ // #swagger.summary = 'Trust the current device (self)'
+ // #swagger.description = 'Marks the current browser/app as trusted so future logins skip the TOTP step (30 days). Web receives an httpOnly trust cookie; native (bearer) sessions receive { trustToken } to store. Returns 409 { error: "trusted_device_limit", devices } when the per-user cap is reached — revoke one first, then retry.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", properties: { deviceName: { type: "string" } } } } } } */
+ /* #swagger.responses[200] = { description: 'Device trusted', content: { "application/json": { schema: { $ref: "#/components/schemas/TrustDeviceResult" } } } } */
+ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ /* #swagger.responses[409] = { description: 'Trusted-device limit reached', content: { "application/json": { schema: { $ref: "#/components/schemas/TrustedDeviceLimit" } } } } */
+ accountChangeLimiter,
+ body('deviceName').optional({ values: 'falsy' }).isString().trim().isLength({ max: 100 }),
+ validate,
+ account.trustThisDevice,
+)
+meRouter.delete(
+ '/trusted-devices',
+ // #swagger.tags = ['Auth · Me']
+ // #swagger.summary = 'Revoke all trusted devices (self)'
+ // #swagger.description = 'Untrust every device; future logins on all of them require the full TOTP step again. Also clears this browser’s trust cookie.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.responses[200] = { description: 'Revoked count', content: { "application/json": { schema: { type: "object", properties: { revoked: { type: "integer" } } } } } } */
+ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ account.revokeAllTrustedDevices,
+)
+meRouter.delete(
+ '/trusted-devices/:id',
+ // #swagger.tags = ['Auth · Me']
+ // #swagger.summary = 'Revoke one trusted device (self)'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Trusted-device id from GET /auth/me/trusted-devices.' }
+ /* #swagger.responses[200] = { description: 'Revoked (idempotent)', content: { "application/json": { schema: { type: "object", properties: { revoked: { type: "boolean" } } } } } } */
+ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ param('id').isInt({ min: 1 }),
+ validate,
+ account.revokeTrustedDevice,
+)
+
+// ── Recovery (backup) codes (self-service) ─────────────────────────────────
+meRouter.get(
+ '/account/recovery-codes/status',
+ // #swagger.tags = ['Auth · Me']
+ // #swagger.summary = 'Remaining recovery-code count (self)'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.responses[200] = { description: 'Remaining unused codes', content: { "application/json": { schema: { type: "object", properties: { remaining: { type: "integer" } } } } } } */
+ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ account.recoveryCodesStatus,
+)
+meRouter.post(
+ '/account/recovery-codes/generate',
+ // #swagger.tags = ['Auth · Me']
+ // #swagger.summary = 'Regenerate recovery codes (self, password step-up)'
+ // #swagger.description = 'Generates a fresh set of single-use recovery codes, invalidating any prior set, and returns them ONCE. Requires the current password (accounts that have one); refuses when two-factor is off. Behind the login backoff/bot guards since a wrong password is credential-guessing.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", properties: { currentPassword: { type: "string" } } } } } } */
+ /* #swagger.responses[200] = { description: 'New recovery codes (shown once)', content: { "application/json": { schema: { $ref: "#/components/schemas/RecoveryCodes" } } } } */
+ /* #swagger.responses[400] = { description: 'Wrong password, or two-factor not enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ backoffGuard,
+ slowLogin,
+ accountChangeLimiter,
+ body('currentPassword').optional({ values: 'falsy' }).isString(),
+ validate,
+ account.generateRecoveryCodes,
+)
+
module.exports = meRouter
diff --git a/server/src/router/v1/auth/mobile.controller.js b/server/src/router/v1/auth/mobile.controller.js
index 5651fb0..01042dc 100644
--- a/server/src/router/v1/auth/mobile.controller.js
+++ b/server/src/router/v1/auth/mobile.controller.js
@@ -14,7 +14,9 @@
const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const mobileSessions = require('../../../model/mobileSessions/mobileSessions.model')
+const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
const sessionService = require('../../../auth/session.service')
+const { establishTrust } = require('./trustDevice.helper')
const totp = require('../../../utils/totp')
const botScore = require('../../../middleware/botScore')
const loginProtection = require('../../../middleware/loginProtection')
@@ -52,9 +54,10 @@ async function persistAndFinish(req, user, out, action, deviceName = null) {
await activity.log({ req, userId: user.id, action })
}
-// POST /auth/mobile/login { username, password, code? }
+// POST /auth/mobile/login { username, password, code?, recoveryCode?, trustDevice? }
async function login(req, res) {
- const { username, password, code } = req.body
+ const { username, password, code, recoveryCode, trustDevice } = req.body
+ const deviceName = req.body.device_name || null
try {
const user = await users.getRawByUsername(username)
const ok = user && (await users.validatePassword(user, password))
@@ -65,26 +68,54 @@ async function login(req, res) {
return res.status(401).json(GENERIC_FAIL)
}
- // Second factor, single-request style: if 2FA is enabled, a valid code must
- // accompany this request. Missing or wrong → tell the app to prompt + retry.
- // A wrong code is a real failed attempt (scored + backed off like web).
+ // Second factor, single-request style: if 2FA is enabled it must be satisfied
+ // by (a) a trusted-device token (X-Trust-Token) bound to this user, (b) a valid
+ // TOTP code, or (c) a single-use recovery code. Otherwise tell the app to prompt
+ // + retry. A wrong code/recovery code is a real failed attempt (scored + backed
+ // off like web); a missing factor is not (it's the expected first round-trip).
+ let viaRecovery = false
if (user.totp_enabled) {
- if (!code || !totp.verifyCode(user.totp_secret, code)) {
- if (code) {
- botScore.recordLoginFailure(req.ip)
- loginProtection.recordFailure(req.ip)
- log.warn('mobile TOTP verify failed', { id: user.id, ip: req.ip })
+ const device = await sessionService.resolveTrustedDevice(req)
+ const trusted = Boolean(device && device.user_id === user.id)
+ if (trusted) {
+ await sessionService.honorTrustedDevice(device.id)
+ await activity.log({ req, userId: user.id, action: 'auth.login.trusted_device' })
+ } else {
+ let verified = Boolean(code) && totp.verifyCode(user.totp_secret, code)
+ if (!verified && recoveryCode) {
+ verified = await recoveryCodes.consumeForUser(user.id, recoveryCode)
+ viaRecovery = verified
+ }
+ if (!verified) {
+ if (code || recoveryCode) {
+ botScore.recordLoginFailure(req.ip)
+ loginProtection.recordFailure(req.ip)
+ log.warn('mobile TOTP verify failed', { id: user.id, ip: req.ip, recovery: Boolean(recoveryCode) })
+ }
+ return res.status(401).json({ totpRequired: true, message: 'A verification code is required.' })
}
- return res.status(401).json({ totpRequired: true, message: 'A verification code is required.' })
}
}
loginProtection.recordSuccess(req.ip)
const meta = sessionService.sessionMeta(req)
const out = sessionService.createMobileSession(user, meta)
- await persistAndFinish(req, user, out, 'auth.mobile.login', req.body.device_name || null)
+ await persistAndFinish(req, user, out, 'auth.mobile.login', deviceName)
+ if (viaRecovery) {
+ await activity.log({ req, userId: user.id, action: 'account.recovery_code.consume' })
+ log.info('mobile login via recovery code', { id: user.id, ip: req.ip })
+ }
+
+ // Optionally remember this device so future logins skip the second factor.
+ const body = tokenResponse(out, user)
+ if (trustDevice) {
+ const result = await establishTrust(req, user, { platform: 'mobile', deviceName })
+ if (result.ok) body.trustToken = result.trustToken
+ else if (result.capReached) { body.trustLimitReached = true; body.devices = result.devices }
+ }
+
log.info('mobile login success', { username: user.username, id: user.id, ip: req.ip })
- return res.json(tokenResponse(out, user))
+ return res.json(body)
} catch (err) {
log.error('mobile login error', err)
return res.status(500).json({ message: 'Internal Server Error' })
diff --git a/server/src/router/v1/auth/mobile.routes.js b/server/src/router/v1/auth/mobile.routes.js
index c530051..c7b8efe 100644
--- a/server/src/router/v1/auth/mobile.routes.js
+++ b/server/src/router/v1/auth/mobile.routes.js
@@ -25,9 +25,9 @@ mobileRouter.post(
'/login',
// #swagger.tags = ['Auth · Mobile']
// #swagger.summary = 'Native login → access + refresh tokens'
- // #swagger.description = 'Bearer-token login for native clients. Single-request 2FA: if the account has TOTP on and no/invalid code is supplied, returns 401 { totpRequired: true } and the client retries with a code.'
+ // #swagger.description = 'Bearer-token login for native clients. Single-request 2FA: if the account has TOTP on and no/invalid code is supplied, returns 401 { totpRequired: true } and the client retries with a code (or a single-use recoveryCode). A previously trusted device may present the X-Trust-Token header to skip the code entirely. Set trustDevice to remember this device (the response then carries trustToken to store); if the trusted-device limit is reached the tokens are still issued and the response carries { trustLimitReached, devices }.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/MobileLoginRequest" } } } } */
- /* #swagger.responses[200] = { description: 'Access + refresh tokens', content: { "application/json": { schema: { $ref: "#/components/schemas/MobileTokenResponse" } } } } */
+ /* #swagger.responses[200] = { description: 'Access + refresh tokens (optionally with trustToken / a trusted-device-limit prompt)', content: { "application/json": { schema: { $ref: "#/components/schemas/MobileTokenResponse" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[401] = { description: 'Invalid credentials, or a TOTP code is required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many attempts (rate limited / backoff)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
@@ -36,7 +36,11 @@ mobileRouter.post(
body('password').isString().notEmpty(),
// Optional TOTP code (single-request 2FA); only checked when the account has 2FA on.
body('code').optional().isString().trim().isLength({ min: 6, max: 8 }),
- // Optional friendly device label for the Active Devices list.
+ // Optional single-use recovery code, an alternative second factor.
+ body('recoveryCode').optional({ values: 'falsy' }).isString().trim().isLength({ min: 8, max: 32 }),
+ // Optional opt-in to remember this device (skip TOTP on future logins).
+ body('trustDevice').optional().isBoolean(),
+ // Optional friendly device label for the Active Devices / Trusted Devices lists.
body('device_name').optional({ values: 'falsy' }).isString().trim().isLength({ max: 100 }),
validate,
login,
diff --git a/server/src/router/v1/auth/passwordReset.controller.js b/server/src/router/v1/auth/passwordReset.controller.js
index 7f170b7..c783848 100644
--- a/server/src/router/v1/auth/passwordReset.controller.js
+++ b/server/src/router/v1/auth/passwordReset.controller.js
@@ -18,6 +18,8 @@
const passwordResets = require('../../../model/passwordResets/passwordResets.model')
const users = require('../../../model/users/users.model')
const mobileSessions = require('../../../model/mobileSessions/mobileSessions.model')
+const trustedDevices = require('../../../model/trustedDevices/trustedDevices.model')
+const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
const activity = require('../../../model/activity/activity.model')
const mailer = require('../../../utils/mailer')
@@ -103,6 +105,10 @@ async function confirmReset(req, res) {
// Web sessions are covered by the cutoff bump; mobile bearer sessions live in
// their own table and must be revoked explicitly.
await mobileSessions.revokeAllForUser(row.user_id)
+ // A reset is a security event (often "I lost access"): drop every trusted
+ // device and recovery code so the second-factor bypass can't survive it.
+ await trustedDevices.revokeAllForUser(row.user_id)
+ await recoveryCodes.clearForUser(row.user_id)
// Retire any other outstanding links for this user (e.g. duplicate requests).
await passwordResets.invalidatePendingForUser(row.user_id)
diff --git a/server/src/router/v1/auth/trustDevice.helper.js b/server/src/router/v1/auth/trustDevice.helper.js
new file mode 100644
index 0000000..62f69b9
--- /dev/null
+++ b/server/src/router/v1/auth/trustDevice.helper.js
@@ -0,0 +1,46 @@
+// ── Shared trusted-device establishment ────────────────────────────────────
+//
+// One place that mints + persists a trusted device, enforces the per-user cap
+// (no silent pruning), and audit-logs it. Reused by every path that can create a
+// trust: web /auth/login/totp, mobile /auth/mobile/login, and the authenticated
+// self-service POST /auth/me/trusted-devices (the "revoke one, then retry" path
+// after a cap-reached prompt).
+//
+// The caller decides how the returned trust token reaches the client: the web
+// paths set the httpOnly rg_trust cookie (setTrustCookie); native paths return the
+// token in the JSON body for EncryptedSharedPreferences. This helper never touches
+// res, so it stays surface-agnostic.
+
+const trustedDevices = require('../../../model/trustedDevices/trustedDevices.model')
+const activity = require('../../../model/activity/activity.model')
+const sessionService = require('../../../auth/session.service')
+
+const log = require('../../../utils/logger')('trusted-device')
+
+// Attempt to trust the current device for `user`. Returns:
+// { ok: true, trustToken } — trusted; caller delivers the token
+// { ok: false, capReached: true, devices } — at the cap; caller prompts to revoke
+// `platform` is 'web' | 'mobile'; `deviceName` is the optional friendly label.
+async function establishTrust(req, user, { platform = 'web', deviceName = null } = {}) {
+ if (await sessionService.trustDeviceCapReached(user.id)) {
+ const devices = await trustedDevices.listActiveForUser(user.id)
+ log.info('trust refused — device cap reached', { userId: user.id, platform })
+ return { ok: false, capReached: true, devices }
+ }
+ const meta = sessionService.sessionMeta(req)
+ const out = sessionService.mintTrustToken(meta)
+ await trustedDevices.store({
+ userId: user.id,
+ tokenHash: out.trustHash,
+ platform,
+ deviceName,
+ deviceHash: out.deviceHash,
+ userAgent: out.userAgent,
+ expiresAt: out.expiresAt,
+ })
+ await activity.log({ req, userId: user.id, action: 'account.trusted_device.add', detail: { platform } })
+ log.info('device trusted', { userId: user.id, platform })
+ return { ok: true, trustToken: out.trustToken }
+}
+
+module.exports = { establishTrust }
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index 2388067..cc4f770 100644
--- a/server/swagger/swagger-output.json
+++ b/server/swagger/swagger-output.json
@@ -271,11 +271,11 @@
"tags": [
"Auth"
],
- "summary": "Complete login with a TOTP code",
- "description": "Second step for 2FA accounts. Exchange the challenge from /login plus the current authenticator code for a session cookie.",
+ "summary": "Complete login with a TOTP or recovery code",
+ "description": "Second step for 2FA accounts. Exchange the challenge from /login plus either the current authenticator code OR a single-use recovery code for a session cookie. Set trustDevice to remember this browser and skip TOTP on future logins (30 days); if the trusted-device limit is reached the session is still issued and the response carries { trustLimitReached, devices } so the user can revoke one first.",
"responses": {
"200": {
- "description": "Session issued",
+ "description": "Session issued (optionally with a trusted-device-limit prompt)",
"content": {
"application/json": {
"schema": {
@@ -718,10 +718,10 @@
"Auth · Mobile"
],
"summary": "Native login → access + refresh tokens",
- "description": "Bearer-token login for native clients. Single-request 2FA: if the account has TOTP on and no/invalid code is supplied, returns 401 { totpRequired: true } and the client retries with a code.",
+ "description": "Bearer-token login for native clients. Single-request 2FA: if the account has TOTP on and no/invalid code is supplied, returns 401 { totpRequired: true } and the client retries with a code (or a single-use recoveryCode). A previously trusted device may present the X-Trust-Token header to skip the code entirely. Set trustDevice to remember this device (the response then carries trustToken to store); if the trusted-device limit is reached the tokens are still issued and the response carries { trustLimitReached, devices }.",
"responses": {
"200": {
- "description": "Access + refresh tokens",
+ "description": "Access + refresh tokens (optionally with trustToken / a trusted-device-limit prompt)",
"content": {
"application/json": {
"schema": {
@@ -1882,6 +1882,358 @@
]
}
},
+ "/api/v1/auth/me/trusted-devices": {
+ "get": {
+ "tags": [
+ "Auth · Me"
+ ],
+ "summary": "List trusted devices (self)",
+ "description": "Active (unrevoked, unexpired) trusted devices — the browsers/apps allowed to skip the TOTP step at login. Never returns tokens.",
+ "responses": {
+ "200": {
+ "description": "Active trusted devices",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/TrustedDevice"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Not authenticated",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ },
+ "post": {
+ "tags": [
+ "Auth · Me"
+ ],
+ "summary": "Trust the current device (self)",
+ "description": "Marks the current browser/app as trusted so future logins skip the TOTP step (30 days). Web receives an httpOnly trust cookie; native to store. Returns 409 { error: \"trusted_device_limit\", devices } when the per-user cap is reached — revoke one first, then retry.",
+ "responses": {
+ "200": {
+ "description": "Device trusted",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TrustDeviceResult"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "401": {
+ "description": "Not authenticated",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden"
+ },
+ "409": {
+ "description": "Trusted-device limit reached",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TrustedDeviceLimit"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "deviceName": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "Auth · Me"
+ ],
+ "summary": "Revoke all trusted devices (self)",
+ "description": "Untrust every device; future logins on all of them require the full TOTP step again. Also clears this browser’s trust cookie.",
+ "responses": {
+ "200": {
+ "description": "Revoked count",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "revoked": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Not authenticated",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/auth/me/trusted-devices/{id}": {
+ "delete": {
+ "tags": [
+ "Auth · Me"
+ ],
+ "summary": "Revoke one trusted device (self)",
+ "description": "",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "Trusted-device id from GET /auth/me/trusted-devices."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Revoked (idempotent)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "revoked": {
+ "type": "boolean"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "401": {
+ "description": "Not authenticated",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/auth/me/account/recovery-codes/status": {
+ "get": {
+ "tags": [
+ "Auth · Me"
+ ],
+ "summary": "Remaining recovery-code count (self)",
+ "description": "",
+ "responses": {
+ "200": {
+ "description": "Remaining unused codes",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "remaining": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Not authenticated",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/auth/me/account/recovery-codes/generate": {
+ "post": {
+ "tags": [
+ "Auth · Me"
+ ],
+ "summary": "Regenerate recovery codes (self, password step-up)",
+ "description": "Generates a fresh set of single-use recovery codes, invalidating any prior set, and returns them ONCE. Requires the current password (accounts that have one); refuses when two-factor is off. Behind the login backoff/bot guards since a wrong password is credential-guessing.",
+ "responses": {
+ "200": {
+ "description": "New recovery codes (shown once)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RecoveryCodes"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Wrong password, or two-factor not enabled",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Not authenticated",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden"
+ },
+ "429": {
+ "description": "Too Many Requests"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "currentPassword": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/v1/auth/me/devices": {
"post": {
"tags": [
@@ -8650,6 +9002,330 @@
]
}
},
+ "/api/v1/admin/users/{id}/trusted-devices": {
+ "get": {
+ "tags": [
+ "Admin · Users"
+ ],
+ "summary": "List a user’s trusted devices (admin only)",
+ "description": "Active (unrevoked, unexpired) trusted devices for the target user — the browsers/apps allowed to skip that user’s TOTP step. Never returns tokens.",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "User id."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Trusted devices",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/TrustedDevice"
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "401": {
+ "description": "Not authenticated",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Admin role required",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ },
+ "delete": {
+ "tags": [
+ "Admin · Users"
+ ],
+ "summary": "Revoke all of a user’s trusted devices (admin only)",
+ "description": "",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "User id."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Revoked count",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "revoked": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "401": {
+ "description": "Not authenticated",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Admin role required",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/admin/users/{id}/trusted-devices/{deviceId}": {
+ "delete": {
+ "tags": [
+ "Admin · Users"
+ ],
+ "summary": "Revoke one of a user’s trusted devices (admin only)",
+ "description": "",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "User id."
+ },
+ {
+ "name": "deviceId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "Trusted-device id."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Revoked (idempotent)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "revoked": {
+ "type": "boolean"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "401": {
+ "description": "Not authenticated",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Admin role required",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/admin/users/{id}/mfa/reset": {
+ "post": {
+ "tags": [
+ "Admin · Users"
+ ],
+ "summary": "Reset a user’s MFA (admin only)",
+ "description": "Recovers a locked-out user: turns TOTP off, revokes every trusted device, and clears their recovery codes. The user can then sign in with their password alone and re-enroll.",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "User id."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "MFA reset",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OkFlag"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "401": {
+ "description": "Not authenticated",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Admin role required",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"/api/v1/admin/users/{id}/shard/accounts": {
"get": {
"tags": [
@@ -11119,13 +11795,16 @@
"required": {
"type": "array",
"example": [
- "challenge",
- "code"
+ "challenge"
],
"items": {
"type": "string"
}
},
+ "description": {
+ "type": "string",
+ "example": "Second step for 2FA login. Supply either code OR recoveryCode."
+ },
"properties": {
"type": "object",
"properties": {
@@ -11149,11 +11828,66 @@
"type": "string",
"example": "string"
},
+ "description": {
+ "type": "string",
+ "example": "Current authenticator code."
+ },
"example": {
"type": "string",
"example": "123456"
}
}
+ },
+ "recoveryCode": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "description": {
+ "type": "string",
+ "example": "A single-use recovery code (alternative to code)."
+ },
+ "example": {
+ "type": "string",
+ "example": "abcde-12345"
+ }
+ }
+ },
+ "trustDevice": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "description": {
+ "type": "string",
+ "example": "Remember this browser so future logins skip the TOTP step (30 days)."
+ },
+ "example": {
+ "type": "boolean",
+ "example": false
+ }
+ }
+ },
+ "deviceName": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "description": {
+ "type": "string",
+ "example": "Optional friendly label for the Trusted Devices list."
+ },
+ "example": {
+ "type": "string",
+ "example": "My Laptop"
+ }
+ }
}
}
}
@@ -11226,6 +11960,40 @@
}
}
},
+ "recoveryCode": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "description": {
+ "type": "string",
+ "example": "Single-use recovery code (alternative to code)."
+ },
+ "example": {
+ "type": "string",
+ "example": "abcde-12345"
+ }
+ }
+ },
+ "trustDevice": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "description": {
+ "type": "string",
+ "example": "Remember this device so future logins skip the TOTP step; the response then carries trustToken."
+ },
+ "example": {
+ "type": "boolean",
+ "example": false
+ }
+ }
+ },
"device_name": {
"type": "object",
"properties": {
@@ -11235,7 +12003,7 @@
},
"description": {
"type": "string",
- "example": "Optional friendly device label for Active Devices."
+ "example": "Optional friendly device label for Active/Trusted Devices."
},
"example": {
"type": "string",
@@ -11302,6 +12070,60 @@
},
"user": {
"$ref": "#/components/schemas/SafeUser"
+ },
+ "trustToken": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "description": {
+ "type": "string",
+ "example": "Present only when trustDevice was requested and accepted — store securely and send as X-Trust-Token on future logins to skip TOTP."
+ }
+ }
+ },
+ "trustLimitReached": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "description": {
+ "type": "string",
+ "example": "Present (true) when trustDevice was requested but the device cap is reached; see devices."
+ }
+ }
+ },
+ "devices": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "items": {
+ "$ref": "#/components/schemas/TrustedDevice"
+ },
+ "description": {
+ "type": "string",
+ "example": "The existing trusted devices, when trustLimitReached is set."
+ }
+ }
}
}
}
@@ -11546,6 +12368,244 @@
}
}
},
+ "TrustedDevice": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "description": {
+ "type": "string",
+ "example": "Trusted-device id (pass to DELETE …/trusted-devices/:id)."
+ }
+ }
+ },
+ "platform": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "enum": {
+ "type": "array",
+ "example": [
+ "web",
+ "mobile"
+ ],
+ "items": {
+ "type": "string"
+ }
+ },
+ "example": {
+ "type": "string",
+ "example": "web"
+ }
+ }
+ },
+ "deviceName": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "example": {
+ "type": "string",
+ "example": "My Laptop"
+ }
+ }
+ },
+ "userAgent": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "createdAt": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "format": {
+ "type": "string",
+ "example": "date-time"
+ }
+ }
+ },
+ "lastUsedAt": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "format": {
+ "type": "string",
+ "example": "date-time"
+ }
+ }
+ },
+ "expiresAt": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "format": {
+ "type": "string",
+ "example": "date-time"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "TrustDeviceResult": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "trusted": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "example": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "trustToken": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "description": {
+ "type": "string",
+ "example": "Native clients only — store securely and send as X-Trust-Token."
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "TrustedDeviceLimit": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "trusted_device_limit"
+ }
+ }
+ },
+ "devices": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "items": {
+ "$ref": "#/components/schemas/TrustedDevice"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "RecoveryCodes": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "recoveryCodes": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "abcde-12345"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"Message": {
"type": "object",
"properties": {
@@ -14847,7 +15907,7 @@
},
"description": {
"type": "string",
- "example": "Result of enabling/disabling 2FA."
+ "example": "Result of enabling/disabling 2FA. Enabling also returns the one-time recovery codes."
},
"properties": {
"type": "object",
@@ -14864,6 +15924,36 @@
"example": true
}
}
+ },
+ "recoveryCodes": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "description": {
+ "type": "string",
+ "example": "Single-use recovery codes, shown ONCE on enable."
+ },
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "abcde-12345"
+ }
+ }
+ }
+ }
}
}
}
diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js
index 4fbfde1..e49a350 100644
--- a/server/swagger/swagger.js
+++ b/server/swagger/swagger.js
@@ -145,10 +145,14 @@ const doc = {
},
TotpLoginRequest: {
type: 'object',
- required: ['challenge', 'code'],
+ required: ['challenge'],
+ description: 'Second step for 2FA login. Supply either code OR recoveryCode.',
properties: {
challenge: { type: 'string', description: 'Token returned by /login when totpRequired.' },
- code: { type: 'string', example: '123456' },
+ code: { type: 'string', description: 'Current authenticator code.', example: '123456' },
+ recoveryCode: { type: 'string', description: 'A single-use recovery code (alternative to code).', example: 'abcde-12345' },
+ trustDevice: { type: 'boolean', description: 'Remember this browser so future logins skip the TOTP step (30 days).', example: false },
+ deviceName: { type: 'string', description: 'Optional friendly label for the Trusted Devices list.', example: 'My Laptop' },
},
},
MobileLoginRequest: {
@@ -158,7 +162,9 @@ const doc = {
username: { type: 'string', example: 'admin' },
password: { type: 'string', format: 'password', example: 'super-secret' },
code: { type: 'string', description: 'TOTP code (only when 2FA is enabled).', example: '123456' },
- device_name: { type: 'string', description: 'Optional friendly device label for Active Devices.', example: 'Pixel 8' },
+ recoveryCode: { type: 'string', description: 'Single-use recovery code (alternative to code).', example: 'abcde-12345' },
+ trustDevice: { type: 'boolean', description: 'Remember this device so future logins skip the TOTP step; the response then carries trustToken.', example: false },
+ device_name: { type: 'string', description: 'Optional friendly device label for Active/Trusted Devices.', example: 'Pixel 8' },
},
},
MobileTokenResponse: {
@@ -172,6 +178,9 @@ const doc = {
example: '15m',
},
user: { $ref: '#/components/schemas/SafeUser' },
+ trustToken: { type: 'string', nullable: true, description: 'Present only when trustDevice was requested and accepted — store securely and send as X-Trust-Token on future logins to skip TOTP.' },
+ trustLimitReached: { type: 'boolean', nullable: true, description: 'Present (true) when trustDevice was requested but the device cap is reached; see devices.' },
+ devices: { type: 'array', nullable: true, items: { $ref: '#/components/schemas/TrustedDevice' }, description: 'The existing trusted devices, when trustLimitReached is set.' },
},
},
MobileRefreshRequest: {
@@ -212,6 +221,46 @@ const doc = {
expiresAt: { type: 'string', format: 'date-time' },
},
},
+ // A device allowed to skip the TOTP step at login (MFA "Trust this device").
+ // Distinct from DeviceSession (a live mobile login session). Never exposes the
+ // trust token/hash.
+ TrustedDevice: {
+ type: 'object',
+ properties: {
+ id: { type: 'integer', description: 'Trusted-device id (pass to DELETE …/trusted-devices/:id).' },
+ platform: { type: 'string', enum: ['web', 'mobile'], example: 'web' },
+ deviceName: { type: 'string', nullable: true, example: 'My Laptop' },
+ userAgent: { type: 'string', nullable: true },
+ createdAt: { type: 'string', format: 'date-time' },
+ lastUsedAt: { type: 'string', format: 'date-time' },
+ expiresAt: { type: 'string', format: 'date-time' },
+ },
+ },
+ // Result of POST /auth/me/trusted-devices. Web receives an httpOnly cookie and
+ // { trusted:true }; native (bearer) sessions additionally get { trustToken }.
+ TrustDeviceResult: {
+ type: 'object',
+ properties: {
+ trusted: { type: 'boolean', example: true },
+ trustToken: { type: 'string', nullable: true, description: 'Native clients only — store securely and send as X-Trust-Token.' },
+ },
+ },
+ // 409 body when the trusted-device cap is reached: the caller must revoke one
+ // of the listed devices before retrying.
+ TrustedDeviceLimit: {
+ type: 'object',
+ properties: {
+ error: { type: 'string', example: 'trusted_device_limit' },
+ devices: { type: 'array', items: { $ref: '#/components/schemas/TrustedDevice' } },
+ },
+ },
+ // One-time recovery (backup) codes. Returned ONLY at generation; never re-shown.
+ RecoveryCodes: {
+ type: 'object',
+ properties: {
+ recoveryCodes: { type: 'array', items: { type: 'string', example: 'abcde-12345' } },
+ },
+ },
Message: {
type: 'object',
properties: { message: { type: 'string', example: 'Logged out.' } },
@@ -638,8 +687,16 @@ const doc = {
},
TotpState: {
type: 'object',
- description: 'Result of enabling/disabling 2FA.',
- properties: { totp_enabled: { type: 'boolean', example: true } },
+ description: 'Result of enabling/disabling 2FA. Enabling also returns the one-time recovery codes.',
+ properties: {
+ totp_enabled: { type: 'boolean', example: true },
+ recoveryCodes: {
+ type: 'array',
+ nullable: true,
+ description: 'Single-use recovery codes, shown ONCE on enable.',
+ items: { type: 'string', example: 'abcde-12345' },
+ },
+ },
},
LinkedIdentity: {
type: 'object',
diff --git a/server/test/adminTrustedDevices.test.js b/server/test/adminTrustedDevices.test.js
new file mode 100644
index 0000000..1f3af0e
--- /dev/null
+++ b/server/test/adminTrustedDevices.test.js
@@ -0,0 +1,113 @@
+// Point the DB at a closed port BEFORE requiring anything that builds the pool.
+// Every model method the controller touches is stubbed, so the DB is never hit.
+process.env.DB_HOST = '127.0.0.1'
+process.env.DB_PORT = '59999'
+
+const { test, after, afterEach } = require('node:test')
+const assert = require('node:assert/strict')
+
+// Unit-test the admin trusted-device + MFA-reset handlers, plus their audit
+// logging. Invariants:
+// - a missing target user is a 404 before any mutation;
+// - revocation is ownership-scoped to the target user id;
+// - an MFA reset turns TOTP off AND clears both trusted devices and recovery
+// codes, and every admin action is audit-logged.
+const ctrl = require('../src/router/v1/admin/admin.controller')
+const users = require('../src/model/users/users.model')
+const trustedDevices = require('../src/model/trustedDevices/trustedDevices.model')
+const recoveryCodes = require('../src/model/recoveryCodes/recoveryCodes.model')
+const activity = require('../src/model/activity/activity.model')
+const db = require('../src/utils/db')
+
+after(() => db.close())
+
+function mockRes() {
+ return {
+ statusCode: 200,
+ body: null,
+ status(c) { this.statusCode = c; return this },
+ json(b) { this.body = b; return this },
+ }
+}
+
+const originals = {
+ getById: users.getById,
+ disableTotp: users.disableTotp,
+ listActiveForUser: trustedDevices.listActiveForUser,
+ revokeByIdForUser: trustedDevices.revokeByIdForUser,
+ revokeAllForUser: trustedDevices.revokeAllForUser,
+ clearForUser: recoveryCodes.clearForUser,
+ log: activity.log,
+}
+afterEach(() => { Object.assign(users, { getById: originals.getById, disableTotp: originals.disableTotp }); Object.assign(trustedDevices, { listActiveForUser: originals.listActiveForUser, revokeByIdForUser: originals.revokeByIdForUser, revokeAllForUser: originals.revokeAllForUser }); recoveryCodes.clearForUser = originals.clearForUser; activity.log = originals.log })
+
+const adminReq = (params = {}) => ({ params, user: { id: 1, role: 'admin' }, ip: '10.0.0.1', headers: {} })
+
+test('listUserTrustedDevices 404s when the target user does not exist', async () => {
+ users.getById = async () => null
+ const res = mockRes()
+ await ctrl.listUserTrustedDevices(adminReq({ id: '77' }), res)
+ assert.equal(res.statusCode, 404)
+})
+
+test('listUserTrustedDevices returns the target user’s devices without token hashes', async () => {
+ users.getById = async (id) => ({ id })
+ trustedDevices.listActiveForUser = async (id) => (id === 77 ? [{ id: 3, platform: 'web', device_name: 'Lap', user_agent: 'UA', created_at: 'c', last_used_at: 'l', expires_at: 'e', token_hash: 'SECRET' }] : [])
+ const res = mockRes()
+ await ctrl.listUserTrustedDevices(adminReq({ id: '77' }), res)
+ assert.equal(res.body.length, 1)
+ assert.equal(res.body[0].id, 3)
+ assert.equal(res.body[0].deviceName, 'Lap')
+ assert.equal(res.body[0].token_hash, undefined, 'never leak the hash')
+})
+
+test('revokeUserTrustedDevice is ownership-scoped to the target user and audit-logged', async () => {
+ users.getById = async (id) => ({ id })
+ let scoped = null
+ trustedDevices.revokeByIdForUser = async (deviceId, userId) => { scoped = { deviceId, userId }; return 1 }
+ let logged = null
+ activity.log = async (e) => { logged = e.action }
+ const res = mockRes()
+ await ctrl.revokeUserTrustedDevice(adminReq({ id: '77', deviceId: '3' }), res)
+ assert.deepEqual(scoped, { deviceId: 3, userId: 77 })
+ assert.equal(res.body.revoked, true)
+ assert.equal(logged, 'admin.trusted_device.revoke')
+})
+
+test('revokeAllUserTrustedDevices revokes for the target user and logs the count', async () => {
+ users.getById = async (id) => ({ id })
+ trustedDevices.revokeAllForUser = async () => 4
+ let logged = null
+ activity.log = async (e) => { logged = e }
+ const res = mockRes()
+ await ctrl.revokeAllUserTrustedDevices(adminReq({ id: '77' }), res)
+ assert.equal(res.body.revoked, 4)
+ assert.equal(logged.action, 'admin.trusted_device.revoke_all')
+ assert.equal(logged.detail.count, 4)
+})
+
+test('resetUserMfa disables TOTP, clears trusted devices AND recovery codes, and logs', async () => {
+ users.getById = async (id) => ({ id })
+ const calls = { disable: null, revoke: null, clear: null, log: null }
+ users.disableTotp = async (id) => { calls.disable = id }
+ trustedDevices.revokeAllForUser = async (id) => { calls.revoke = id; return 2 }
+ recoveryCodes.clearForUser = async (id) => { calls.clear = id }
+ activity.log = async (e) => { calls.log = e.action }
+ const res = mockRes()
+ await ctrl.resetUserMfa(adminReq({ id: '77' }), res)
+ assert.equal(res.body.ok, true)
+ assert.equal(calls.disable, 77)
+ assert.equal(calls.revoke, 77)
+ assert.equal(calls.clear, 77)
+ assert.equal(calls.log, 'admin.user.totp.reset')
+})
+
+test('resetUserMfa 404s (and mutates nothing) for a missing user', async () => {
+ users.getById = async () => null
+ let touched = false
+ users.disableTotp = async () => { touched = true }
+ const res = mockRes()
+ await ctrl.resetUserMfa(adminReq({ id: '999' }), res)
+ assert.equal(res.statusCode, 404)
+ assert.equal(touched, false)
+})
diff --git a/server/test/authMe.test.js b/server/test/authMe.test.js
index f834b4b..ed7a819 100644
--- a/server/test/authMe.test.js
+++ b/server/test/authMe.test.js
@@ -27,6 +27,13 @@ test('/auth/me/account* rejects unauthenticated callers with 401', async () => {
['POST', '/api/v1/auth/me/account/totp/setup'],
['POST', '/api/v1/auth/me/account/totp/enable', { code: '123456' }],
['DELETE', '/api/v1/auth/me/account/identities/google'],
+ // Trusted devices + recovery codes are self-service too — same gate.
+ ['GET', '/api/v1/auth/me/trusted-devices'],
+ ['POST', '/api/v1/auth/me/trusted-devices', { deviceName: 'X' }],
+ ['DELETE', '/api/v1/auth/me/trusted-devices'],
+ ['DELETE', '/api/v1/auth/me/trusted-devices/1'],
+ ['GET', '/api/v1/auth/me/account/recovery-codes/status'],
+ ['POST', '/api/v1/auth/me/account/recovery-codes/generate', { currentPassword: 'x' }],
]
for (const [method, path, body] of calls) {
const res = await fetch(app.url + path, {
diff --git a/server/test/authTrustedDevice.test.js b/server/test/authTrustedDevice.test.js
new file mode 100644
index 0000000..0b54144
--- /dev/null
+++ b/server/test/authTrustedDevice.test.js
@@ -0,0 +1,170 @@
+// Point the DB at a closed port BEFORE requiring the controller (its models build
+// the pool). Every collaborator is monkeypatched, so no query runs.
+process.env.DB_HOST = '127.0.0.1'
+process.env.DB_PORT = '59999'
+
+const { test, after, beforeEach, afterEach } = require('node:test')
+const assert = require('node:assert/strict')
+
+// Unit-test the trusted-device + recovery-code additions to the web auth flow:
+// - a TOTP user on a trusted device (bound to THEM) skips the second factor;
+// - a trust token bound to a DIFFERENT user is ignored (challenge as usual);
+// - loginTotp accepts a single-use recovery code as an alternative second factor;
+// - trustDevice sets the trust cookie under the cap, and surfaces a
+// { trustLimitReached, devices } prompt (session still issued) at the cap.
+const ctrl = require('../src/router/v1/auth/auth.controller')
+const users = require('../src/model/users/users.model')
+const activity = require('../src/model/activity/activity.model')
+const sessionService = require('../src/auth/session.service')
+const trustedDevices = require('../src/model/trustedDevices/trustedDevices.model')
+const recoveryCodes = require('../src/model/recoveryCodes/recoveryCodes.model')
+const totp = require('../src/utils/totp')
+const botScore = require('../src/middleware/botScore')
+const loginProtection = require('../src/middleware/loginProtection')
+const db = require('../src/utils/db')
+
+after(() => db.close())
+
+function mockRes() {
+ return {
+ statusCode: 200,
+ body: null,
+ cookies: {},
+ status(c) { this.statusCode = c; return this },
+ json(b) { this.body = b; return this },
+ cookie(name, val) { this.cookies[name] = val; return this },
+ clearCookie(name) { this.cookies[name] = undefined; return this },
+ }
+}
+
+let sessionsCreated
+const orig = {}
+beforeEach(() => {
+ sessionsCreated = []
+ for (const [mod, name] of [
+ [users, 'getRawByUsername'], [users, 'validatePassword'], [users, 'recordLogin'], [users, 'getRawById'],
+ [activity, 'log'],
+ [sessionService, 'createSession'], [sessionService, 'createPartialSession'], [sessionService, 'upgradeSessionAfterTotp'],
+ [sessionService, 'resolveTrustedDevice'], [sessionService, 'honorTrustedDevice'],
+ [sessionService, 'trustDeviceCapReached'], [sessionService, 'mintTrustToken'], [sessionService, 'sessionMeta'],
+ [trustedDevices, 'store'], [trustedDevices, 'listActiveForUser'],
+ [recoveryCodes, 'consumeForUser'],
+ [totp, 'verifyCode'],
+ [botScore, 'recordLoginFailure'], [loginProtection, 'recordFailure'], [loginProtection, 'recordSuccess'],
+ ]) {
+ orig[name] = orig[name] || { mod, val: mod[name] }
+ }
+ users.recordLogin = async () => {}
+ activity.log = async () => {}
+ botScore.recordLoginFailure = () => {}
+ loginProtection.recordFailure = () => {}
+ loginProtection.recordSuccess = () => {}
+ sessionService.createSession = (user, authMethod) => {
+ sessionsCreated.push({ user, authMethod })
+ return { token: 'session-token' }
+ }
+ sessionService.createPartialSession = () => 'challenge-jwt'
+ sessionService.honorTrustedDevice = async () => true
+ sessionService.sessionMeta = () => ({ deviceHash: 'dh', userAgent: 'UA' })
+})
+afterEach(() => {
+ for (const key of Object.keys(orig)) { orig[key].mod[key] = orig[key].val; delete orig[key] }
+})
+
+const baseReq = (body = {}) => ({ body, ip: '10.0.0.1', headers: {} })
+
+// ── login(): trusted-device skips TOTP ────────────────────────────────────
+test('login: a TOTP user on a device trusted by THEM skips the code and gets a session', async () => {
+ users.getRawByUsername = async () => ({ id: 5, username: 'safe', role: 'admin', status: 'active', totp_enabled: 1 })
+ users.validatePassword = async () => true
+ sessionService.resolveTrustedDevice = async () => ({ id: 11, user_id: 5 })
+ let honored = null
+ sessionService.honorTrustedDevice = async (id) => { honored = id }
+ const res = mockRes()
+ await ctrl.login(baseReq({ username: 'safe', password: 'right' }), res)
+ assert.equal(sessionsCreated.length, 1, 'session issued without a TOTP challenge')
+ assert.equal(sessionsCreated[0].authMethod, 'totp')
+ assert.equal(honored, 11, 'the trusted device was stamped as used')
+ assert.equal(res.body.totpRequired, undefined)
+})
+
+test('login: a trust token bound to a DIFFERENT user is ignored (challenge as usual)', async () => {
+ users.getRawByUsername = async () => ({ id: 5, username: 'safe', status: 'active', totp_enabled: 1 })
+ users.validatePassword = async () => true
+ sessionService.resolveTrustedDevice = async () => ({ id: 11, user_id: 999 }) // someone else's device
+ const res = mockRes()
+ await ctrl.login(baseReq({ username: 'safe', password: 'right' }), res)
+ assert.equal(res.body.totpRequired, true)
+ assert.equal(sessionsCreated.length, 0)
+})
+
+test('login: a trusted-device lookup error falls back to the TOTP challenge (fail closed)', async () => {
+ users.getRawByUsername = async () => ({ id: 5, username: 'safe', status: 'active', totp_enabled: 1 })
+ users.validatePassword = async () => true
+ sessionService.resolveTrustedDevice = async () => { throw new Error('store down') }
+ const res = mockRes()
+ await ctrl.login(baseReq({ username: 'safe', password: 'right' }), res)
+ assert.equal(res.body.totpRequired, true)
+ assert.equal(sessionsCreated.length, 0)
+})
+
+// ── loginTotp(): recovery code as an alternative factor ───────────────────
+test('loginTotp: a valid recovery code (no TOTP code) issues the session and is consumed', async () => {
+ sessionService.upgradeSessionAfterTotp = () => ({ id: 5 })
+ users.getRawById = async () => ({ id: 5, username: 'safe', role: 'player', totp_enabled: 1, totp_secret: 'S' })
+ totp.verifyCode = () => false
+ let consumed = null
+ recoveryCodes.consumeForUser = async (id, code) => { consumed = { id, code }; return true }
+ const res = mockRes()
+ await ctrl.loginTotp(baseReq({ challenge: 'ok', recoveryCode: 'abcde-12345' }), res)
+ assert.equal(sessionsCreated.length, 1)
+ assert.deepEqual(consumed, { id: 5, code: 'abcde-12345' })
+ assert.equal(res.body.user.id, 5)
+})
+
+test('loginTotp: neither a valid code nor a valid recovery code is a 401, no session', async () => {
+ sessionService.upgradeSessionAfterTotp = () => ({ id: 5 })
+ users.getRawById = async () => ({ id: 5, totp_enabled: 1, totp_secret: 'S' })
+ totp.verifyCode = () => false
+ recoveryCodes.consumeForUser = async () => false
+ const res = mockRes()
+ await ctrl.loginTotp(baseReq({ challenge: 'ok', recoveryCode: 'nope' }), res)
+ assert.equal(res.statusCode, 401)
+ assert.equal(sessionsCreated.length, 0)
+})
+
+// ── loginTotp(): trustDevice opt-in ───────────────────────────────────────
+test('loginTotp: trustDevice under the cap sets the trust cookie', async () => {
+ sessionService.upgradeSessionAfterTotp = () => ({ id: 5 })
+ users.getRawById = async () => ({ id: 5, username: 'safe', role: 'admin', totp_enabled: 1, totp_secret: 'S' })
+ totp.verifyCode = () => true
+ sessionService.trustDeviceCapReached = async () => false
+ sessionService.mintTrustToken = () => ({ trustToken: 'TRUST-RAW', trustHash: 'H', deviceHash: null, userAgent: null, expiresAt: new Date() })
+ let stored = null
+ trustedDevices.store = async (row) => { stored = row }
+ const res = mockRes()
+ await ctrl.loginTotp(baseReq({ challenge: 'ok', code: '654321', trustDevice: true, deviceName: 'My Laptop' }), res)
+ assert.equal(sessionsCreated.length, 1)
+ assert.equal(res.cookies.rg_trust, 'TRUST-RAW', 'the trust cookie was set')
+ assert.equal(stored.userId, 5)
+ assert.equal(stored.platform, 'web')
+ assert.equal(stored.deviceName, 'My Laptop')
+ assert.equal(res.body.trustLimitReached, undefined)
+})
+
+test('loginTotp: trustDevice at the cap still issues the session but returns the limit prompt', async () => {
+ sessionService.upgradeSessionAfterTotp = () => ({ id: 5 })
+ users.getRawById = async () => ({ id: 5, username: 'safe', role: 'admin', totp_enabled: 1, totp_secret: 'S' })
+ totp.verifyCode = () => true
+ sessionService.trustDeviceCapReached = async () => true
+ trustedDevices.listActiveForUser = async () => [{ id: 1, device_name: 'Old', created_at: 't', last_used_at: 't', expires_at: 't', platform: 'web' }]
+ let stored = false
+ trustedDevices.store = async () => { stored = true }
+ const res = mockRes()
+ await ctrl.loginTotp(baseReq({ challenge: 'ok', code: '654321', trustDevice: true }), res)
+ assert.equal(sessionsCreated.length, 1, 'login still succeeds')
+ assert.equal(res.cookies.rg_trust, undefined, 'no trust cookie at the cap')
+ assert.equal(stored, false, 'no new trust row created')
+ assert.equal(res.body.trustLimitReached, true)
+ assert.equal(res.body.devices.length, 1)
+})
diff --git a/server/test/passwordResetController.test.js b/server/test/passwordResetController.test.js
index b49108b..2af1d67 100644
--- a/server/test/passwordResetController.test.js
+++ b/server/test/passwordResetController.test.js
@@ -18,6 +18,8 @@ const ctrl = require('../src/router/v1/auth/passwordReset.controller')
const passwordResets = require('../src/model/passwordResets/passwordResets.model')
const users = require('../src/model/users/users.model')
const mobileSessions = require('../src/model/mobileSessions/mobileSessions.model')
+const trustedDevices = require('../src/model/trustedDevices/trustedDevices.model')
+const recoveryCodes = require('../src/model/recoveryCodes/recoveryCodes.model')
const activity = require('../src/model/activity/activity.model')
const mailer = require('../src/utils/mailer')
const db = require('../src/utils/db')
@@ -46,7 +48,8 @@ beforeEach(() => {
for (const [mod, name] of [
[users, 'getActiveByEmail'], [users, 'getById'], [users, 'update'],
[passwordResets, 'create'], [passwordResets, 'findValidByToken'], [passwordResets, 'consume'], [passwordResets, 'invalidatePendingForUser'],
- [mobileSessions, 'revokeAllForUser'], [activity, 'log'], [mailer, 'sendPasswordReset'],
+ [mobileSessions, 'revokeAllForUser'], [trustedDevices, 'revokeAllForUser'], [recoveryCodes, 'clearForUser'],
+ [activity, 'log'], [mailer, 'sendPasswordReset'],
]) {
orig[name] = { mod, val: mod[name] }
}
@@ -55,6 +58,8 @@ beforeEach(() => {
passwordResets.create = async () => ({ token: 'opaque-token' })
passwordResets.invalidatePendingForUser = async () => {}
mobileSessions.revokeAllForUser = async () => {}
+ trustedDevices.revokeAllForUser = async () => {}
+ recoveryCodes.clearForUser = async () => {}
})
afterEach(() => {
for (const key of Object.keys(orig)) {
diff --git a/server/test/recoveryCodes.test.js b/server/test/recoveryCodes.test.js
new file mode 100644
index 0000000..24dfde0
--- /dev/null
+++ b/server/test/recoveryCodes.test.js
@@ -0,0 +1,95 @@
+// Point the DB at a closed port BEFORE requiring the model (it builds the pool).
+// The .db layer is monkeypatched so no query runs; db.close() releases the pool.
+process.env.DB_HOST = '127.0.0.1'
+process.env.DB_PORT = '59999'
+
+const { test, after, beforeEach, afterEach } = require('node:test')
+const assert = require('node:assert/strict')
+const bcrypt = require('bcryptjs')
+
+// Unit-test the recovery-code model. Invariants a regression must not break:
+// - generation returns the requested count of human-formatted codes and REPLACES
+// any prior set (delete-then-insert), storing only bcrypt hashes;
+// - a code verifies regardless of case/dash formatting, and only once (single use);
+// - a wrong code never consumes anything.
+const model = require('../src/model/recoveryCodes/recoveryCodes.model')
+const codesDb = require('../src/model/recoveryCodes/recoveryCodes.db')
+const db = require('../src/utils/db')
+
+after(() => db.close())
+
+const orig = {}
+beforeEach(() => {
+ for (const name of ['insertMany', 'listUnusedForUser', 'countUnusedForUser', 'markUsed', 'deleteAllForUser']) {
+ orig[name] = codesDb[name]
+ }
+})
+afterEach(() => {
+ for (const name of Object.keys(orig)) codesDb[name] = orig[name]
+ for (const k of Object.keys(orig)) delete orig[k]
+})
+
+test('normalize strips separators/whitespace and uppercases', () => {
+ assert.equal(model.normalize('abcde-fghij'), 'ABCDEFGHIJ')
+ assert.equal(model.normalize(' ab cd '), 'ABCD')
+ assert.equal(model.normalize(null), '')
+})
+
+test('generateForUser returns the requested count, replaces the old set, and stores only hashes', async () => {
+ let deleted = null
+ let inserted = null
+ codesDb.deleteAllForUser = async (id) => { deleted = id }
+ codesDb.insertMany = async (id, hashes) => { inserted = { id, hashes }; return hashes.length }
+
+ const codes = await model.generateForUser(42, 5)
+
+ assert.equal(codes.length, 5)
+ assert.equal(deleted, 42, 'old codes are cleared first')
+ assert.equal(inserted.id, 42)
+ assert.equal(inserted.hashes.length, 5)
+ // Nothing stored in the clear: every persisted value is a bcrypt hash, and it
+ // is NOT the code itself.
+ for (let i = 0; i < 5; i++) {
+ assert.match(inserted.hashes[i], /^\$2[aby]\$/)
+ assert.notEqual(inserted.hashes[i], model.normalize(codes[i]))
+ }
+ // Each displayed code carries a separator for readability.
+ assert.ok(codes.every((c) => c.includes('-')))
+})
+
+test('consumeForUser accepts a formatted code once, then never again', async () => {
+ const plainCanonical = 'ABCDE23456'
+ const hash = await bcrypt.hash(plainCanonical, 10)
+ const rows = [{ id: 1, code_hash: await bcrypt.hash('OTHER12345', 10) }, { id: 2, code_hash: hash }]
+ const marked = []
+ codesDb.listUnusedForUser = async () => rows.filter((r) => !marked.includes(r.id))
+ codesDb.markUsed = async (id) => { marked.push(id); return 1 }
+
+ // Case/format-insensitive: the user may type it lowercase with a dash.
+ const ok = await model.consumeForUser(7, 'abcde-23456')
+ assert.equal(ok, true)
+ assert.deepEqual(marked, [2], 'the matching row was consumed')
+
+ // Single use: the same code no longer verifies (its row is now used).
+ const again = await model.consumeForUser(7, 'abcde-23456')
+ assert.equal(again, false)
+})
+
+test('consumeForUser returns false for a wrong code and consumes nothing', async () => {
+ const rows = [{ id: 1, code_hash: await bcrypt.hash('REALCODE99', 10) }]
+ let markedCount = 0
+ codesDb.listUnusedForUser = async () => rows
+ codesDb.markUsed = async () => { markedCount++; return 1 }
+
+ const ok = await model.consumeForUser(7, 'WRONGCODE0')
+ assert.equal(ok, false)
+ assert.equal(markedCount, 0)
+})
+
+test('consumeForUser is false for an empty input without touching the DB', async () => {
+ let listed = false
+ codesDb.listUnusedForUser = async () => { listed = true; return [] }
+ const ok = await model.consumeForUser(7, ' ')
+ assert.equal(ok, false)
+ assert.equal(listed, false)
+})
diff --git a/server/test/selfTrustedDevices.test.js b/server/test/selfTrustedDevices.test.js
new file mode 100644
index 0000000..06176f6
--- /dev/null
+++ b/server/test/selfTrustedDevices.test.js
@@ -0,0 +1,162 @@
+// Point the DB at a closed port BEFORE requiring the controller (its models build
+// the pool). Every collaborator is monkeypatched, so no query runs.
+process.env.DB_HOST = '127.0.0.1'
+process.env.DB_PORT = '59999'
+
+const { test, after, beforeEach, afterEach } = require('node:test')
+const assert = require('node:assert/strict')
+
+// Unit-test the self-service trusted-device + recovery-code account handlers.
+// Invariants:
+// - enabling TOTP hands back a one-time batch of recovery codes;
+// - disabling TOTP clears BOTH trusted devices and recovery codes;
+// - trusting the current device is ownership-scoped and honors the cap (409);
+// - self-revoke is scoped to the caller's own id;
+// - regenerating recovery codes is a password step-up (wrong password → 400).
+const ctrl = require('../src/router/v1/admin/account.controller')
+const users = require('../src/model/users/users.model')
+const activity = require('../src/model/activity/activity.model')
+const sessionService = require('../src/auth/session.service')
+const trustedDevices = require('../src/model/trustedDevices/trustedDevices.model')
+const recoveryCodes = require('../src/model/recoveryCodes/recoveryCodes.model')
+const totp = require('../src/utils/totp')
+const botScore = require('../src/middleware/botScore')
+const loginProtection = require('../src/middleware/loginProtection')
+const db = require('../src/utils/db')
+
+after(() => db.close())
+
+function mockRes() {
+ return {
+ statusCode: 200,
+ body: null,
+ cookies: {},
+ status(c) { this.statusCode = c; return this },
+ json(b) { this.body = b; return this },
+ cookie(name, val) { this.cookies[name] = val; return this },
+ clearCookie(name) { this.cookies[name] = undefined; return this },
+ }
+}
+
+const orig = {}
+beforeEach(() => {
+ for (const [mod, name] of [
+ [users, 'getRawById'], [users, 'validatePassword'], [users, 'enableTotp'], [users, 'disableTotp'], [users, 'setTotpSecret'],
+ [activity, 'log'], [totp, 'verifyCode'],
+ [sessionService, 'trustDeviceCapReached'], [sessionService, 'mintTrustToken'], [sessionService, 'sessionMeta'],
+ [trustedDevices, 'store'], [trustedDevices, 'listActiveForUser'], [trustedDevices, 'revokeByIdForUser'], [trustedDevices, 'revokeAllForUser'],
+ [recoveryCodes, 'generateForUser'], [recoveryCodes, 'clearForUser'], [recoveryCodes, 'remainingForUser'],
+ [botScore, 'recordLoginFailure'], [loginProtection, 'recordFailure'],
+ ]) {
+ orig[name] = orig[name] || { mod, val: mod[name] }
+ }
+ activity.log = async () => {}
+ botScore.recordLoginFailure = () => {}
+ loginProtection.recordFailure = () => {}
+ sessionService.sessionMeta = () => ({ deviceHash: 'dh', userAgent: 'UA' })
+})
+afterEach(() => {
+ for (const key of Object.keys(orig)) { orig[key].mod[key] = orig[key].val; delete orig[key] }
+})
+
+const req = (extra = {}) => ({ body: {}, params: {}, ip: '10.0.0.1', headers: {}, user: { id: 5, username: 'u' }, session: { authMethod: 'local' }, ...extra })
+
+// ── TOTP enable/disable ↔ recovery codes ─────────────────────────────────
+test('totpEnable returns a one-time batch of recovery codes', async () => {
+ users.getRawById = async () => ({ id: 5, username: 'u', totp_secret: 'S', totp_enabled: 0 })
+ totp.verifyCode = () => true
+ users.enableTotp = async () => {}
+ recoveryCodes.generateForUser = async () => ['aaaa-bbbb', 'cccc-dddd']
+ const res = mockRes()
+ await ctrl.totpEnable({ ...req(), body: { code: '123456' }, user: { id: 5, username: 'u', totp_enabled: 0 } }, res)
+ assert.equal(res.body.totp_enabled, true)
+ assert.deepEqual(res.body.recoveryCodes, ['aaaa-bbbb', 'cccc-dddd'])
+})
+
+test('totpDisable clears trusted devices and recovery codes', async () => {
+ users.getRawById = async () => ({ id: 5, totp_enabled: 1, totp_secret: 'S' })
+ totp.verifyCode = () => true
+ users.disableTotp = async () => {}
+ let revokedTrust = false
+ let clearedCodes = false
+ trustedDevices.revokeAllForUser = async () => { revokedTrust = true }
+ recoveryCodes.clearForUser = async () => { clearedCodes = true }
+ const res = mockRes()
+ await ctrl.totpDisable({ ...req(), body: { code: '123456' } }, res)
+ assert.equal(res.body.totp_enabled, false)
+ assert.equal(revokedTrust, true)
+ assert.equal(clearedCodes, true)
+ assert.equal(res.cookies.rg_trust, undefined, 'trust cookie cleared')
+})
+
+// ── trust current device ─────────────────────────────────────────────────
+test('trustThisDevice sets the web trust cookie under the cap', async () => {
+ sessionService.trustDeviceCapReached = async () => false
+ sessionService.mintTrustToken = () => ({ trustToken: 'RAW', trustHash: 'H', deviceHash: null, userAgent: null, expiresAt: new Date() })
+ let stored = null
+ trustedDevices.store = async (row) => { stored = row }
+ const res = mockRes()
+ await ctrl.trustThisDevice(req({ body: { deviceName: 'Desk' } }), res)
+ assert.equal(res.body.trusted, true)
+ assert.equal(res.cookies.rg_trust, 'RAW')
+ assert.equal(stored.userId, 5)
+ assert.equal(stored.platform, 'web')
+})
+
+test('trustThisDevice returns 409 with the device list at the cap', async () => {
+ sessionService.trustDeviceCapReached = async () => true
+ trustedDevices.listActiveForUser = async () => [{ id: 1, platform: 'web', device_name: 'A', created_at: 'c', last_used_at: 'l', expires_at: 'e' }]
+ const res = mockRes()
+ await ctrl.trustThisDevice(req(), res)
+ assert.equal(res.statusCode, 409)
+ assert.equal(res.body.error, 'trusted_device_limit')
+ assert.equal(res.body.devices.length, 1)
+})
+
+test('trustThisDevice on a mobile (bearer) session returns the token in the body, no cookie', async () => {
+ sessionService.trustDeviceCapReached = async () => false
+ sessionService.mintTrustToken = () => ({ trustToken: 'RAW', trustHash: 'H', deviceHash: null, userAgent: null, expiresAt: new Date() })
+ trustedDevices.store = async () => {}
+ const res = mockRes()
+ await ctrl.trustThisDevice(req({ session: { authMethod: 'mobile' } }), res)
+ assert.equal(res.body.trustToken, 'RAW')
+ assert.equal(res.cookies.rg_trust, undefined, 'native clients get no cookie')
+})
+
+// ── self-revoke ownership scoping ─────────────────────────────────────────
+test('revokeTrustedDevice is scoped to the caller’s own id', async () => {
+ let scoped = null
+ trustedDevices.revokeByIdForUser = async (id, userId) => { scoped = { id, userId }; return 1 }
+ const res = mockRes()
+ await ctrl.revokeTrustedDevice(req({ params: { id: '9' } }), res)
+ assert.deepEqual(scoped, { id: 9, userId: 5 })
+ assert.equal(res.body.revoked, true)
+})
+
+// ── recovery-code regeneration is a password step-up ──────────────────────
+test('generateRecoveryCodes rejects a wrong current password with 400 (no regeneration)', async () => {
+ users.getRawById = async () => ({ id: 5, totp_enabled: 1, password_hash: 'H' })
+ users.validatePassword = async () => false
+ let generated = false
+ recoveryCodes.generateForUser = async () => { generated = true; return [] }
+ const res = mockRes()
+ await ctrl.generateRecoveryCodes(req({ body: { currentPassword: 'wrong' } }), res)
+ assert.equal(res.statusCode, 400)
+ assert.equal(generated, false)
+})
+
+test('generateRecoveryCodes returns a fresh set when the password checks out', async () => {
+ users.getRawById = async () => ({ id: 5, totp_enabled: 1, password_hash: 'H' })
+ users.validatePassword = async () => true
+ recoveryCodes.generateForUser = async () => ['new1-new1', 'new2-new2']
+ const res = mockRes()
+ await ctrl.generateRecoveryCodes(req({ body: { currentPassword: 'right' } }), res)
+ assert.deepEqual(res.body.recoveryCodes, ['new1-new1', 'new2-new2'])
+})
+
+test('generateRecoveryCodes refuses when two-factor is off', async () => {
+ users.getRawById = async () => ({ id: 5, totp_enabled: 0, password_hash: 'H' })
+ const res = mockRes()
+ await ctrl.generateRecoveryCodes(req({ body: { currentPassword: 'right' } }), res)
+ assert.equal(res.statusCode, 400)
+})
diff --git a/server/test/trustedDevices.test.js b/server/test/trustedDevices.test.js
new file mode 100644
index 0000000..c9324fe
--- /dev/null
+++ b/server/test/trustedDevices.test.js
@@ -0,0 +1,84 @@
+// Point the DB at a closed port BEFORE requiring the modules (they build the pool).
+// The .db / model layers are monkeypatched so no query runs.
+process.env.DB_HOST = '127.0.0.1'
+process.env.DB_PORT = '59999'
+
+const { test, after, beforeEach, afterEach } = require('node:test')
+const assert = require('node:assert/strict')
+const crypto = require('crypto')
+
+// Unit-test the trusted-device seam: the session-service crypto helpers and the
+// model's cap check. Invariants:
+// - a trust token is a high-entropy opaque value hashed deterministically with
+// sha256 (so the login path can look it up by hash);
+// - resolveTrustedDevice only returns a device when a token is actually present;
+// - the per-user cap is a hard boundary (>= MAX is "at cap").
+const sessionService = require('../src/auth/session.service')
+const trustedDevices = require('../src/model/trustedDevices/trustedDevices.model')
+const devicesDb = require('../src/model/trustedDevices/trustedDevices.db')
+const db = require('../src/utils/db')
+
+after(() => db.close())
+
+test('hashTrustToken is deterministic sha256 hex', () => {
+ const h1 = sessionService.hashTrustToken('abc')
+ const h2 = sessionService.hashTrustToken('abc')
+ assert.equal(h1, h2)
+ assert.equal(h1, crypto.createHash('sha256').update('abc').digest('hex'))
+ assert.equal(h1.length, 64)
+ assert.notEqual(sessionService.hashTrustToken('abc'), sessionService.hashTrustToken('abd'))
+})
+
+test('mintTrustToken emits an opaque token, its matching hash, and a ~30d expiry', () => {
+ const now = 1_000_000_000_000
+ const out = sessionService.mintTrustToken({ deviceHash: 'dh', userAgent: 'UA' }, now)
+ assert.ok(out.trustToken.length >= 40, 'token carries real entropy')
+ assert.match(out.trustToken, /^[A-Za-z0-9_-]+$/, 'url-safe base64')
+ assert.equal(out.trustHash, sessionService.hashTrustToken(out.trustToken))
+ assert.equal(out.deviceHash, 'dh')
+ assert.equal(out.userAgent, 'UA')
+ const days = (out.expiresAt.getTime() - now) / (24 * 60 * 60 * 1000)
+ assert.equal(Math.round(days), 30)
+})
+
+// ── resolveTrustedDevice ─────────────────────────────────────────────────
+const origFind = trustedDevices.findValidByHash
+afterEach(() => { trustedDevices.findValidByHash = origFind })
+
+test('resolveTrustedDevice returns null when the request carries no trust token', async () => {
+ let looked = false
+ trustedDevices.findValidByHash = async () => { looked = true; return { id: 1 } }
+ const row = await sessionService.resolveTrustedDevice({ headers: {} })
+ assert.equal(row, null)
+ assert.equal(looked, false, 'no lookup without a token')
+})
+
+test('resolveTrustedDevice looks up by the hash of the presented cookie token', async () => {
+ let seenHash = null
+ trustedDevices.findValidByHash = async (h) => { seenHash = h; return { id: 9, user_id: 3 } }
+ const req = { headers: {}, cookies: { rg_trust: 'opaque-raw' } }
+ const row = await sessionService.resolveTrustedDevice(req)
+ assert.equal(row.id, 9)
+ assert.equal(seenHash, sessionService.hashTrustToken('opaque-raw'))
+})
+
+test('resolveTrustedDevice also accepts the X-Trust-Token header (native clients)', async () => {
+ trustedDevices.findValidByHash = async (h) => (h === sessionService.hashTrustToken('hdr-token') ? { id: 5 } : null)
+ const row = await sessionService.resolveTrustedDevice({ headers: { 'x-trust-token': 'hdr-token' } })
+ assert.equal(row.id, 5)
+})
+
+// ── cap ──────────────────────────────────────────────────────────────────
+const origCount = devicesDb.countActiveForUser
+beforeEach(() => { devicesDb.countActiveForUser = origCount })
+after(() => { devicesDb.countActiveForUser = origCount })
+
+test('isAtCap is false below the max and true at/over it', async () => {
+ const max = trustedDevices.MAX_TRUSTED_DEVICES
+ devicesDb.countActiveForUser = async () => max - 1
+ assert.equal(await trustedDevices.isAtCap(1), false)
+ devicesDb.countActiveForUser = async () => max
+ assert.equal(await trustedDevices.isAtCap(1), true)
+ devicesDb.countActiveForUser = async () => max + 3
+ assert.equal(await trustedDevices.isAtCap(1), true)
+})