Files
website/server/src/router/v1/auth/auth.controller.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

241 lines
11 KiB
JavaScript

const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const settings = require('../../../model/settings/settings.model')
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')
const usernamePolicy = require('../../../auth/usernamePolicy')
const log = require('../../../utils/logger')('auth')
// Honeypot input name — must match the hidden field rendered on the login form.
// Chosen to look like a real field so naive bots fill it; real users never see it.
const HONEYPOT_FIELD = 'company'
// One generic failure response for every "you don't get in" case (wrong user,
// wrong password, tripped honeypot). Never reveals which was wrong.
const GENERIC_FAIL = { message: 'Incorrect username or password.' }
// True when this user must complete a second factor before getting a session.
function needsTotp(user) {
return Boolean(user && user.totp_enabled)
}
// Issue the real session: create the session token via the session service, set
// 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', 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 }, ...(extra || {}) })
}
async function login(req, res) {
const { username, password } = req.body
// Honeypot: a populated hidden field means a bot. Fail generically, but score
// it hard — this is an unambiguous signal, unlike a mistyped password.
if (req.body[HONEYPOT_FIELD]) {
botScore.recordHoneypot(req.ip)
loginProtection.recordFailure(req.ip)
log.warn('honeypot login hit', { ip: req.ip, username })
return res.status(401).json(GENERIC_FAIL)
}
try {
const user = await users.getRawByUsername(username)
const ok = user && (await users.validatePassword(user, password))
if (!ok) {
botScore.recordLoginFailure(req.ip)
loginProtection.recordFailure(req.ip)
log.warn('login failed', { username, ip: req.ip })
return res.status(401).json(GENERIC_FAIL)
}
// Correct credentials, but the account is disabled/banned (or pending): do
// not issue a session or a TOTP challenge. A distinct, clear message here is
// fine — the caller already proved the password, so this leaks nothing.
if (user.status && user.status !== 'active') {
log.warn('login refused: inactive account', { username, status: user.status, ip: req.ip })
return res.status(403).json({ message: 'This account is not active. Contact an administrator.' })
}
// Password is correct. If this user has TOTP on, do NOT issue a session yet —
// 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 })
}
return issueSession(req, res, user, 'local')
} catch (err) {
log.error('login error', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// Public self-registration for a `player` account. Gated by the
// `player_registration` setting (must allow the password path) and hardened the
// same way as login: honeypot + registerLimiter + the global botScore guard.
// On success the new player is auto-logged-in (session cookie set).
async function register(req, res) {
// Honeypot: identical treatment to login — a filled hidden field is a bot.
if (req.body[HONEYPOT_FIELD]) {
botScore.recordHoneypot(req.ip)
loginProtection.recordFailure(req.ip)
log.warn('honeypot register hit', { ip: req.ip })
return res.status(400).json({ message: 'Registration failed.' })
}
try {
const mode = await settings.getRegistrationMode()
// Password self-registration is only open when the mode includes it.
if (mode !== 'password' && mode !== 'both') {
return res.status(403).json({ message: 'Registration is not open.' })
}
const check = usernamePolicy.validateUsername(req.body.username)
if (!check.ok) return res.status(400).json({ message: check.message })
const email = req.body.email ? String(req.body.email).trim() : null
let user
try {
user = await users.createUser({
username: check.name,
password: req.body.password,
email,
role: 'player',
})
} catch (err) {
// The UNIQUE index is the source of truth for the uniqueness race — a
// concurrent duplicate loses here and gets a clean 409.
if (users.isDuplicateUsername(err)) {
return res.status(409).json({ message: 'That username is already taken.' })
}
throw err
}
await activity.log({ req, userId: user.id, action: 'auth.register', detail: { username: user.username } })
log.info('player registered', { username: user.username, id: user.id, ip: req.ip })
// New password accounts never have TOTP yet — log straight in.
return issueSession(req, res, user, 'local')
} catch (err) {
log.error('register error', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// 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, 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) {
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.' })
}
// 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' })
}
}
// Clear the caller's cookie AND revoke this session server-side, so a copy of the
// token (proxy log, shared machine, XSS-exfiltrated cookie) can't keep being used
// after logout. attachSession populated req.session (best-effort) with the jti +
// expiry; if there was no valid session, there's simply nothing to revoke.
async function logout(req, res) {
clearAuthCookie(req, res)
try {
if (req.session?.sessionId) {
await sessionService.revokeSession(req.session.sessionId, {
userId: req.session.userId,
expiresAt: req.session.expiresAt,
})
await activity.log({ req, userId: req.session.userId, action: 'auth.logout' })
}
} catch (err) {
// Never fail the logout on a revocation/logging hiccup — the cookie is cleared.
log.error('logout revoke error', err)
}
return res.json({ message: 'Logged out.' })
}
async function me(req, res) {
try {
const user = await users.getById(req.user.id)
if (!user) return res.status(401).json({ message: 'Unauthorized' })
return res.json({ user })
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { login, register, loginTotp, logout, me, needsTotp, issueSession, HONEYPOT_FIELD }