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
@@ -187,9 +187,11 @@ type emailOTPVerifyRequest struct {
|
||||
|
||||
// handleEmailOTPVerify redeems a code for the caller (spec §B2, external app face).
|
||||
// Outcomes mirror the link-verify shape: an invalid/expired/mismatched code → 400
|
||||
// invalid_code, a locked code (too many wrong guesses) → 429 otp_locked, and on
|
||||
// success the user's email is written and email_verified flips true. The verified
|
||||
// address is echoed so the panel can render it.
|
||||
// invalid_code, a locked code (too many wrong guesses) → 429 otp_locked, an address
|
||||
// another account already proved → 409 email_taken (the code stays live — the
|
||||
// address, not the code, is the problem), and on success the user's email is
|
||||
// written and email_verified flips true. The verified address is echoed so the
|
||||
// panel can render it.
|
||||
func (a *API) handleEmailOTPVerify(w http.ResponseWriter, r *http.Request) {
|
||||
p := principalFromContext(r.Context())
|
||||
var req emailOTPVerifyRequest
|
||||
@@ -211,6 +213,10 @@ func (a *API) handleEmailOTPVerify(w http.ResponseWriter, r *http.Request) {
|
||||
case errors.Is(err, ErrOTPInvalid):
|
||||
writeError(w, r, newError(http.StatusBadRequest, "invalid_code", "email code is invalid or expired"))
|
||||
return
|
||||
case errors.Is(err, ErrEmailTaken):
|
||||
writeError(w, r, newError(http.StatusConflict, "email_taken",
|
||||
"that email is already verified on another account; sign in with it or use another address"))
|
||||
return
|
||||
case err != nil:
|
||||
writeError(w, r, err)
|
||||
return
|
||||
|
||||
Reference in new issue
Block a user