feat(auth): unique, changeable, verifiable email addresses (engagement Phase 1b)
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>
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
// 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')
|
||||
@@ -22,6 +23,7 @@ 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')
|
||||
|
||||
@@ -37,6 +39,10 @@ async function getAccount(req, res) {
|
||||
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),
|
||||
@@ -133,6 +139,127 @@ async function changePassword(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
@@ -396,6 +523,9 @@ async function generateRecoveryCodes(req, res) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
changeEmail,
|
||||
resendEmailVerification,
|
||||
cancelEmailChange,
|
||||
getAccount,
|
||||
changeUsername,
|
||||
changePassword,
|
||||
|
||||
Reference in New Issue
Block a user