package auth import ( "context" "crypto/rand" "errors" "fmt" "math/big" "regexp" "time" smsprovider "hfb_sys/backend/internal/integrations/sms" "hfb_sys/backend/internal/model" "github.com/redis/go-redis/v9" "go.uber.org/zap" ) var ( ErrDependencyUnavailable = errors.New("dependency unavailable") ErrInvalidPhone = errors.New("invalid phone") ErrCodeRateLimited = errors.New("sms code rate limited") ErrCodeInvalid = errors.New("sms code invalid") ErrSMSSendFailed = errors.New("sms send failed") ErrUserDisabled = errors.New("user disabled") ) const ( smsLoginCooldown = 60 * time.Second smsLoginHourlyLimit = 5 smsLoginHourlyWindow = time.Hour ) type Service struct { users *UserRepository redis *redis.Client jwt *JWTManager sms smsprovider.Provider log *zap.Logger } type LoginResult struct { User *model.User `json:"user"` Tokens TokenPair `json:"tokens"` } func NewService(users *UserRepository, redis *redis.Client, jwt *JWTManager, sms smsprovider.Provider, log *zap.Logger) *Service { return &Service{users: users, redis: redis, jwt: jwt, sms: sms, log: log} } func (s *Service) SendSMSCode(ctx context.Context, phone string) error { if s.redis == nil { return ErrDependencyUnavailable } if !isPhone(phone) { return ErrInvalidPhone } cooldownKey := "sms:cooldown:login:" + phone exists, err := s.redis.Exists(ctx, cooldownKey).Result() if err != nil { return err } if exists > 0 { return ErrCodeRateLimited } hourlyKey := "sms:hourly:login:" + phone hourlyCount, err := s.redis.Get(ctx, hourlyKey).Int() if err != nil && !errors.Is(err, redis.Nil) { return err } if hourlyCount >= smsLoginHourlyLimit { return ErrCodeRateLimited } code, err := randomDigits(6) if err != nil { return err } if s.sms == nil { return ErrDependencyUnavailable } if err := s.sms.SendLoginCode(ctx, phone, code); err != nil { if s.log != nil { s.log.Warn("sms login code send failed", zap.String("phone", PublicPhone(phone)), zap.Error(err)) } if smsprovider.IsProviderRateLimited(err) { pipe := s.redis.TxPipeline() pipe.Set(ctx, cooldownKey, "1", smsLoginCooldown) pipe.Set(ctx, hourlyKey, smsLoginHourlyLimit, smsLoginHourlyWindow) if _, pipeErr := pipe.Exec(ctx); pipeErr != nil { return pipeErr } return ErrCodeRateLimited } return ErrSMSSendFailed } pipe := s.redis.TxPipeline() pipe.Set(ctx, codeKey(phone), code, 5*time.Minute) pipe.Set(ctx, cooldownKey, "1", smsLoginCooldown) pipe.Incr(ctx, hourlyKey) pipe.Expire(ctx, hourlyKey, smsLoginHourlyWindow) pipe.Incr(ctx, "sms:daily:login:"+phone) pipe.Expire(ctx, "sms:daily:login:"+phone, 24*time.Hour) if _, err := pipe.Exec(ctx); err != nil { return err } return nil } func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string) (LoginResult, error) { if s.redis == nil || s.users == nil { return LoginResult{}, ErrDependencyUnavailable } if !isPhone(phone) { return LoginResult{}, ErrInvalidPhone } stored, err := s.redis.Get(ctx, codeKey(phone)).Result() if errors.Is(err, redis.Nil) || stored != code { return LoginResult{}, ErrCodeInvalid } if err != nil { return LoginResult{}, err } user, err := s.users.FindOrCreateByPhone(ctx, phone) if err != nil { return LoginResult{}, err } if user.Status != "active" { return LoginResult{}, ErrUserDisabled } tokens, err := s.jwt.GeneratePair(user.ID, user.Phone) if err != nil { return LoginResult{}, err } _ = s.redis.Del(ctx, codeKey(phone)).Err() return LoginResult{User: user, Tokens: tokens}, nil } func (s *Service) RefreshToken(refreshToken string) (TokenPair, error) { claims, err := s.jwt.ParseSubject(refreshToken, tokenTypeRefresh, "user") if err != nil { return TokenPair{}, err } return s.jwt.GeneratePair(claims.UserID, claims.Phone) } func codeKey(phone string) string { return "sms:code:login:" + phone } func isPhone(phone string) bool { return regexp.MustCompile(`^1[3-9]\d{9}$`).MatchString(phone) } func randomDigits(length int) (string, error) { result := make([]byte, length) for i := range result { n, err := rand.Int(rand.Reader, big.NewInt(10)) if err != nil { return "", err } result[i] = byte('0' + n.Int64()) } return string(result), nil } func PublicPhone(phone string) string { if len(phone) < 7 { return phone } return fmt.Sprintf("%s****%s", phone[:3], phone[len(phone)-4:]) }