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:
Lemon-miaow committed 2026-09-23 03:19:35 +08:00
1 parent 2a55a0d265
commit b6ef27cd2d
8 files changed
+120 -7

No files matched your search

+14
View File
@@ -339,6 +339,20 @@ func TestEmailOTPVerifyRejections(t *testing.T) {
t.Fatalf("code = %d body %s, want 429 otp_locked", w.Code, w.Body.String())
}
})
t.Run("address proven elsewhere -> 409 email_taken, not consumed", func(t *testing.T) {
repo := newFakeRepo()
live(repo, "tk", otpCodeHash("123456"), time.Unix(1_700_000_600, 0), 0)
// Another account already proved the same address: login resolves accounts
// BY verified email, so the second proof must be refused.
repo.staff["other"] = &StaffUser{ID: "u2", Email: "[email protected]", EmailVerified: true}
w := do(mk(repo), "POST", "/api/v1/account/email/verify", `{"code":"123456"}`, nil)
if w.Code != http.StatusConflict || decodeErr(t, w) != "email_taken" {
t.Fatalf("code = %d body %s, want 409 email_taken", w.Code, w.Body.String())
}
if repo.otps["tk"].consumed {
t.Error("a taken address must not consume the code")
}
})
}
// TestEmailOTPBruteForceLockout drives the lockout end-to-end through the handler: