Files
website/server/test/selfTrustedDevices.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

163 lines
8.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 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 callers 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)
})