package service import ( "context" "crypto/rand" "fmt" "strings" "time" "github.com/redis/go-redis/v9" ) // maxCodeAttempts caps wrong guesses per issued code before it's burned. With a // single 6-digit code (1e6 space) and only this many tries per send — and sends // throttled by the cooldown — brute force is infeasible. Mirrors the Python // EmailCodeStore.MAX_ATTEMPTS. const maxCodeAttempts = 5 type EmailCodeService struct { redis *redis.Client codeTTL time.Duration resendCooldown time.Duration } func NewEmailCodeService(redis *redis.Client) *EmailCodeService { return &EmailCodeService{ redis: redis, codeTTL: 6 * time.Minute, // CODE_TTL_SECONDS=360 resendCooldown: 120 * time.Second, // CODE_COOLDOWN_SECONDS=120 } } // Redis exposes the underlying client so collaborators (e.g. LoginGuard) can be // built without threading the client through every constructor. func (s *EmailCodeService) Redis() *redis.Client { return s.redis } func (s *EmailCodeService) Issue(ctx context.Context, email, purpose string) (string, error) { email = strings.ToLower(strings.TrimSpace(email)) purpose = strings.ToLower(strings.TrimSpace(purpose)) ok, err := s.redis.SetNX(ctx, s.cooldownKey(email, purpose), "1", s.resendCooldown).Result() if err != nil { return "", err } if !ok { return "", fmt.Errorf("请稍后再试") } code, err := randomDigits(6) if err != nil { return "", err } if err := s.redis.Set(ctx, s.codeKey(email, purpose), code, s.codeTTL).Err(); err != nil { return "", err } // Reset the wrong-guess counter for this fresh code (same TTL as the code). if err := s.redis.Set(ctx, s.attemptsKey(email, purpose), "0", s.codeTTL).Err(); err != nil { return "", err } return code, nil } func (s *EmailCodeService) Verify(ctx context.Context, email, purpose, code string) (bool, error) { normalizedCode, err := ValidateEmailCode(code) if err != nil { return false, err } email = strings.ToLower(strings.TrimSpace(email)) purpose = strings.ToLower(strings.TrimSpace(purpose)) stored, err := s.redis.Get(ctx, s.codeKey(email, purpose)).Result() if err != nil { if err == redis.Nil { return false, nil } return false, err } // Count the attempt first; burn the code (and its counter) once the cap is // hit so the attacker must request a new one and wait out the send cooldown. attempts, err := s.redis.Incr(ctx, s.attemptsKey(email, purpose)).Result() if err != nil { return false, err } if attempts > maxCodeAttempts { if err := s.redis.Del(ctx, s.codeKey(email, purpose), s.attemptsKey(email, purpose)).Err(); err != nil { return false, err } return false, nil } if stored != normalizedCode { return false, nil } // Correct code: one-time use, clear both the code and its attempt counter. if err := s.redis.Del(ctx, s.codeKey(email, purpose), s.attemptsKey(email, purpose)).Err(); err != nil { return false, err } return true, nil } func (s *EmailCodeService) codeKey(email, purpose string) string { return "email_code:" + purpose + ":" + email } func (s *EmailCodeService) attemptsKey(email, purpose string) string { return "email_code_attempts:" + purpose + ":" + email } func (s *EmailCodeService) cooldownKey(email, purpose string) string { return "email_code_cooldown:" + purpose + ":" + email } func randomDigits(n int) (string, error) { buf := make([]byte, n) src := make([]byte, n) if _, err := rand.Read(src); err != nil { return "", err } for i := range src { buf[i] = byte('0' + (src[i] % 10)) } return string(buf), nil }