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:
@@ -3,6 +3,8 @@ const wiki = require('../../../model/wiki/wiki.model')
|
||||
const settings = require('../../../model/settings/settings.model')
|
||||
const users = require('../../../model/users/users.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const trustedDevices = require('../../../model/trustedDevices/trustedDevices.model')
|
||||
const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
|
||||
const announceJobs = require('../../../model/announceJobs/announceJobs.model')
|
||||
const newsGump = require('../../../utils/newsGump')
|
||||
const pushDispatch = require('../../../utils/pushDispatch')
|
||||
@@ -651,6 +653,88 @@ async function deleteUser(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin: a user's trusted devices & MFA (admin only) ─────────────────────
|
||||
// Staff-facing view/revocation of another user's trusted devices, plus an MFA
|
||||
// reset for a locked-out user. All actions are audit-logged with the acting admin
|
||||
// (via activity.log's req) and the target user id.
|
||||
function toAdminTrustedDevice(r) {
|
||||
return {
|
||||
id: r.id,
|
||||
platform: r.platform,
|
||||
deviceName: r.device_name || null,
|
||||
userAgent: r.user_agent || null,
|
||||
createdAt: r.created_at,
|
||||
lastUsedAt: r.last_used_at || r.created_at,
|
||||
expiresAt: r.expires_at,
|
||||
}
|
||||
}
|
||||
|
||||
async function listUserTrustedDevices(req, res) {
|
||||
const id = Number(req.params.id)
|
||||
try {
|
||||
const target = await users.getById(id)
|
||||
if (!target) return res.status(404).json({ message: 'Not found' })
|
||||
const rows = await trustedDevices.listActiveForUser(id)
|
||||
return res.json(rows.map(toAdminTrustedDevice))
|
||||
} catch (err) {
|
||||
log.error('listUserTrustedDevices', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeUserTrustedDevice(req, res) {
|
||||
const id = Number(req.params.id)
|
||||
const deviceId = Number(req.params.deviceId)
|
||||
try {
|
||||
const target = await users.getById(id)
|
||||
if (!target) return res.status(404).json({ message: 'Not found' })
|
||||
const n = await trustedDevices.revokeByIdForUser(deviceId, id)
|
||||
if (n) {
|
||||
await activity.log({ req, action: 'admin.trusted_device.revoke', detail: { userId: id, deviceId } })
|
||||
log.info('admin revoked trusted device', { adminId: req.user.id, userId: id, deviceId })
|
||||
}
|
||||
return res.json({ revoked: n > 0 })
|
||||
} catch (err) {
|
||||
log.error('revokeUserTrustedDevice', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeAllUserTrustedDevices(req, res) {
|
||||
const id = Number(req.params.id)
|
||||
try {
|
||||
const target = await users.getById(id)
|
||||
if (!target) return res.status(404).json({ message: 'Not found' })
|
||||
const n = await trustedDevices.revokeAllForUser(id)
|
||||
await activity.log({ req, action: 'admin.trusted_device.revoke_all', detail: { userId: id, count: n } })
|
||||
log.info('admin revoked all trusted devices', { adminId: req.user.id, userId: id, count: n })
|
||||
return res.json({ revoked: n })
|
||||
} catch (err) {
|
||||
log.error('revokeAllUserTrustedDevices', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Reset a locked-out user's MFA: turn TOTP off, drop every trusted device, and
|
||||
// clear their recovery codes. Lets an admin recover a user who lost their
|
||||
// authenticator; the user can then sign in with their password alone and re-enroll.
|
||||
async function resetUserMfa(req, res) {
|
||||
const id = Number(req.params.id)
|
||||
try {
|
||||
const target = await users.getById(id)
|
||||
if (!target) return res.status(404).json({ message: 'Not found' })
|
||||
await users.disableTotp(id)
|
||||
await trustedDevices.revokeAllForUser(id)
|
||||
await recoveryCodes.clearForUser(id)
|
||||
await activity.log({ req, action: 'admin.user.totp.reset', detail: { userId: id } })
|
||||
log.info('admin reset user MFA', { adminId: req.user.id, userId: id })
|
||||
return res.json({ ok: true })
|
||||
} catch (err) {
|
||||
log.error('resetUserMfa', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
dashboard,
|
||||
setSiteMode,
|
||||
@@ -685,4 +769,8 @@ module.exports = {
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
listUserTrustedDevices,
|
||||
revokeUserTrustedDevice,
|
||||
revokeAllUserTrustedDevices,
|
||||
resetUserMfa,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user