package auth import ( "context" "crypto/rand" "errors" "fmt" "math/big" "regexp" "time" "hfb_sys/backend/internal/captcha" smsprovider "hfb_sys/backend/internal/integrations/sms" "hfb_sys/backend/internal/logging" "hfb_sys/backend/internal/model" "github.com/redis/go-redis/v9" "go.uber.org/zap" "golang.org/x/crypto/bcrypt" ) var ( ErrDependencyUnavailable = errors.New("dependency unavailable") ErrInvalidPhone = errors.New("invalid phone") ErrCaptchaInvalid = errors.New("captcha invalid") ErrCodeRateLimited = errors.New("sms code rate limited") ErrCodeInvalid = errors.New("sms code invalid") ErrSMSSendFailed = errors.New("sms send failed") ErrUserDisabled = errors.New("user disabled") ErrInvalidPassword = errors.New("invalid password") ErrInvalidOldPassword = errors.New("invalid old password") ErrPasswordNotSet = errors.New("password not set") ErrPasswordTooWeak = errors.New("password too weak") ErrLoginLocked = errors.New("login locked") ErrUserAlreadyExists = errors.New("user already exists") ) const ( smsLoginCooldown = 60 * time.Second smsLoginHourlyLimit = 5 smsLoginHourlyWindow = time.Hour smsCaptchaTTL = 3 * time.Minute passwordMinLength = 8 passwordMaxLength = 20 loginFailureTTL = 15 * time.Minute loginLockTTL = 15 * time.Minute loginMaxFailureCount = 5 ) 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) Captcha(ctx context.Context) (*captcha.Item, error) { if s.redis == nil { return nil, ErrDependencyUnavailable } item, err := captcha.Generate(ctx, s.redis, "auth:sms", smsCaptchaTTL) if errors.Is(err, captcha.ErrDependencyUnavailable) { return nil, ErrDependencyUnavailable } return item, err } func (s *Service) SendSMSCode(ctx context.Context, phone string, captchaID string, captchaCode 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 } if err := captcha.Verify(ctx, s.redis, "auth:sms", captchaID, captchaCode); err != nil { if errors.Is(err, captcha.ErrDependencyUnavailable) { return ErrDependencyUnavailable } if errors.Is(err, captcha.ErrInvalid) { return ErrCaptchaInvalid } return err } 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 { fields := []zap.Field{ zap.String("module", "auth"), zap.String("phone", PublicPhone(phone)), zap.Error(err), } if requestID := logging.RequestIDFromContext(ctx); requestID != "" { fields = append(fields, zap.String("request_id", requestID)) } s.log.Warn("短信验证码发送失败", fields...) } 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.issueTokenPair(user) if err != nil { return LoginResult{}, err } _ = s.redis.Del(ctx, codeKey(phone)).Err() return LoginResult{User: user, Tokens: tokens}, nil } func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (TokenPair, error) { if s.users == nil { return TokenPair{}, ErrDependencyUnavailable } claims, err := s.jwt.ParseSubject(refreshToken, tokenTypeRefresh, "user") if err != nil { return TokenPair{}, err } user, err := s.users.FindActiveForToken(ctx, claims.UserID, claims.TokenVersion) if err != nil { return TokenPair{}, err } return s.issueTokenPair(user) } func (s *Service) issueTokenPair(user *model.User) (TokenPair, error) { return s.jwt.GenerateSubjectPairWithVersion(user.ID, user.Phone, "user", user.TokenVersion) } func (s *Service) Logout(ctx context.Context, userID uint64) error { if s.users == nil { return ErrDependencyUnavailable } return s.users.RevokeTokens(ctx, userID) } 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:]) } func (s *Service) LoginWithPassword(ctx context.Context, phone, password, clientIP string) (LoginResult, error) { if s.redis == nil || s.users == nil { return LoginResult{}, ErrDependencyUnavailable } if !isPhone(phone) { return LoginResult{}, ErrInvalidPhone } locked, err := s.redis.Exists(ctx, loginLockRedisKey(phone, clientIP)).Result() if err != nil { return LoginResult{}, err } if locked > 0 { return LoginResult{}, ErrLoginLocked } user, err := s.users.FindByPhone(ctx, phone) if IsNotFound(err) { // 用户不存在也计失败次数,避免该接口被用于探测手机号是否已注册 _ = recordLoginFailure(ctx, s.redis, phone, clientIP) return LoginResult{}, ErrInvalidPassword } if err != nil { return LoginResult{}, err } if user.PasswordHash == "" { return LoginResult{}, ErrPasswordNotSet } if checkPasswordHash(password, user.PasswordHash) != nil { _ = recordLoginFailure(ctx, s.redis, phone, clientIP) return LoginResult{}, ErrInvalidPassword } if user.Status != "active" { return LoginResult{}, ErrUserDisabled } _ = clearLoginFailure(ctx, s.redis, phone, clientIP) tokens, err := s.issueTokenPair(user) if err != nil { return LoginResult{}, err } return LoginResult{User: user, Tokens: tokens}, nil } func (s *Service) SetPassword(ctx context.Context, userID uint64, password, oldPassword string) error { if !isValidPassword(password) { return ErrPasswordTooWeak } user, err := s.users.FindByID(ctx, userID) if err != nil { return err } // 已设置过密码时必须校验旧密码,避免会话被他人借用后直接改密锁定原号主 if user.PasswordHash != "" { if checkPasswordHash(oldPassword, user.PasswordHash) != nil { return ErrInvalidOldPassword } } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return err } return s.users.SetPassword(ctx, userID, string(hash)) } func (s *Service) ResetPassword(ctx context.Context, phone, code, newPassword string) error { if s.redis == nil || s.users == nil { return ErrDependencyUnavailable } if !isPhone(phone) { return ErrInvalidPhone } stored, err := s.redis.Get(ctx, codeKey(phone)).Result() if errors.Is(err, redis.Nil) || stored != code { return ErrCodeInvalid } if err != nil { return err } if !isValidPassword(newPassword) { return ErrPasswordTooWeak } user, err := s.users.FindByPhone(ctx, phone) if err != nil { return err } hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) if err != nil { return err } if err := s.users.SetPassword(ctx, user.ID, string(hash)); err != nil { return err } _ = s.redis.Del(ctx, codeKey(phone)).Err() return nil } func (s *Service) RegisterWithPassword(ctx context.Context, phone, code, password 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 } if !isValidPassword(password) { return LoginResult{}, ErrPasswordTooWeak } // 已注册的号不允许通过注册流程覆盖密码,避免"注册即改密"接管账号 existing, err := s.users.FindByPhone(ctx, phone) if err == nil && existing != nil { return LoginResult{}, ErrUserAlreadyExists } if err != nil && !IsNotFound(err) { return LoginResult{}, err } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return LoginResult{}, err } user, err := s.users.RegisterWithPassword(ctx, phone, string(hash)) if err != nil { // 并发场景下仍可能因唯一索引冲突,统一按"已注册"处理 return LoginResult{}, ErrUserAlreadyExists } if user.Status != "active" { return LoginResult{}, ErrUserDisabled } tokens, err := s.issueTokenPair(user) if err != nil { return LoginResult{}, err } _ = s.redis.Del(ctx, codeKey(phone)).Err() return LoginResult{User: user, Tokens: tokens}, nil } func isValidPassword(password string) bool { return len(password) >= passwordMinLength && len(password) <= passwordMaxLength } func checkPasswordHash(password, hash string) error { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) } func recordLoginFailure(ctx context.Context, rdb *redis.Client, phone, clientIP string) error { key := loginFailureRedisKey(phone, clientIP) count, err := rdb.Incr(ctx, key).Result() if err != nil { return err } _ = rdb.Expire(ctx, key, loginFailureTTL).Err() if count >= int64(loginMaxFailureCount) { _ = rdb.Set(ctx, loginLockRedisKey(phone, clientIP), "1", loginLockTTL).Err() } return nil } func clearLoginFailure(ctx context.Context, rdb *redis.Client, phone, clientIP string) error { return rdb.Del(ctx, loginFailureRedisKey(phone, clientIP), loginLockRedisKey(phone, clientIP)).Err() } func loginFailureRedisKey(phone, clientIP string) string { return fmt.Sprintf("user:login:fail:%s:%s", clientIP, phone) } func loginLockRedisKey(phone, clientIP string) string { return fmt.Sprintf("user:login:lock:%s:%s", clientIP, phone) }