Skip to content

fix(email): 验证码尝试次数原子化 + 重置密码 token 哈希存储且原子单次消费 - #7676

Merged
Wei-Shaw merged 1 commit into
Wei-Shaw:mainfrom
william-drakemond:fix/email-code-token-atomic
Oct 2, 2026
Merged

Wei-Shaw merged 1 commit into
Wei-Shaw:mainfrom
william-drakemond:fix/email-code-token-atomic

Conversation

@william-drakemond

Copy link
Copy Markdown
Contributor

背景

  • 邮箱验证码校验的失败计数是 Get → Attempts++ → Set(非原子),并发错误猜测可同时读到旧计数,实际比较次数超过 5 次上限。通知邮箱验证码(user_service)存在同样问题。
  • 重置密码 token 明文存于 Redis,且消费是“先比较再 Delete”(非原子),两个并发请求可能同时通过校验。

改动

  • EmailCache 新增 IncrVerificationCodeAttempts / IncrNotifyVerifyCodeAttempts:Redis 实现用 Lua 脚本(EXISTS + INCR + 按验证码剩余 PTTL 设置过期),计数存于独立 key <code key>:attempts;设置新验证码时同时清零计数,删除时一并删除。
  • 校验流程改为先原子占用一次尝试,再做常量时间比较:占用结果超过上限直接拒绝,因此无论并发多少,最多只会真正比较 5 次。注册验证码与通知邮箱验证码共用 verifyCodeWithAttempts。
  • 重置 token:只存 sha256(token) 的 hex;VerifyPasswordResetToken 对哈希做 subtle.ConstantTimeCompare;新增 ConsumePasswordResetToken(Lua 比较哈希后 DEL,原子),只有一个并发请求能成功。错误 token 不会删除有效 token。
  • 由于不再保存明文,重复发送重置邮件时不再复用旧 token,而是签发新 token(覆盖旧的;已有 30s 发送冷却限制频率)。
  • 更新各测试 stub 以实现新接口方法。

兼容性

  • 选择了更简单的方案:升级前已发出的重置密码链接(Redis 中为明文)在升级后失效,用户需重新申请。这些 token TTL 仅 30 分钟,影响窗口很小,且避免了保留明文兼容分支。
  • 升级前已存在的验证码 JSON 中的 Attempts 仍会被读取(取 JSON 与计数 key 的较大值),不影响进行中的验证码。

测试

  • internal/repository/email_cache_atomic_test.go(miniredis,-race):
    • 50 个并发错误验证码:返回 invalid 的不超过 4 个,其余均为 max attempts,之后正确验证码也被拒绝;
    • 新验证码重置计数、计数 TTL 跟随验证码、删除时计数一并清理;
    • 30 个并发消费同一重置 token 仅 1 个成功,且 Redis 中存储值不包含明文 token;
    • 哈希不匹配不删除 token,重复消费失败。
  • internal/service/email_service_reset_token_test.go(unit):消费时传给 cache 的是 SHA-256 哈希;旧明文存储值不再通过校验。
  • go build ./...、go vet ./internal/...、go vet -tags unit ./internal/...、go test -tags unit ./internal/service/ ./internal/handler/ ./internal/repository/ 全部通过。

🤖 Generated with Claude Code

…okens

- Verification code attempts (register + notify email) are now reserved via
  an atomic Redis INCR (Lua) before comparing, so concurrent wrong guesses
  cannot exceed the 5-attempt cap.
- Password reset tokens are stored as SHA-256 hashes and consumed with an
  atomic Lua compare-and-delete, so a token can only be used once even under
  concurrent requests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants