fix(auth): enforce the verified-email uniqueness that email login assumes
The design has claimed since migration 0010 that at most one account can hold a PROVEN email address, with ErrEmailTaken as the 409 a second verifier sees. Neither half ever shipped: no migration created users_verified_email_unique, and VerifyEmailOTP had no guard at all — the sentinel was defined but never returned, so two accounts could both verify one address. The damage is not cosmetic: the pre-session login resolves accounts BY verified email, so the duplicate decided which identity a mailed sign-in code belonged to. - Migration 0020 creates the partial unique index (lower(email) WHERE email_verified) the comments have been citing — the database-level backstop. - VerifyEmailOTP now refuses the take-over with ErrEmailTaken BEFORE consuming the code (the address, not the code, is the problem), charges no attempt, and maps a lost cross-user race (unique violation) to the same answer. - The verify handler answers 409 email_taken instead of a generic 500. Covered by the pgint suite (sequential double-verify refused with the code still live, a direct duplicate write still loses to the index, the refused account can still prove its own address) and a hermetic 409 case.
This commit is contained in:
8 files changed
+120
-7
No files matched your search
@@ -309,7 +309,10 @@ type Repo interface {
|
||||
// mismatch increments attempts and returns ErrOTPInvalid WITHOUT consuming the
|
||||
// code (so a typo does not burn it). On a match the code is consumed and the
|
||||
// user row is flipped to email=<the proven address>, email_verified=true; the
|
||||
// proven email is returned. now is the API clock so expiry is testable.
|
||||
// proven email is returned — unless a DIFFERENT account has already proven the
|
||||
// same address, which is ErrEmailTaken with the code left unconsumed (the
|
||||
// address, not the code, is the problem). now is the API clock so expiry is
|
||||
// testable.
|
||||
//
|
||||
// This is the ONBOARDING primitive: verifying the code is the moment the address
|
||||
// becomes proven, so the write is load-bearing. The pre-session LOGIN door must
|
||||
@@ -490,7 +493,7 @@ type Repo interface {
|
||||
// (email_verified true), so a merely-asserted or unverified address never
|
||||
// resolves to a session-mintable identity — an attacker cannot claim someone
|
||||
// else's login by typing their email. Matching is on lower(email) to align with
|
||||
// the users_verified_email_unique partial index (migration 0010), which
|
||||
// the users_verified_email_unique partial index (migration 0020), which
|
||||
// guarantees at most one verified row per normalized address, so the result is
|
||||
// unambiguous. A player (role='user') row resolves too — email-first login is
|
||||
// passwordless and role-agnostic here; the door that consumes this result decides
|
||||
|
||||
Reference in new issue
Block a user