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>
101 lines
4.8 KiB
JavaScript
101 lines
4.8 KiB
JavaScript
// ── Email-address verification (public, token-gated) ───────────────────────
|
|
//
|
|
// The confirm half of the Phase 1b change-and-verify flow. The request half is
|
|
// authenticated and lives on /auth/me/account/email; this half is deliberately
|
|
// NOT, because the link is opened from a mailbox, routinely on a device that is
|
|
// not logged in — requiring a session here would strand exactly the users the
|
|
// flow exists to serve.
|
|
//
|
|
// That is safe because the token IS the proof: it is opaque, single-use,
|
|
// short-lived, stored only as a sha256, and it carries the user and the address
|
|
// it was minted for. Using it installs an address on that account and does
|
|
// nothing else — it grants no session, no access, and no way to read anything.
|
|
// Compare passwordReset.controller, which is the same posture for a strictly
|
|
// more powerful capability.
|
|
//
|
|
// GET /auth/email/verify/:token -> validate the link so the page can render
|
|
// POST /auth/email/verify/:token -> install the address
|
|
|
|
const emailVerifications = require('../../../model/emailVerifications/emailVerifications.model')
|
|
const users = require('../../../model/users/users.model')
|
|
const activity = require('../../../model/activity/activity.model')
|
|
|
|
const log = require('../../../utils/logger')('auth-email-verify')
|
|
|
|
const INVALID = 'This confirmation link is invalid or has expired.'
|
|
|
|
// GET /auth/email/verify/:token — validate a link so the page can render. 404 for
|
|
// anything not currently usable, never distinguishing expired from used from
|
|
// never-was.
|
|
async function lookup(req, res) {
|
|
try {
|
|
const row = await emailVerifications.findValidByToken(req.params.token)
|
|
if (!row) return res.status(404).json({ message: INVALID })
|
|
const user = await users.getById(row.user_id)
|
|
if (!user) return res.status(404).json({ message: INVALID })
|
|
// The address is echoed because the person holding this link is the person it
|
|
// was mailed to — they already know it. The username tells them which account
|
|
// they are about to attach it to.
|
|
return res.json({ username: user.username, email: row.email })
|
|
} catch (err) {
|
|
log.error('lookup', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// POST /auth/email/verify/:token — install the address.
|
|
async function confirm(req, res) {
|
|
try {
|
|
const row = await emailVerifications.findValidByToken(req.params.token)
|
|
if (!row) return res.status(404).json({ message: INVALID })
|
|
|
|
// Consume first: if we lost a double-submit race, stop before touching the
|
|
// account so a spent link cannot be replayed.
|
|
const won = await emailVerifications.consume(row.id)
|
|
if (!won) return res.status(404).json({ message: INVALID })
|
|
|
|
let installed
|
|
try {
|
|
installed = await users.promotePendingEmail(row.user_id, row.email)
|
|
} catch (err) {
|
|
// The UNIQUE index is the arbiter, and it fires here rather than at request
|
|
// time because a pending address reserves nothing: between staging and
|
|
// confirming, someone else may have verified the same address first.
|
|
//
|
|
// ANTI-ENUMERATION: the answer is the generic INVALID, identical to an
|
|
// expired or already-used link. Saying "that address is taken" would turn
|
|
// this endpoint into an oracle for which addresses hold accounts — the same
|
|
// posture passwordReset.controller keeps. The real reason is logged, never
|
|
// returned; note the driver's message embeds the address, which is another
|
|
// reason it must not travel to a client.
|
|
if (users.isDuplicateEmail(err)) {
|
|
log.warn('email verification lost to an existing address', { userId: row.user_id })
|
|
await users.clearPendingEmail(row.user_id).catch(() => {})
|
|
return res.status(404).json({ message: INVALID })
|
|
}
|
|
throw err
|
|
}
|
|
|
|
// The guard rejected it: the user has since asked for a different address, so
|
|
// this token is stale even though it had not expired. Same generic answer.
|
|
if (!installed) {
|
|
log.info('email verification superseded by a later request', { userId: row.user_id })
|
|
return res.status(404).json({ message: INVALID })
|
|
}
|
|
|
|
// Retire any other outstanding links for this user — one address is now proved
|
|
// and the others must not be installable behind the user's back.
|
|
await emailVerifications.invalidatePendingForUser(row.user_id)
|
|
|
|
await activity.log({ req, userId: row.user_id, action: 'account.email.verified' })
|
|
log.info('account email verified', { userId: row.user_id, ip: req.ip })
|
|
// No session is issued: this proves control of a mailbox, not of an account.
|
|
return res.json({ ok: true, message: 'Your email address has been confirmed.' })
|
|
} catch (err) {
|
|
log.error('confirm', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
module.exports = { lookup, confirm }
|