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:
@@ -118,6 +118,19 @@ const passwordResetConfirmLimiter = makeLimiter({
|
||||
message: 'Too many attempts. Please try again later.',
|
||||
})
|
||||
|
||||
// Email-verification confirmations (engagement Phase 1b). Same reasoning as the
|
||||
// password-reset confirm limiter: the token is 256-bit random, but an
|
||||
// unauthenticated token-bearing endpoint should not be free to hammer. The
|
||||
// REQUEST side is authenticated and limited separately — accountChangeLimiter per
|
||||
// IP, plus a per-user ceiling in the model, because the mail goes to an address
|
||||
// its recipient did not ask to hear from.
|
||||
const emailVerifyConfirmLimiter = makeLimiter({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 15,
|
||||
label: 'email-verify-confirm',
|
||||
message: 'Too many attempts. Please try again later.',
|
||||
})
|
||||
|
||||
// CSP violation reports. Unauthenticated by necessity (browsers send them with no
|
||||
// session), and every accepted report writes a log line — so an attacker who can get
|
||||
// a victim to load a page could otherwise use it as a log-flood amplifier. Generous
|
||||
@@ -149,5 +162,6 @@ module.exports = {
|
||||
mobileSsoExchangeLimiter,
|
||||
passwordResetRequestLimiter,
|
||||
passwordResetConfirmLimiter,
|
||||
emailVerifyConfirmLimiter,
|
||||
cspReportLimiter,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user