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:
@@ -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' })
|
||||
|
||||
Reference in New Issue
Block a user