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>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user