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,
|
||||
}
|
||||
|
||||
63
server/src/model/recoveryCodes/recoveryCodes.db.js
Normal file
63
server/src/model/recoveryCodes/recoveryCodes.db.js
Normal file
@@ -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,
|
||||
}
|
||||
86
server/src/model/recoveryCodes/recoveryCodes.model.js
Normal file
86
server/src/model/recoveryCodes/recoveryCodes.model.js
Normal file
@@ -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,
|
||||
}
|
||||
103
server/src/model/trustedDevices/trustedDevices.db.js
Normal file
103
server/src/model/trustedDevices/trustedDevices.db.js
Normal file
@@ -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,
|
||||
}
|
||||
69
server/src/model/trustedDevices/trustedDevices.model.js
Normal file
69
server/src/model/trustedDevices/trustedDevices.model.js
Normal file
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
46
server/src/router/v1/auth/trustDevice.helper.js
Normal file
46
server/src/router/v1/auth/trustDevice.helper.js
Normal file
@@ -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 }
|
||||
Reference in New Issue
Block a user