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:
@@ -21,11 +21,30 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
-- (validatePassword returns false).
|
||||
password_hash VARCHAR(72) NULL,
|
||||
role ENUM('admin','editor','moderator','player') NOT NULL DEFAULT 'admin',
|
||||
-- Optional contact email (players). Not unique — SSO emails may repeat. Used
|
||||
-- only for display + a future self-serve reset. email_verified is wired now so
|
||||
-- an eventual SMTP verification flow needs no schema change.
|
||||
-- The account's ONE contact address, and the destination for password-reset
|
||||
-- mail. Unique since engagement Phase 1b — but the index is on email_norm
|
||||
-- below, never on this column, and the reason is not stylistic:
|
||||
--
|
||||
-- Every case-insensitive (_ci) collation this server offers is ALSO
|
||||
-- accent-insensitive, so a UNIQUE index on `email` would refuse
|
||||
-- jose@x.com once josé@x.com exists. Those are two different mailboxes.
|
||||
--
|
||||
-- LOWER() under a _bin collation folds case WITHOUT folding accents, which is
|
||||
-- exactly the equivalence a mail system uses. Keeping the fold in a generated
|
||||
-- column rather than in application code means it cannot be bypassed by a
|
||||
-- caller that forgets to normalize.
|
||||
email VARCHAR(255) NULL,
|
||||
-- The uniqueness key. STORED (not VIRTUAL) because a UNIQUE index over it must
|
||||
-- be materialized. Multiple NULLs are legal under a UNIQUE index, which is what
|
||||
-- lets the Phase 1b de-duplication null the losers without deleting an account.
|
||||
email_norm VARCHAR(255) COLLATE utf8mb4_bin AS (LOWER(email)) STORED,
|
||||
email_verified TINYINT(1) NOT NULL DEFAULT 0,
|
||||
-- An address the user has asked for but not yet proved. It does NOT displace
|
||||
-- `email` until the verification link is used, so a typo cannot silently
|
||||
-- redirect this account's password-reset mail. Deliberately NOT unique: a
|
||||
-- pending address reserves nothing, and two users may both be pending on one
|
||||
-- address — the second to verify loses, with the same generic failure.
|
||||
email_pending VARCHAR(255) NULL,
|
||||
-- Account lifecycle, independent of role: staff can disable/ban a player
|
||||
-- without changing their role. active = normal; disabled = admin-locked;
|
||||
-- banned = moderation ban; pending = reserved for future email-verify gating.
|
||||
@@ -38,7 +57,12 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
tokens_valid_after DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login_at DATETIME NULL,
|
||||
last_login_ip VARCHAR(45) NULL -- IPv6-capable, set on each login
|
||||
last_login_ip VARCHAR(45) NULL, -- IPv6-capable, set on each login
|
||||
-- One account per mailbox (engagement Phase 1b). On the generated column, not
|
||||
-- on `email` — see the note there. Upgraded databases get this in the migration
|
||||
-- block at the foot of this file, AFTER the de-duplication that makes it
|
||||
-- addable; adding it here too is what gives a FRESH install the same shape.
|
||||
UNIQUE KEY uq_users_email_norm (email_norm)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
@@ -415,6 +439,54 @@ CREATE TABLE IF NOT EXISTS password_resets (
|
||||
INDEX idx_password_resets_status (status, expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Self-service email verification (engagement Phase 1b). The same shape as
|
||||
-- password_resets, deliberately: an opaque random token whose sha256 is all that
|
||||
-- is stored, single-use, short-lived. The design of record calls this link
|
||||
-- "signed"; every comparable flow in this codebase (user_invites,
|
||||
-- password_resets, mobile_refresh_tokens) uses a hashed random token instead, and
|
||||
-- matching them beats introducing a second token mechanism for one caller.
|
||||
--
|
||||
-- The address lives on the ROW, not just on the user: a token proves control of
|
||||
-- the address it was mailed to, so if the user changes their mind and requests a
|
||||
-- different address, the older token must not be able to confirm the newer one.
|
||||
CREATE TABLE IF NOT EXISTS email_verifications (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque token
|
||||
user_id INT NOT NULL,
|
||||
email VARCHAR(255) NOT NULL, -- the address THIS token proves
|
||||
status ENUM('pending','used') NOT NULL DEFAULT 'pending',
|
||||
requested_ip VARCHAR(64) NULL, -- who asked (audit only)
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
used_at DATETIME NULL,
|
||||
CONSTRAINT fk_email_verifications_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_email_verifications_user (user_id),
|
||||
INDEX idx_email_verifications_status (status, expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Who lost an address to the Phase 1b de-duplication, and what they lost.
|
||||
--
|
||||
-- These accounts are exactly the ones an operator must contact: they can no
|
||||
-- longer receive password-reset or engagement mail until they set a new address.
|
||||
-- Written by the migration below in pure SQL (ensureSchema() reads this file
|
||||
-- statement-by-statement and there is no JS migration hook), surfaced as a
|
||||
-- dashboard warning until acknowledged.
|
||||
--
|
||||
-- No foreign key to users, on purpose: the same reasoning as posts.announce_job_id
|
||||
-- — a constraint re-added on every boot is a constraint that can fail a boot, and
|
||||
-- this table is a historical record rather than a live relation.
|
||||
CREATE TABLE IF NOT EXISTS email_dedupe_report (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
username VARCHAR(32) NOT NULL, -- captured at clear time
|
||||
lost_address VARCHAR(255) NOT NULL,
|
||||
cleared_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
acknowledged_at DATETIME NULL, -- set when an admin dismisses the warning
|
||||
-- Makes the migration's INSERT strictly idempotent: an account cleared once is
|
||||
-- never reported twice, however many times ensureSchema() runs.
|
||||
UNIQUE KEY uq_edr_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── Push notifications (opt-in) ─────────────────────────────────────────────
|
||||
-- One row per registered push endpoint (Android/UnifiedPush v1; FCM later). The
|
||||
-- `endpoint` is the UnifiedPush distributor URL the app's ntfy topic was handed —
|
||||
@@ -1489,6 +1561,75 @@ ALTER TABLE email_config ADD COLUMN IF NOT EXISTS transport VARCHAR(32) NOT NULL
|
||||
ALTER TABLE email_config ADD COLUMN IF NOT EXISTS credential_enc TEXT NULL;
|
||||
ALTER TABLE email_config ADD COLUMN IF NOT EXISTS reply_to VARCHAR(255) NULL;
|
||||
|
||||
-- ── Engagement Phase 1b: one account per mailbox ───────────────────────────
|
||||
-- (ENGAGEMENT.md Phase 1b / §0.6.) ORDER IS LOAD-BEARING and every statement here
|
||||
-- is idempotent — after the first successful boot each one matches zero rows.
|
||||
--
|
||||
-- Why the generated column is added BEFORE the de-duplication rather than after:
|
||||
-- the de-dupe must group addresses exactly the way the index will, and it cannot
|
||||
-- do that with LOWER(email) = LOWER(email) in SQL, because that comparison uses
|
||||
-- the COLUMN's collation, which is accent-insensitive. Grouping on email_norm —
|
||||
-- the very column the UNIQUE index goes on — makes the two agree by construction
|
||||
-- instead of by a hand-matched COLLATE clause someone can get wrong later.
|
||||
-- (Tested: with the LOWER()=LOWER() form, jose@x.com was nulled as a "duplicate"
|
||||
-- of josé@x.com. They are different mailboxes.)
|
||||
|
||||
-- 1. An empty string is a value, not an absence, so two accounts holding '' would
|
||||
-- collide under the index and stop the boot. Unreachable through the current
|
||||
-- routes (isEmail() rejects ''), but this runs against databases whose history
|
||||
-- we do not control.
|
||||
UPDATE users SET email = NULL WHERE email = '';
|
||||
|
||||
-- 2. The pending-address column and the uniqueness key. No index yet — a UNIQUE
|
||||
-- index here, before step 3, is precisely the ALTER that fails and takes the
|
||||
-- site down with it (§0.6 finding 1).
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_pending VARCHAR(255) NULL;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_norm VARCHAR(255) COLLATE utf8mb4_bin AS (LOWER(email)) STORED;
|
||||
|
||||
-- 3. Record every account about to lose its address, BEFORE nulling it — the
|
||||
-- report is the only place the lost value survives. Oldest-wins (§7.1 Q1):
|
||||
-- the earliest-created account keeps the address, ties broken by id so the
|
||||
-- outcome is deterministic. Verified status deliberately does NOT arbitrate —
|
||||
-- SSO set email_verified from the mere presence of an address, so it is too
|
||||
-- weak a signal to decide who keeps a mailbox (§0.6 finding 3).
|
||||
INSERT IGNORE INTO email_dedupe_report (user_id, username, lost_address)
|
||||
SELECT l.id, l.username, l.email FROM (
|
||||
SELECT u.id, u.username, u.email FROM users u
|
||||
WHERE u.email_norm IS NOT NULL
|
||||
AND u.id <> (SELECT u2.id FROM users u2
|
||||
WHERE u2.email_norm = u.email_norm
|
||||
ORDER BY u2.created_at ASC, u2.id ASC LIMIT 1)
|
||||
) AS l;
|
||||
|
||||
-- 4. Clear the losers. NEVER deletes a row: multiple NULLs are legal under a
|
||||
-- UNIQUE index, so every account survives with its login intact and simply has
|
||||
-- no contact address until its owner sets one. The extra derived table is not
|
||||
-- decoration — MariaDB refuses a subquery on the table being updated (error
|
||||
-- 1093) without it.
|
||||
UPDATE users SET email = NULL, email_verified = 0
|
||||
WHERE id IN (SELECT id FROM (
|
||||
SELECT u.id FROM users u
|
||||
WHERE u.email_norm IS NOT NULL
|
||||
AND u.id <> (SELECT u2.id FROM users u2
|
||||
WHERE u2.email_norm = u.email_norm
|
||||
ORDER BY u2.created_at ASC, u2.id ASC LIMIT 1)
|
||||
) AS losers);
|
||||
|
||||
-- 5. Now the table can hold it.
|
||||
ALTER TABLE users ADD UNIQUE INDEX IF NOT EXISTS uq_users_email_norm (email_norm);
|
||||
|
||||
-- 6. The verification gate: may an UNVERIFIED address receive opt-in engagement
|
||||
-- mail? ON for a fresh install, OFF for an upgrade — the asymmetry is the G22
|
||||
-- lesson, not an oversight. Turning it on retroactively would silently stop
|
||||
-- mailing every existing opted-in user on upgrade day, which is exactly the
|
||||
-- kind of quiet breakage Phase 1 had to write a dashboard warning to undo.
|
||||
-- "Fresh" is read off the users table: a database with no users has no one to
|
||||
-- surprise. Both statements are INSERT IGNORE, so an operator who has since
|
||||
-- changed the value keeps theirs.
|
||||
INSERT IGNORE INTO settings (`key`, value)
|
||||
SELECT 'email_verification_required', 'on' FROM DUAL WHERE (SELECT COUNT(*) FROM users) = 0;
|
||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('email_verification_required', 'off');
|
||||
|
||||
-- The status a Gmail-connected deployment carries is 'connected', and after the
|
||||
-- upgrade that is a lie: nothing can send. Correct it once, narrowly. The WHERE
|
||||
-- makes this idempotent and self-limiting — it matches only a row that still holds
|
||||
|
||||
Reference in New Issue
Block a user