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

197 lines
8.0 KiB
JavaScript

// Point the DB at a closed port BEFORE requiring the controller (its models build
// the pool). Every model/mailer call is monkeypatched, so no query runs;
// db.close() at the end releases the pool so the process exits cleanly.
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 password reset controller. The security invariants:
// - requestReset NEVER reveals whether an email exists — empty, unmatched,
// matched, and even an internal error all return the same generic 200;
// - one account's mail failure does not abort the others or change the answer;
// - confirmReset consumes the token atomically (a lost double-submit race is a
// 404) and, on success, rotates the password AND revokes mobile sessions,
// without auto-logging the user in.
const ctrl = require('../src/router/v1/auth/passwordReset.controller')
const passwordResets = require('../src/model/passwordResets/passwordResets.model')
const users = require('../src/model/users/users.model')
const mobileSessions = require('../src/model/mobileSessions/mobileSessions.model')
const trustedDevices = require('../src/model/trustedDevices/trustedDevices.model')
const recoveryCodes = require('../src/model/recoveryCodes/recoveryCodes.model')
const activity = require('../src/model/activity/activity.model')
const mailer = require('../src/utils/mailer')
const db = require('../src/utils/db')
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 GENERIC_MATCH = /if an account exists/i
const orig = {}
beforeEach(() => {
for (const [mod, name] of [
[users, 'getActiveByEmail'], [users, 'getById'], [users, 'update'],
[passwordResets, 'create'], [passwordResets, 'findValidByToken'], [passwordResets, 'consume'], [passwordResets, 'invalidatePendingForUser'],
[mobileSessions, 'revokeAllForUser'], [trustedDevices, 'revokeAllForUser'], [recoveryCodes, 'clearForUser'],
[activity, 'log'], [mailer, 'sendPasswordReset'],
]) {
orig[name] = { mod, val: mod[name] }
}
activity.log = async () => {}
mailer.sendPasswordReset = async () => ({ sent: true })
passwordResets.create = async () => ({ token: 'opaque-token' })
passwordResets.invalidatePendingForUser = async () => {}
mobileSessions.revokeAllForUser = async () => {}
trustedDevices.revokeAllForUser = async () => {}
recoveryCodes.clearForUser = async () => {}
})
afterEach(() => {
for (const key of Object.keys(orig)) {
orig[key].mod[key] = orig[key].val
delete orig[key]
}
})
const req = (body = {}) => ({ body, ip: '10.0.0.1' })
// ── requestReset never enumerates ───────────────────────────────────────
test('requestReset returns the generic OK for an empty email without any lookup', async () => {
let lookedUp = false
users.getActiveByEmail = async () => {
lookedUp = true
return []
}
const res = mockRes()
await ctrl.requestReset(req({ email: ' ' }), res)
assert.equal(res.statusCode, 200)
assert.match(res.body.message, GENERIC_MATCH)
assert.equal(lookedUp, false, 'a blank email is short-circuited before the DB')
})
test('requestReset returns the SAME generic OK whether or not the email matched', async () => {
users.getActiveByEmail = async () => [] // no account
const resNone = mockRes()
await ctrl.requestReset(req({ email: 'ghost@x.io' }), resNone)
users.getActiveByEmail = async () => [{ id: 1, email: 'real@x.io', username: 'real' }]
const resHit = mockRes()
await ctrl.requestReset(req({ email: 'real@x.io' }), resHit)
assert.deepEqual(resNone.body, resHit.body) // indistinguishable
assert.equal(resHit.statusCode, 200)
})
test('requestReset emails every account matching a (non-unique) address', async () => {
users.getActiveByEmail = async () => [
{ id: 1, email: 'shared@x.io', username: 'alpha' },
{ id: 2, email: 'shared@x.io', username: 'beta' },
]
const sent = []
mailer.sendPasswordReset = async ({ username }) => {
sent.push(username)
return { sent: true }
}
await ctrl.requestReset(req({ email: 'shared@x.io' }), mockRes())
assert.deepEqual(sent.sort(), ['alpha', 'beta'])
})
test("requestReset: one account's mail failure does not abort the others or change the response", async () => {
users.getActiveByEmail = async () => [
{ id: 1, email: 'a@x.io', username: 'alpha' },
{ id: 2, email: 'b@x.io', username: 'beta' },
]
const sent = []
mailer.sendPasswordReset = async ({ username }) => {
if (username === 'alpha') throw new Error('smtp reject')
sent.push(username)
return { sent: true }
}
const res = mockRes()
await ctrl.requestReset(req({ email: 'a@x.io' }), res)
assert.deepEqual(sent, ['beta'], 'beta still got its link after alpha failed')
assert.match(res.body.message, GENERIC_MATCH)
})
test('requestReset stays generic even when the account lookup itself throws', async () => {
users.getActiveByEmail = async () => {
throw new Error('pool down')
}
const res = mockRes()
await ctrl.requestReset(req({ email: 'x@x.io' }), res)
assert.equal(res.statusCode, 200) // internal error is not an enumeration oracle
assert.match(res.body.message, GENERIC_MATCH)
})
// ── lookupReset ─────────────────────────────────────────────────────────
test('lookupReset 404s an invalid/expired token and returns only the username on success', async () => {
passwordResets.findValidByToken = async () => null
const res404 = mockRes()
await ctrl.lookupReset({ params: { token: 'bad' } }, res404)
assert.equal(res404.statusCode, 404)
passwordResets.findValidByToken = async () => ({ user_id: 7 })
users.getById = async () => ({ id: 7, username: 'target', email: 'secret@x.io' })
const resOk = mockRes()
await ctrl.lookupReset({ params: { token: 'good' } }, resOk)
assert.deepEqual(resOk.body, { username: 'target' }) // email/token never surfaced
})
// ── confirmReset consume race + revoke-everywhere ───────────────────────
test('confirmReset 404s when the token is not valid', async () => {
passwordResets.findValidByToken = async () => null
const res = mockRes()
await ctrl.confirmReset({ params: { token: 'bad' }, body: { password: 'new' } }, res)
assert.equal(res.statusCode, 404)
})
test('confirmReset 404s the loser of a double-submit race and never rotates the password', async () => {
passwordResets.findValidByToken = async () => ({ id: 3, user_id: 7 })
passwordResets.consume = async () => false // lost the race
let rotated = false
users.update = async () => {
rotated = true
}
const res = mockRes()
await ctrl.confirmReset({ params: { token: 't' }, body: { password: 'new' } }, res)
assert.equal(res.statusCode, 404)
assert.equal(rotated, false)
})
test('confirmReset rotates the password, revokes mobile sessions, and retires other links — no auto-login', async () => {
passwordResets.findValidByToken = async () => ({ id: 3, user_id: 7 })
passwordResets.consume = async () => true
const calls = { update: null, revoke: null, invalidate: null }
users.update = async (id, patch) => {
calls.update = { id, patch }
}
mobileSessions.revokeAllForUser = async (id) => {
calls.revoke = id
}
passwordResets.invalidatePendingForUser = async (id) => {
calls.invalidate = id
}
const res = mockRes()
await ctrl.confirmReset({ params: { token: 't' }, body: { password: 'brand-new' } }, res)
assert.deepEqual(calls.update, { id: 7, patch: { password: 'brand-new' } })
assert.equal(calls.revoke, 7)
assert.equal(calls.invalidate, 7)
assert.equal(res.body.ok, true)
assert.equal(res.body.user, undefined, 'no session/user is returned — the user signs in fresh')
})