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:
@@ -22,6 +22,7 @@ const { requireAuth } = require('../../../auth/session.middleware')
|
||||
const noindex = require('../../../middleware/noindex')
|
||||
const validate = require('../../../middleware/validate')
|
||||
const { accountChangeLimiter } = require('../../../middleware/rateLimit')
|
||||
const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection')
|
||||
|
||||
const meRouter = express.Router()
|
||||
|
||||
@@ -164,4 +165,83 @@ meRouter.delete(
|
||||
account.revokeSession,
|
||||
)
|
||||
|
||||
// ── Trusted devices (self-service, MFA "Trust this device") ────────────────
|
||||
// Distinct from /sessions (mobile login sessions): these are the devices allowed
|
||||
// to SKIP the TOTP step at login. List, trust-current, revoke one, untrust all.
|
||||
meRouter.get(
|
||||
'/trusted-devices',
|
||||
// #swagger.tags = ['Auth · Me']
|
||||
// #swagger.summary = 'List trusted devices (self)'
|
||||
// #swagger.description = 'Active (unrevoked, unexpired) trusted devices — the browsers/apps allowed to skip the TOTP step at login. Never returns tokens.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Active 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" } } } } */
|
||||
account.listTrustedDevices,
|
||||
)
|
||||
meRouter.post(
|
||||
'/trusted-devices',
|
||||
// #swagger.tags = ['Auth · Me']
|
||||
// #swagger.summary = 'Trust the current device (self)'
|
||||
// #swagger.description = 'Marks the current browser/app as trusted so future logins skip the TOTP step (30 days). Web receives an httpOnly trust cookie; native (bearer) sessions receive { trustToken } to store. Returns 409 { error: "trusted_device_limit", devices } when the per-user cap is reached — revoke one first, then retry.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", properties: { deviceName: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Device trusted', content: { "application/json": { schema: { $ref: "#/components/schemas/TrustDeviceResult" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Trusted-device limit reached', content: { "application/json": { schema: { $ref: "#/components/schemas/TrustedDeviceLimit" } } } } */
|
||||
accountChangeLimiter,
|
||||
body('deviceName').optional({ values: 'falsy' }).isString().trim().isLength({ max: 100 }),
|
||||
validate,
|
||||
account.trustThisDevice,
|
||||
)
|
||||
meRouter.delete(
|
||||
'/trusted-devices',
|
||||
// #swagger.tags = ['Auth · Me']
|
||||
// #swagger.summary = 'Revoke all trusted devices (self)'
|
||||
// #swagger.description = 'Untrust every device; future logins on all of them require the full TOTP step again. Also clears this browser’s trust cookie.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #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" } } } } */
|
||||
account.revokeAllTrustedDevices,
|
||||
)
|
||||
meRouter.delete(
|
||||
'/trusted-devices/:id',
|
||||
// #swagger.tags = ['Auth · Me']
|
||||
// #swagger.summary = 'Revoke one trusted device (self)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Trusted-device id from GET /auth/me/trusted-devices.' }
|
||||
/* #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" } } } } */
|
||||
param('id').isInt({ min: 1 }),
|
||||
validate,
|
||||
account.revokeTrustedDevice,
|
||||
)
|
||||
|
||||
// ── Recovery (backup) codes (self-service) ─────────────────────────────────
|
||||
meRouter.get(
|
||||
'/account/recovery-codes/status',
|
||||
// #swagger.tags = ['Auth · Me']
|
||||
// #swagger.summary = 'Remaining recovery-code count (self)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Remaining unused codes', content: { "application/json": { schema: { type: "object", properties: { remaining: { type: "integer" } } } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
account.recoveryCodesStatus,
|
||||
)
|
||||
meRouter.post(
|
||||
'/account/recovery-codes/generate',
|
||||
// #swagger.tags = ['Auth · Me']
|
||||
// #swagger.summary = 'Regenerate recovery codes (self, password step-up)'
|
||||
// #swagger.description = 'Generates a fresh set of single-use recovery codes, invalidating any prior set, and returns them ONCE. Requires the current password (accounts that have one); refuses when two-factor is off. Behind the login backoff/bot guards since a wrong password is credential-guessing.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", properties: { currentPassword: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'New recovery codes (shown once)', content: { "application/json": { schema: { $ref: "#/components/schemas/RecoveryCodes" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Wrong password, or two-factor not enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
backoffGuard,
|
||||
slowLogin,
|
||||
accountChangeLimiter,
|
||||
body('currentPassword').optional({ values: 'falsy' }).isString(),
|
||||
validate,
|
||||
account.generateRecoveryCodes,
|
||||
)
|
||||
|
||||
module.exports = meRouter
|
||||
|
||||
Reference in New Issue
Block a user