Files
chiyiandClaude Opus 4.8 606caaf047 Initial open-source release (MIT): image2api AI gateway
Full Go backend + Vue 3 frontend, OpenAI-compatible API, multi-provider
account pools, billing/admin, Docker one-command deploy with auto HTTPS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:59:04 +08:00

127 lines
3.5 KiB
Go

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
}