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
@@ -231,6 +231,48 @@ func TestOnboardingEmailOTPContract(t *testing.T) {
|
||||
assertConsumed(t, u.ID, purpose, false)
|
||||
}
|
||||
|
||||
// The verified-email uniqueness guard: two accounts cannot prove the same
|
||||
// address (the login door resolves accounts BY verified email, so a duplicate
|
||||
// would make identity ambiguous). This is the guard ConsumeLoginEmailOTP
|
||||
// deliberately skips.
|
||||
func TestOnboardingEmailOTPRejectsTakenEmail(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
a, b := newUser(t, "user", "take-a"), newUser(t, "user", "take-b")
|
||||
addr := "shared-" + suffix(t) + "@example.net"
|
||||
now := mustNow()
|
||||
purpose := "onboard_email"
|
||||
|
||||
if err := repo.CreateEmailOTP(ctx, "tka-"+suffix(t), a.ID, addr, "h-a", purpose, now.Add(5*time.Minute)); err != nil {
|
||||
t.Fatalf("CreateEmailOTP(a): %v", err)
|
||||
}
|
||||
if _, err := repo.VerifyEmailOTP(ctx, a.ID, purpose, "h-a", now); err != nil {
|
||||
t.Fatalf("verify a: %v", err)
|
||||
}
|
||||
if err := repo.CreateEmailOTP(ctx, "tkb-"+suffix(t), b.ID, addr, "h-b", purpose, now.Add(5*time.Minute)); err != nil {
|
||||
t.Fatalf("CreateEmailOTP(b): %v", err)
|
||||
}
|
||||
if _, err := repo.VerifyEmailOTP(ctx, b.ID, purpose, "h-b", now); !errors.Is(err, api.ErrEmailTaken) {
|
||||
t.Fatalf("second account proving a taken email = %v, want ErrEmailTaken", err)
|
||||
}
|
||||
// The address, not the code, was the problem: b's code stays live.
|
||||
assertConsumed(t, b.ID, purpose, false)
|
||||
// The invariant is also enforced by the database, not only the app guard: a
|
||||
// direct write that bypasses VerifyEmailOTP still loses (uppercased to prove
|
||||
// the index keys on lower(email)).
|
||||
if _, err := db.ExecContext(ctx,
|
||||
`UPDATE users SET email = $2, email_verified = true WHERE id = $1`, b.ID, strings.ToUpper(addr)); err == nil {
|
||||
t.Fatal("a direct duplicate verified-email write succeeded; users_verified_email_unique is missing")
|
||||
}
|
||||
// And the refusal does not wedge b: its OWN address still verifies fine.
|
||||
own := "own-" + suffix(t) + "@example.net"
|
||||
if err := repo.CreateEmailOTP(ctx, "tkb2-"+suffix(t), b.ID, own, "h-b2", purpose, now.Add(5*time.Minute)); err != nil {
|
||||
t.Fatalf("CreateEmailOTP(b, own): %v", err)
|
||||
}
|
||||
if _, err := repo.VerifyEmailOTP(ctx, b.ID, purpose, "h-b2", now); err != nil {
|
||||
t.Fatalf("b must still be able to prove its own address: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- email OTP: pre-session login primitive (regression: the #16 drift) --------
|
||||
|
||||
func TestConsumeLoginEmailOTPContract(t *testing.T) {
|
||||
|
||||
Reference in new issue
Block a user