Makes `users.email` unique, de-duplicates the addresses an upgrade will find, and builds the self-service change-and-verify flow that did not exist. The uniqueness index is on a generated `email_norm AS (LOWER(email)) STORED` column under `utf8mb4_bin`, NOT on `email` under a `_ci` collation as the plan specified. Every case-insensitive collation this server offers is also accent-insensitive: `josé@x.com` and `jose@x.com` compare equal, and those are two different mailboxes. The plan's index would have refused the second address forever and the de-duplication would have nulled a legitimate account's. A requested address is STAGED in `email_pending` and only a tokened link installs it, so a typo cannot silently redirect account-recovery mail. `isDuplicateUsername()` now distinguishes the two indexes. All five call sites branch on it; each answers differently on purpose, because a public form, an IdP callback, a half-completed invite and an admin screen do not owe the same person the same amount of truth. SSO reads the IdP's actual `email_verified`/`verified` claim instead of inferring verification from an address merely being present. Co-Authored-By: Claude <noreply@anthropic.com>
546 lines
24 KiB
JavaScript
546 lines
24 KiB
JavaScript
// Self-service account security for the logged-in user (any role): username,
|
|
// password, TOTP, linked identities, device sessions, trusted devices and
|
|
// recovery codes.
|
|
//
|
|
// Reached through exactly one router — me.routes.js at /auth/me — which applies
|
|
// `noindex, requireAuth`, so req.user is the fresh DB row and the status +
|
|
// session-cutoff checks have already run. Every handler keys off req.user.id and
|
|
// none of them consults a role: this file lived under router/v1/admin/ while it
|
|
// also served /admin/account/* and /player/account/*, and moved here when those
|
|
// two surfaces were deleted.
|
|
|
|
const users = require('../../../model/users/users.model')
|
|
const emailVerifications = require('../../../model/emailVerifications/emailVerifications.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 { establishTrust } = require('./trustDevice.helper')
|
|
const { setAuthCookie, setTrustCookie, clearTrustCookie } = require('../../../auth/token')
|
|
const usernamePolicy = require('../../../auth/usernamePolicy')
|
|
const loginProtection = require('../../../middleware/loginProtection')
|
|
const botScore = require('../../../middleware/botScore')
|
|
const totp = require('../../../utils/totp')
|
|
const mailer = require('../../../utils/mailer')
|
|
|
|
const log = require('../../../utils/logger')('account')
|
|
|
|
// Current user's security status (does not expose the secret). has_password lets
|
|
// the player portal tell an SSO-only account (must *set* a password, no current
|
|
// one required) apart from one that already has a usable password. req.user is the
|
|
// sanitized row (password_hash stripped), so read the raw row for that one flag.
|
|
async function getAccount(req, res) {
|
|
try {
|
|
const raw = await users.getRawById(req.user.id)
|
|
return res.json({
|
|
id: req.user.id,
|
|
username: req.user.username,
|
|
role: req.user.role,
|
|
email: req.user.email || null,
|
|
email_verified: Boolean(req.user.email_verified),
|
|
// The address awaiting its link, so the screen can say "check your inbox"
|
|
// rather than looking as though the change silently failed.
|
|
email_pending: (raw && raw.email_pending) || null,
|
|
status: req.user.status || 'active',
|
|
totp_enabled: Boolean(req.user.totp_enabled),
|
|
has_password: Boolean(raw && raw.password_hash),
|
|
})
|
|
} catch (err) {
|
|
log.error('getAccount', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// Re-mint this caller's session and refresh their cookie so a self-service change
|
|
// (username/password) doesn't log them out. Returns the new Session object.
|
|
function reissueSession(req, res, user) {
|
|
const { token: sessionToken, session } = sessionService.createSession(user, req.authMethod || 'local')
|
|
setAuthCookie(req, res, sessionToken)
|
|
return session
|
|
}
|
|
|
|
// PATCH /account/username — change the caller's own username. The DB UNIQUE index
|
|
// is the source of truth for collisions (case-insensitive via the column's _ci
|
|
// collation): attempt the write and translate a duplicate-key error into 409.
|
|
async function changeUsername(req, res) {
|
|
const check = usernamePolicy.validateUsername(req.body.username)
|
|
if (!check.ok) return res.status(400).json({ message: check.message })
|
|
try {
|
|
if (check.name === req.user.username) {
|
|
return res.status(400).json({ message: 'That is already your username.' })
|
|
}
|
|
let updated
|
|
try {
|
|
updated = await users.update(req.user.id, { username: check.name })
|
|
} catch (err) {
|
|
if (users.isDuplicateUsername(err)) {
|
|
return res.status(409).json({ message: 'That username is already taken.' })
|
|
}
|
|
throw err
|
|
}
|
|
// The JWT embeds username; authz always uses the fresh DB row, but re-issue
|
|
// the cookie so nothing downstream renders a stale name. No global revocation
|
|
// — a username isn't a secret.
|
|
reissueSession(req, res, updated)
|
|
await activity.log({ req, action: 'account.username.change', detail: { username: updated.username } })
|
|
log.info('account username changed', { id: req.user.id, username: updated.username })
|
|
return res.json({ username: updated.username })
|
|
} catch (err) {
|
|
log.error('changeUsername', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// PATCH /account/password — change (or set) the caller's own password.
|
|
// • Account already has a password: require currentPassword and verify it.
|
|
// • SSO-provisioned account with a null hash: allow setting an initial password
|
|
// with no current password required.
|
|
// users.update rotates the hash and revokes existing sessions; we then re-issue
|
|
// this caller's session so their own change doesn't log them out.
|
|
async function changePassword(req, res) {
|
|
try {
|
|
const raw = await users.getRawById(req.user.id)
|
|
if (!raw) return res.status(401).json({ message: 'Unauthorized' })
|
|
|
|
if (raw.password_hash) {
|
|
const ok = await users.validatePassword(raw, req.body.currentPassword || '')
|
|
if (!ok) {
|
|
// A wrong current password is credential-guessing — trip the same
|
|
// backoff + bot scoring as a failed login.
|
|
loginProtection.recordFailure(req.ip)
|
|
botScore.recordLoginFailure(req.ip)
|
|
log.warn('changePassword wrong current password', { id: req.user.id, ip: req.ip })
|
|
return res.status(400).json({ message: 'Your current password is incorrect.' })
|
|
}
|
|
}
|
|
|
|
// Rotate the hash + revoke every existing session (users.update bumps the cutoff).
|
|
const updated = await users.update(req.user.id, { password: req.body.newPassword })
|
|
// Re-issue this caller's session, then rewind the cutoff just below the new
|
|
// token's issued-at so the inclusive cutoff test doesn't catch it (see users.db).
|
|
const session = reissueSession(req, res, updated)
|
|
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 })
|
|
} catch (err) {
|
|
log.error('changePassword', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// ── Email address (engagement Phase 1b) ────────────────────────────────────
|
|
|
|
function baseUrl() {
|
|
return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
|
|
}
|
|
|
|
function verifyUrl(token) {
|
|
return `${baseUrl()}/account/verify-email/${token}`
|
|
}
|
|
|
|
// Mint a verification and mail it. Shared by the change and resend paths so the
|
|
// quota, the supersede and the log line cannot drift between them. Returns a
|
|
// { ok } or { ok: false, status, message } the caller can hand straight back.
|
|
async function issueVerification(req, email) {
|
|
if (await emailVerifications.sendQuotaExhausted(req.user.id)) {
|
|
log.warn('email verification quota exhausted', { id: req.user.id, ip: req.ip })
|
|
return { ok: false, status: 429, message: 'Too many verification emails. Try again later.' }
|
|
}
|
|
// A fresh request supersedes every older link — otherwise an address the user
|
|
// typed by mistake stays installable for a day.
|
|
await emailVerifications.invalidatePendingForUser(req.user.id)
|
|
const { token } = await emailVerifications.create({ userId: req.user.id, email, requestedIp: req.ip })
|
|
try {
|
|
const result = await mailer.sendEmailVerification({ to: email, verifyUrl: verifyUrl(token), username: req.user.username })
|
|
if (!result.sent) {
|
|
// Unlike a password reset there is no enumeration reason to pretend: the
|
|
// caller typed this address themselves and is entitled to know why nothing
|
|
// arrived. The pending address stays staged so a later resend works.
|
|
log.warn('verification email not sent (mail not configured)', { id: req.user.id })
|
|
return { ok: true, emailed: false, reason: 'NOT_CONFIGURED' }
|
|
}
|
|
} catch (err) {
|
|
log.error('verification send failed', err)
|
|
return { ok: true, emailed: false, reason: 'SEND_FAILED' }
|
|
}
|
|
return { ok: true, emailed: true }
|
|
}
|
|
|
|
// PATCH /account/email - ask to set or change the caller's own address.
|
|
//
|
|
// The address is STAGED, not installed: `email` keeps receiving mail until the
|
|
// link is used, so a typo cannot silently redirect this account's password-reset
|
|
// mail to a mailbox its owner does not control.
|
|
//
|
|
// The current password is required when the account has one. An address is where
|
|
// account recovery lands, so repointing it is a credential-grade act; an
|
|
// SSO-provisioned account with no password hash is exempt, exactly as
|
|
// changePassword already carves out.
|
|
async function changeEmail(req, res) {
|
|
const email = String(req.body.email || '').trim()
|
|
try {
|
|
const raw = await users.getRawById(req.user.id)
|
|
if (!raw) return res.status(401).json({ message: 'Unauthorized' })
|
|
|
|
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('changeEmail wrong current password', { id: req.user.id, ip: req.ip })
|
|
return res.status(400).json({ message: 'Your current password is incorrect.' })
|
|
}
|
|
}
|
|
|
|
if (raw.email && raw.email.toLowerCase() === email.toLowerCase()) {
|
|
return res.status(400).json({ message: 'That is already your email address.' })
|
|
}
|
|
|
|
// Stage it. This is also where a collision with a live address FIRST shows up
|
|
// cheaply, but it is not the guard that matters - email_pending is deliberately
|
|
// not unique, so the real arbitration happens at verification time against the
|
|
// UNIQUE index. Answering identically in both places is what keeps this from
|
|
// becoming an address-existence oracle.
|
|
await users.setPendingEmail(req.user.id, email)
|
|
|
|
const issued = await issueVerification(req, email)
|
|
if (!issued.ok) return res.status(issued.status).json({ message: issued.message })
|
|
|
|
await activity.log({ req, action: 'account.email.change_requested' })
|
|
log.info('account email change requested', { id: req.user.id })
|
|
return res.json({ email_pending: email, emailed: Boolean(issued.emailed), reason: issued.reason || null })
|
|
} catch (err) {
|
|
log.error('changeEmail', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// POST /account/email/resend - re-send the link for the address already staged.
|
|
async function resendEmailVerification(req, res) {
|
|
try {
|
|
const raw = await users.getRawById(req.user.id)
|
|
if (!raw) return res.status(401).json({ message: 'Unauthorized' })
|
|
if (!raw.email_pending) {
|
|
return res.status(400).json({ message: 'There is no email address awaiting confirmation.' })
|
|
}
|
|
const issued = await issueVerification(req, raw.email_pending)
|
|
if (!issued.ok) return res.status(issued.status).json({ message: issued.message })
|
|
log.info('account email verification resent', { id: req.user.id })
|
|
return res.json({ email_pending: raw.email_pending, emailed: Boolean(issued.emailed), reason: issued.reason || null })
|
|
} catch (err) {
|
|
log.error('resendEmailVerification', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// DELETE /account/email/pending - abandon a staged address (a typo, or a change
|
|
// of mind). Retires the outstanding links too, so the abandoned address cannot be
|
|
// installed afterwards by a link already sitting in a mailbox.
|
|
async function cancelEmailChange(req, res) {
|
|
try {
|
|
await users.clearPendingEmail(req.user.id)
|
|
await emailVerifications.invalidatePendingForUser(req.user.id)
|
|
await activity.log({ req, action: 'account.email.change_cancelled' })
|
|
log.info('account email change cancelled', { id: req.user.id })
|
|
return res.json({ ok: true })
|
|
} catch (err) {
|
|
log.error('cancelEmailChange', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// Step 1: generate a fresh secret (stored but not yet enabled) and return the
|
|
// otpauth URL + a QR data URL for the user to scan. Overwrites any pending,
|
|
// not-yet-confirmed secret. Refuses if TOTP is already enabled.
|
|
async function totpSetup(req, res) {
|
|
try {
|
|
if (req.user.totp_enabled) {
|
|
return res.status(409).json({ message: 'Two-factor is already enabled. Disable it first to re-enroll.' })
|
|
}
|
|
const { base32, otpauthUrl } = totp.generateSecret(req.user.username)
|
|
await users.setTotpSecret(req.user.id, base32)
|
|
const qr = await totp.qrDataUrl(otpauthUrl)
|
|
log.info('totp setup started', { id: req.user.id, username: req.user.username })
|
|
return res.json({ otpauthUrl, qr })
|
|
} catch (err) {
|
|
log.error('totpSetup', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// Step 2: confirm one code against the pending secret, then flip totp_enabled on.
|
|
async function totpEnable(req, res) {
|
|
try {
|
|
const user = await users.getRawById(req.user.id)
|
|
if (!user || !user.totp_secret) {
|
|
return res.status(400).json({ message: 'Start setup before enabling two-factor.' })
|
|
}
|
|
if (user.totp_enabled) {
|
|
return res.status(409).json({ message: 'Two-factor is already enabled.' })
|
|
}
|
|
if (!totp.verifyCode(user.totp_secret, req.body.code)) {
|
|
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, recoveryCodes: codes })
|
|
} catch (err) {
|
|
log.error('totpEnable', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// Turn TOTP off. Require a current code to prove the requester still controls the
|
|
// authenticator (so a walk-up on an open session can't quietly remove 2FA).
|
|
async function totpDisable(req, res) {
|
|
try {
|
|
const user = await users.getRawById(req.user.id)
|
|
if (!user || !user.totp_enabled) {
|
|
return res.status(400).json({ message: 'Two-factor is not enabled.' })
|
|
}
|
|
if (!totp.verifyCode(user.totp_secret, req.body.code)) {
|
|
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 })
|
|
} catch (err) {
|
|
log.error('totpDisable', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// ── Linked SSO identities (self-service) ──────────────────────────────────
|
|
// List the external accounts (Google/Discord/…) linked to the current user.
|
|
// Linking itself happens via the SSO redirect flow (/auth/sso/:provider/link).
|
|
async function listIdentities(req, res) {
|
|
try {
|
|
const rows = await userIdentities.listForUser(req.user.id)
|
|
return res.json(rows.map((r) => ({ provider: r.provider, email: r.email, linked_at: r.created_at })))
|
|
} catch (err) {
|
|
log.error('listIdentities', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// Remove a linked SSO identity from the current user's account.
|
|
async function unlinkIdentity(req, res) {
|
|
const { provider } = req.params
|
|
try {
|
|
const removed = await userIdentities.unlink(req.user.id, provider)
|
|
if (!removed) return res.status(404).json({ message: 'No linked account for that provider.' })
|
|
await activity.log({ req, action: 'auth.sso.unlink', detail: { provider } })
|
|
log.info('sso identity unlinked', { provider, id: req.user.id })
|
|
return res.json({ unlinked: true })
|
|
} catch (err) {
|
|
log.error('unlinkIdentity', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// List the current user's active mobile device sessions (the "Active Devices"
|
|
// surface). Never exposes the token hash — only labels + timestamps.
|
|
async function listSessions(req, res) {
|
|
try {
|
|
const rows = await mobileSessions.listActiveForUser(req.user.id)
|
|
return res.json(
|
|
rows.map((r) => ({
|
|
id: r.id,
|
|
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,
|
|
})),
|
|
)
|
|
} catch (err) {
|
|
log.error('listSessions', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// Revoke one of the current user's mobile device sessions by id (ownership-scoped
|
|
// in the query so a user can only revoke their own). Idempotent.
|
|
async function revokeSession(req, res) {
|
|
const id = Number(req.params.id)
|
|
try {
|
|
const n = await mobileSessions.revokeByIdForUser(id, req.user.id)
|
|
if (n) {
|
|
await activity.log({ req, action: 'auth.mobile.session.revoke', detail: { sessionRowId: id } })
|
|
log.info('mobile session revoked (self)', { id, userId: req.user.id })
|
|
}
|
|
return res.json({ revoked: n > 0 })
|
|
} catch (err) {
|
|
log.error('revokeSession', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// ── 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 = {
|
|
changeEmail,
|
|
resendEmailVerification,
|
|
cancelEmailChange,
|
|
getAccount,
|
|
changeUsername,
|
|
changePassword,
|
|
totpSetup,
|
|
totpEnable,
|
|
totpDisable,
|
|
listIdentities,
|
|
unlinkIdentity,
|
|
listSessions,
|
|
revokeSession,
|
|
listTrustedDevices,
|
|
trustThisDevice,
|
|
revokeTrustedDevice,
|
|
revokeAllTrustedDevices,
|
|
recoveryCodesStatus,
|
|
generateRecoveryCodes,
|
|
}
|