From 60ebacff2c699094b55d37dad7b3abaa40a9cf29 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 21 Jul 2026 23:38:48 -0500 Subject: [PATCH] feat(auth): trusted devices, recovery codes, and admin MFA management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add opt-in "Trust this device" so a browser/app skips the TOTP step (never the password) for 30 days, single-use bcrypt recovery codes as a 2FA-lockout fallback, and admin trusted-device/MFA-reset management — backend, web UI, OpenAPI spec, and tests. - Schema: trusted_devices (sha256 token hash, looked up by unique index) and recovery_codes (bcrypt, single-use). Both additive/idempotent. - Session service: trust-token mint/hash/resolve + cap helpers; new rg_trust httpOnly cookie (survives logout, revoked on untrust/password change/reset/ TOTP disable). JWTs stay stateless — trust is a server-side row, not a claim. - Web + mobile login accept a trusted-device token / recovery code; login/totp gains trustDevice + recoveryCode. Cap of 10/user with NO silent pruning — an over-cap trust returns 409/trustLimitReached and the client prompts to revoke. - Self-service /auth/me/trusted-devices* + recovery-codes*; admin /admin/users/:id/trusted-devices* + /mfa/reset. All actions audit-logged. - Client: "Trust this device" + recovery-code login options, one-time recovery code display, Trusted Devices + Recovery Codes account panels, a TOTP-styled revoke-to-continue cap modal, and admin per-user security controls. - OpenAPI regenerated; 33 new server tests (all suites green). Co-Authored-By: Claude Opus 4.8 --- client/src/api/client.js | 29 +- .../security/RecoveryCodesDisplay.jsx | 62 + .../security/RecoveryCodesPanel.jsx | 86 ++ .../components/security/TrustLimitModal.jsx | 124 ++ .../security/TrustedDevicesPanel.jsx | 137 ++ client/src/contexts/AuthContext.jsx | 11 +- client/src/routes/admin/AdminLogin.jsx | 83 +- .../src/routes/admin/views/AccountAdmin.jsx | 22 +- client/src/routes/admin/views/UserDetail.jsx | 111 +- client/src/routes/player/PlayerAccount.jsx | 19 +- client/src/routes/player/PlayerLogin.jsx | 77 +- server/.env.example | 9 + server/db/schema.sql | 44 + server/src/auth/session.service.js | 64 + server/src/auth/token.js | 37 + .../model/recoveryCodes/recoveryCodes.db.js | 63 + .../recoveryCodes/recoveryCodes.model.js | 86 ++ .../model/trustedDevices/trustedDevices.db.js | 103 ++ .../trustedDevices/trustedDevices.model.js | 69 + .../src/router/v1/admin/account.controller.js | 152 ++- .../src/router/v1/admin/admin.controller.js | 88 ++ server/src/router/v1/admin/admin.routes.js | 62 + server/src/router/v1/auth/auth.controller.js | 69 +- server/src/router/v1/auth/auth.routes.js | 13 +- server/src/router/v1/auth/me.routes.js | 80 ++ .../src/router/v1/auth/mobile.controller.js | 57 +- server/src/router/v1/auth/mobile.routes.js | 10 +- .../v1/auth/passwordReset.controller.js | 6 + .../src/router/v1/auth/trustDevice.helper.js | 46 + server/swagger/swagger-output.json | 1108 ++++++++++++++++- server/swagger/swagger.js | 67 +- server/test/adminTrustedDevices.test.js | 113 ++ server/test/authMe.test.js | 7 + server/test/authTrustedDevice.test.js | 170 +++ server/test/passwordResetController.test.js | 7 +- server/test/recoveryCodes.test.js | 95 ++ server/test/selfTrustedDevices.test.js | 162 +++ server/test/trustedDevices.test.js | 84 ++ 38 files changed, 3542 insertions(+), 90 deletions(-) create mode 100644 client/src/components/security/RecoveryCodesDisplay.jsx create mode 100644 client/src/components/security/RecoveryCodesPanel.jsx create mode 100644 client/src/components/security/TrustLimitModal.jsx create mode 100644 client/src/components/security/TrustedDevicesPanel.jsx create mode 100644 server/src/model/recoveryCodes/recoveryCodes.db.js create mode 100644 server/src/model/recoveryCodes/recoveryCodes.model.js create mode 100644 server/src/model/trustedDevices/trustedDevices.db.js create mode 100644 server/src/model/trustedDevices/trustedDevices.model.js create mode 100644 server/src/router/v1/auth/trustDevice.helper.js create mode 100644 server/test/adminTrustedDevices.test.js create mode 100644 server/test/authTrustedDevice.test.js create mode 100644 server/test/recoveryCodes.test.js create mode 100644 server/test/selfTrustedDevices.test.js create mode 100644 server/test/trustedDevices.test.js 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} +
+ ))} +
+
+ + + {onDone && ( + + )} +
+
+ ) +} 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 && ( + + )} +
+ +
+
+ )} + + {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 || '—'} +
+
+ +
+ ))} + {devices.length === 0 && ( +

All devices revoked. You can trust this one now.

+ )} +
+ + {error &&

{error}

} + +
+ + +
+
+
+ ) +} + +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)} +
+
+ +
+ ))} +
+ ) : ( +

No trusted devices yet.

+ )} + +
+ + {devices.length > 0 && ( + + )} +
+ + {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() { ) : ( - + <> + + {!ssoTotp && ( + + )} + {!ssoTotp && ( + + )} + )} {(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)} +
    +
    + +
  • + ))} +
+ )} + +
+ {devices && devices.length > 0 && ( + + )} + +
+ + {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() { ) : ( - + <> + + {/* Trust-this-device only applies to real authenticator/recovery login, + not the SSO 2FA bounce (which has no trust cookie flow here). */} + {!ssoTotp && ( + + )} + {!ssoTotp && ( + + )} + )} {(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) +})