feat(auth): trusted devices, recovery codes, and admin MFA management
Add opt-in "Trust this device" so a browser/app skips the TOTP step (never the password) for 30 days, single-use bcrypt recovery codes as a 2FA-lockout fallback, and admin trusted-device/MFA-reset management — backend, web UI, OpenAPI spec, and tests. - Schema: trusted_devices (sha256 token hash, looked up by unique index) and recovery_codes (bcrypt, single-use). Both additive/idempotent. - Session service: trust-token mint/hash/resolve + cap helpers; new rg_trust httpOnly cookie (survives logout, revoked on untrust/password change/reset/ TOTP disable). JWTs stay stateless — trust is a server-side row, not a claim. - Web + mobile login accept a trusted-device token / recovery code; login/totp gains trustDevice + recoveryCode. Cap of 10/user with NO silent pruning — an over-cap trust returns 409/trustLimitReached and the client prompts to revoke. - Self-service /auth/me/trusted-devices* + recovery-codes*; admin /admin/users/:id/trusted-devices* + /mfa/reset. All actions audit-logged. - Client: "Trust this device" + recovery-code login options, one-time recovery code display, Trusted Devices + Recovery Codes account panels, a TOTP-styled revoke-to-continue cap modal, and admin per-user security controls. - OpenAPI regenerated; 33 new server tests (all suites green). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user