fix(api): ConsumeLoginEmailOTP honesty — wrong/expired/consumed codes are ErrOTPInvalid 400, not a 500

The PG implementation was a single UPDATE ... WHERE code_hash that returned
ErrNotFound on zero rows: every wrong, expired, replayed or superseded code on
the pre-session email-login door (and the op-login finish / migration confirm
doors) fell through to writeError's unmapped-error 500, and attempts were never
charged so otpMaxAttempts/ErrOTPLocked could not trigger. The fake repo and the
Repo interface ("SAME code lifecycle as VerifyEmailOTP") already documented the
intended contract; only the PG side had drifted.

Mirror VerifyEmailOTP's transaction without its users write: SELECT ... FOR
UPDATE the newest live row, expiry + attempt cap before the hash compare,
mismatch charges one attempt and returns ErrOTPInvalid without consuming,
match consumes and commits. Verified live on the VM: 5 wrong guesses return
400 and stop at attempts=5 (correct code then also refused, unconsumed);
fresh code redeems; replay returns 400.
This commit is contained in:
Lemon-miaow committed 2026-09-22 19:51:04 +08:00
1 parent 9309ff5a7f
commit 52549f7b3a
1 file changed
+48 -16
+48 -16
View File
@@ -1878,29 +1878,61 @@ func (p *PGRepo) UserByEmail(ctx context.Context, email string) (*StaffUser, err
return &u, nil
}
// ConsumeLoginEmailOTP redeems a live code for the PRE-SESSION email login door.
// Unlike VerifyEmailOTP it has no identity side-effects: it neither writes
// users.email nor runs the verified-email uniqueness guard — login already
// resolved the userID via UserByEmail, which requires email_verified, so the
// address is settled. Zero rows affected (no live code, expired, consumed, or
// hash mismatch) → ErrNotFound.
// ConsumeLoginEmailOTP redeems the live code for the PRE-SESSION email login door
// with the SAME lifecycle as VerifyEmailOTP (FOR UPDATE, expiry + attempt cap
// before the hash compare, a mismatch charges one attempt without consuming) but
// with NO identity side-effects: it neither writes users.email nor runs the
// verified-email uniqueness guard — login already resolved the userID via
// UserByEmail, which requires email_verified, so the address is settled. Errors
// are exactly ErrOTPInvalid / ErrOTPLocked (ErrEmailTaken is structurally
// impossible here).
func (p *PGRepo) ConsumeLoginEmailOTP(ctx context.Context, userID, purpose, codeHash string, now time.Time) error {
res, err := p.db.ExecContext(ctx,
`UPDATE email_otps SET consumed_at = $4
WHERE user_id = $1 AND purpose = $2 AND code_hash = $3
AND consumed_at IS NULL AND expires_at > $4`,
userID, purpose, codeHash, now)
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
defer tx.Rollback() //nolint:errcheck // no-op after commit
var (
id string
storedHash string
attempts int
expiresAt time.Time
)
switch err := tx.QueryRowContext(ctx,
`SELECT id, code_hash, attempts, expires_at FROM email_otps
WHERE user_id = $1 AND purpose = $2 AND consumed_at IS NULL
ORDER BY created_at DESC LIMIT 1 FOR UPDATE`,
userID, purpose).Scan(&id, &storedHash, &attempts, &expiresAt); {
case errors.Is(err, sql.ErrNoRows):
// Nothing live: never minted, already consumed, or superseded.
return ErrOTPInvalid
case err != nil:
return err
}
if n == 0 {
return ErrNotFound
if !expiresAt.After(now) {
return ErrOTPInvalid
}
return nil
if attempts >= otpMaxAttempts {
return ErrOTPLocked
}
if storedHash != codeHash {
if _, err := tx.ExecContext(ctx,
`UPDATE email_otps SET attempts = attempts + 1 WHERE id = $1`, id); err != nil {
return fmt.Errorf("record otp attempt: %w", err)
}
if err := tx.Commit(); err != nil {
return err
}
return ErrOTPInvalid
}
if _, err := tx.ExecContext(ctx,
`UPDATE email_otps SET consumed_at = $2 WHERE id = $1`, id, now); err != nil {
return fmt.Errorf("consume otp: %w", err)
}
return tx.Commit()
}
// ---- op.console staff login: in-game approval state machine (spec §B op-login) ----