// Self-service account security for the logged-in user (any role). Mounted under // the admin router (so isLoggedIn has already run and req.user is the fresh DB // row), but NOT behind the admin-only gate — editors manage their own 2FA too. 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 { 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') const totp = require('../../../utils/totp') 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, 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' }) } } // 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 = { getAccount, changeUsername, changePassword, totpSetup, totpEnable, totpDisable, listIdentities, unlinkIdentity, listSessions, revokeSession, listTrustedDevices, trustThisDevice, revokeTrustedDevice, revokeAllTrustedDevices, recoveryCodesStatus, generateRecoveryCodes, }