req.user comes from getById which strips password_hash, so has_password was always false — the account page mis-rendered a real password account as the SSO-only 'set a password' variant (and the change-password form omitted the required current-password field). Read the raw row for that one flag. Caught by a browser click-through of the /account portal. Adds a regression test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
220 lines
9.4 KiB
JavaScript
220 lines
9.4 KiB
JavaScript
// 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 sessionService = require('../../../auth/session.service')
|
|
const { setAuthCookie } = 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))
|
|
}
|
|
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)
|
|
await activity.log({ req, action: 'account.totp.enable' })
|
|
log.info('totp enabled', { id: user.id, username: user.username })
|
|
return res.json({ totp_enabled: true })
|
|
} 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)
|
|
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' })
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
getAccount,
|
|
changeUsername,
|
|
changePassword,
|
|
totpSetup,
|
|
totpEnable,
|
|
totpDisable,
|
|
listIdentities,
|
|
unlinkIdentity,
|
|
}
|