Files
website/server/test/adminTrustedDevices.test.js
wtclaude 60ebacff2c
All checks were successful
PR Checks / bot-install (pull_request) Successful in 19s
PR Checks / server-tests (pull_request) Successful in 42s
PR Checks / client-build (pull_request) Successful in 9m24s
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>
2026-07-21 23:38:48 -05:00

114 lines
5.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 users 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)
})