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:
@@ -79,6 +79,50 @@ meRouter.patch(
|
||||
account.changePassword,
|
||||
)
|
||||
|
||||
// Email address (engagement Phase 1b). The change is STAGED and only a tokened
|
||||
// link installs it, so these three routes never alter the address that is
|
||||
// currently receiving mail. The confirm half is public and lives at
|
||||
// /auth/email/verify/:token, because the link is opened from a mailbox.
|
||||
meRouter.patch(
|
||||
'/account/email',
|
||||
// #swagger.tags = ['Auth · Me']
|
||||
// #swagger.summary = 'Request a new email address (self, any role)'
|
||||
// #swagger.description = 'Stages the address and emails a confirmation link. The account keeps its current address until that link is used, so a mistyped address cannot redirect password-reset mail. Requires currentPassword when the account has a password; SSO-provisioned accounts with no password are exempt.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ChangeEmailRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Address staged; a confirmation link was sent', content: { "application/json": { schema: { $ref: "#/components/schemas/PendingEmail" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error, wrong current password, or already your address', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[429] = { description: 'Too many verification emails', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
accountChangeLimiter,
|
||||
body('email').isString().trim().isEmail().isLength({ max: 255 }),
|
||||
body('currentPassword').optional({ values: 'falsy' }).isString(),
|
||||
validate,
|
||||
account.changeEmail,
|
||||
)
|
||||
meRouter.post(
|
||||
'/account/email/resend',
|
||||
// #swagger.tags = ['Auth · Me']
|
||||
// #swagger.summary = 'Re-send the confirmation link for the pending address'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Confirmation link re-sent', content: { "application/json": { schema: { $ref: "#/components/schemas/PendingEmail" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'No address is awaiting confirmation', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[429] = { description: 'Too many verification emails', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
accountChangeLimiter,
|
||||
account.resendEmailVerification,
|
||||
)
|
||||
meRouter.delete(
|
||||
'/account/email/pending',
|
||||
// #swagger.tags = ['Auth · Me']
|
||||
// #swagger.summary = 'Abandon the pending email address'
|
||||
// #swagger.description = 'Clears the staged address and retires its outstanding links, so a confirmation email already delivered can no longer install it.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Pending address cleared', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
account.cancelEmailChange,
|
||||
)
|
||||
|
||||
// TOTP self-enrollment (disable requires a valid current code; it does not take
|
||||
// a password).
|
||||
meRouter.post(
|
||||
|
||||
Reference in New Issue
Block a user