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:
@@ -6,8 +6,11 @@ const users = require('../../../model/users/users.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const userIdentities = require('../../../model/userIdentities/userIdentities.model')
|
||||
const mobileSessions = require('../../../model/mobileSessions/mobileSessions.model')
|
||||
const trustedDevices = require('../../../model/trustedDevices/trustedDevices.model')
|
||||
const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
|
||||
const sessionService = require('../../../auth/session.service')
|
||||
const { setAuthCookie } = require('../../../auth/token')
|
||||
const { establishTrust } = require('../auth/trustDevice.helper')
|
||||
const { setAuthCookie, setTrustCookie, clearTrustCookie } = require('../../../auth/token')
|
||||
const usernamePolicy = require('../../../auth/usernamePolicy')
|
||||
const loginProtection = require('../../../middleware/loginProtection')
|
||||
const botScore = require('../../../middleware/botScore')
|
||||
@@ -108,6 +111,12 @@ async function changePassword(req, res) {
|
||||
if (session && session.createdAt) {
|
||||
await users.setSessionCutoff(req.user.id, new Date(session.createdAt - 1000))
|
||||
}
|
||||
// A password change is a security event: drop every trusted device and every
|
||||
// recovery code so a compromised-then-changed account can't be re-entered with
|
||||
// a stale second-factor bypass. Clear this browser's trust cookie too.
|
||||
await trustedDevices.revokeAllForUser(req.user.id)
|
||||
await recoveryCodes.clearForUser(req.user.id)
|
||||
clearTrustCookie(req, res)
|
||||
await activity.log({ req, action: 'account.password.change' })
|
||||
log.info('account password changed', { id: req.user.id })
|
||||
return res.json({ ok: true })
|
||||
@@ -150,9 +159,14 @@ async function totpEnable(req, res) {
|
||||
return res.status(400).json({ message: 'That code is not valid. Try again.' })
|
||||
}
|
||||
await users.enableTotp(user.id)
|
||||
// Issue the initial batch of single-use recovery codes, shown to the user ONCE
|
||||
// right here (the only time they leave the server in the clear). Generation
|
||||
// replaces any prior set, so re-enrolling always starts clean.
|
||||
const codes = await recoveryCodes.generateForUser(user.id)
|
||||
await activity.log({ req, action: 'account.totp.enable' })
|
||||
await activity.log({ req, action: 'account.recovery_codes.generate', detail: { count: codes.length } })
|
||||
log.info('totp enabled', { id: user.id, username: user.username })
|
||||
return res.json({ totp_enabled: true })
|
||||
return res.json({ totp_enabled: true, recoveryCodes: codes })
|
||||
} catch (err) {
|
||||
log.error('totpEnable', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
@@ -171,6 +185,11 @@ async function totpDisable(req, res) {
|
||||
return res.status(400).json({ message: 'That code is not valid. Try again.' })
|
||||
}
|
||||
await users.disableTotp(user.id)
|
||||
// With 2FA off, both the trusted-device bypass and recovery codes are moot and
|
||||
// must not linger — drop them so re-enabling later starts from a clean slate.
|
||||
await trustedDevices.revokeAllForUser(user.id)
|
||||
await recoveryCodes.clearForUser(user.id)
|
||||
clearTrustCookie(req, res)
|
||||
await activity.log({ req, action: 'account.totp.disable' })
|
||||
log.info('totp disabled', { id: user.id, username: user.username })
|
||||
return res.json({ totp_enabled: false })
|
||||
@@ -246,6 +265,129 @@ async function revokeSession(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Trusted devices (self-service) ─────────────────────────────────────────
|
||||
// Shape a trusted_devices row for the client (never the token hash).
|
||||
function toTrustedDevice(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,
|
||||
}
|
||||
}
|
||||
|
||||
// List the current user's active trusted devices (Trusted Devices screen).
|
||||
async function listTrustedDevices(req, res) {
|
||||
try {
|
||||
const rows = await trustedDevices.listActiveForUser(req.user.id)
|
||||
return res.json(rows.map(toTrustedDevice))
|
||||
} catch (err) {
|
||||
log.error('listTrustedDevices', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Trust the CURRENT device/browser from an authenticated session. This is the
|
||||
// "revoke one, then retry" completion after a cap-reached prompt, and a general
|
||||
// self-service way to trust the device you're on. Web receives the token as the
|
||||
// httpOnly rg_trust cookie; native (bearer) sessions get it in the JSON body.
|
||||
async function trustThisDevice(req, res) {
|
||||
try {
|
||||
const isMobile = (req.session?.authMethod || req.authMethod) === 'mobile'
|
||||
const result = await establishTrust(req, req.user, {
|
||||
platform: isMobile ? 'mobile' : 'web',
|
||||
deviceName: req.body.deviceName || null,
|
||||
})
|
||||
if (!result.ok && result.capReached) {
|
||||
return res.status(409).json({ error: 'trusted_device_limit', devices: result.devices.map(toTrustedDevice) })
|
||||
}
|
||||
if (!isMobile) {
|
||||
setTrustCookie(req, res, result.trustToken)
|
||||
return res.json({ trusted: true })
|
||||
}
|
||||
return res.json({ trusted: true, trustToken: result.trustToken })
|
||||
} catch (err) {
|
||||
log.error('trustThisDevice', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Revoke one of the current user's trusted devices by id (ownership-scoped).
|
||||
async function revokeTrustedDevice(req, res) {
|
||||
const id = Number(req.params.id)
|
||||
try {
|
||||
const n = await trustedDevices.revokeByIdForUser(id, req.user.id)
|
||||
if (n) {
|
||||
await activity.log({ req, action: 'account.trusted_device.revoke', detail: { deviceId: id } })
|
||||
log.info('trusted device revoked (self)', { id, userId: req.user.id })
|
||||
}
|
||||
return res.json({ revoked: n > 0 })
|
||||
} catch (err) {
|
||||
log.error('revokeTrustedDevice', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Revoke ALL of the current user's trusted devices ("untrust everywhere"), and
|
||||
// clear this browser's trust cookie.
|
||||
async function revokeAllTrustedDevices(req, res) {
|
||||
try {
|
||||
const n = await trustedDevices.revokeAllForUser(req.user.id)
|
||||
clearTrustCookie(req, res)
|
||||
await activity.log({ req, action: 'account.trusted_device.revoke_all', detail: { count: n } })
|
||||
log.info('all trusted devices revoked (self)', { userId: req.user.id, count: n })
|
||||
return res.json({ revoked: n })
|
||||
} catch (err) {
|
||||
log.error('revokeAllTrustedDevices', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Recovery codes (self-service) ──────────────────────────────────────────
|
||||
// Remaining (unused) code count — never the codes themselves.
|
||||
async function recoveryCodesStatus(req, res) {
|
||||
try {
|
||||
const remaining = await recoveryCodes.remainingForUser(req.user.id)
|
||||
return res.json({ remaining })
|
||||
} catch (err) {
|
||||
log.error('recoveryCodesStatus', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Regenerate the recovery-code set, returning the new codes ONCE. Password
|
||||
// step-up: an account that has a password must supply and match currentPassword
|
||||
// (SSO-only accounts with no password may proceed while authenticated, mirroring
|
||||
// changePassword). Refuses when 2FA is off (codes only exist alongside TOTP).
|
||||
async function generateRecoveryCodes(req, res) {
|
||||
try {
|
||||
const raw = await users.getRawById(req.user.id)
|
||||
if (!raw) return res.status(401).json({ message: 'Unauthorized' })
|
||||
if (!raw.totp_enabled) {
|
||||
return res.status(400).json({ message: 'Enable two-factor before generating recovery codes.' })
|
||||
}
|
||||
if (raw.password_hash) {
|
||||
const ok = await users.validatePassword(raw, req.body.currentPassword || '')
|
||||
if (!ok) {
|
||||
loginProtection.recordFailure(req.ip)
|
||||
botScore.recordLoginFailure(req.ip)
|
||||
log.warn('generateRecoveryCodes wrong current password', { id: req.user.id, ip: req.ip })
|
||||
return res.status(400).json({ message: 'Your current password is incorrect.' })
|
||||
}
|
||||
}
|
||||
const codes = await recoveryCodes.generateForUser(req.user.id)
|
||||
await activity.log({ req, action: 'account.recovery_codes.generate', detail: { count: codes.length } })
|
||||
log.info('recovery codes regenerated', { id: req.user.id })
|
||||
return res.json({ recoveryCodes: codes })
|
||||
} catch (err) {
|
||||
log.error('generateRecoveryCodes', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAccount,
|
||||
changeUsername,
|
||||
@@ -257,4 +399,10 @@ module.exports = {
|
||||
unlinkIdentity,
|
||||
listSessions,
|
||||
revokeSession,
|
||||
listTrustedDevices,
|
||||
trustThisDevice,
|
||||
revokeTrustedDevice,
|
||||
revokeAllTrustedDevices,
|
||||
recoveryCodesStatus,
|
||||
generateRecoveryCodes,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -1283,6 +1283,68 @@ adminRouter.delete(
|
||||
ctrl.deleteUser,
|
||||
)
|
||||
|
||||
// ── A user's trusted devices & MFA (admin only) ───────────────────────
|
||||
adminRouter.get(
|
||||
'/users/:id/trusted-devices',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'List a user’s trusted devices (admin only)'
|
||||
// #swagger.description = 'Active (unrevoked, unexpired) trusted devices for the target user — the browsers/apps allowed to skip that user’s TOTP step. Never returns tokens.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Trusted devices', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/TrustedDevice" } } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
ctrl.listUserTrustedDevices,
|
||||
)
|
||||
adminRouter.delete(
|
||||
'/users/:id/trusted-devices',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Revoke all of a user’s trusted devices (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Revoked count', content: { "application/json": { schema: { type: "object", properties: { revoked: { type: "integer" } } } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
ctrl.revokeAllUserTrustedDevices,
|
||||
)
|
||||
adminRouter.delete(
|
||||
'/users/:id/trusted-devices/:deviceId',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Revoke one of a user’s trusted devices (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
// #swagger.parameters['deviceId'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Trusted-device id.' }
|
||||
/* #swagger.responses[200] = { description: 'Revoked (idempotent)', content: { "application/json": { schema: { type: "object", properties: { revoked: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
param('deviceId').isInt({ min: 1 }),
|
||||
validate,
|
||||
ctrl.revokeUserTrustedDevice,
|
||||
)
|
||||
adminRouter.post(
|
||||
'/users/:id/mfa/reset',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Reset a user’s MFA (admin only)'
|
||||
// #swagger.description = 'Recovers a locked-out user: turns TOTP off, revokes every trusted device, and clears their recovery codes. The user can then sign in with their password alone and re-enroll.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'MFA reset', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
ctrl.resetUserMfa,
|
||||
)
|
||||
|
||||
// ── User → shard (uo-link) footprint (admin only) ─────────────────────
|
||||
// Backs the /admin/users/:id detail page: a user's linked game accounts and,
|
||||
// scoped to those accounts, their vendor sales / houses / online characters.
|
||||
|
||||
Reference in New Issue
Block a user