aliyun_captcha_service.go 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "strings"
  7. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  8. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  9. )
  10. var (
  11. ErrAliyunCaptchaVerificationFailed = infraerrors.BadRequest("ALIYUN_CAPTCHA_VERIFICATION_FAILED", "aliyun captcha verification failed")
  12. ErrAliyunCaptchaNotConfigured = infraerrors.ServiceUnavailable("ALIYUN_CAPTCHA_NOT_CONFIGURED", "aliyun captcha not configured")
  13. // ErrCaptchaInvalidCredentials 阿里云验证码凭证无效(仅后台保存校验时返回,公开接口错误码不变)
  14. ErrCaptchaInvalidCredentials = infraerrors.BadRequest("CAPTCHA_INVALID_CREDENTIALS", "invalid aliyun captcha credentials")
  15. )
  16. // AliyunCaptchaCredentials 阿里云验证码 2.0 服务端校验所需的完整凭证
  17. type AliyunCaptchaCredentials struct {
  18. AccessKeyID string
  19. AccessKeySecret string
  20. SceneID string
  21. Endpoint string
  22. }
  23. // AliyunCaptchaVerifyResult VerifyIntelligentCaptcha 的归一化结果
  24. type AliyunCaptchaVerifyResult struct {
  25. VerifyResult bool
  26. VerifyCode string // 阿里云细分结果码,仅用于日志
  27. }
  28. // AliyunCaptchaAPIError 阿里云 OpenAPI 业务错误。
  29. // repository 层负责把 SDK 错误归一化为该类型,service 层不依赖 SDK 包。
  30. type AliyunCaptchaAPIError struct {
  31. Code string
  32. Message string
  33. }
  34. func (e *AliyunCaptchaAPIError) Error() string {
  35. return fmt.Sprintf("aliyun captcha api error: %s: %s", e.Code, e.Message)
  36. }
  37. // AliyunCaptchaVerifier 调用阿里云验证码 2.0 服务端校验的端口
  38. type AliyunCaptchaVerifier interface {
  39. VerifyCaptcha(ctx context.Context, cred AliyunCaptchaCredentials, captchaVerifyParam string) (*AliyunCaptchaVerifyResult, error)
  40. }
  41. const (
  42. // AliyunCaptchaRegionCN 中国内地;AliyunCaptchaRegionSGP 新加坡。
  43. // 该值同时下发给前端 AliyunCaptchaConfig.region,两端必须一致。
  44. AliyunCaptchaRegionCN = "cn"
  45. AliyunCaptchaRegionSGP = "sgp"
  46. aliyunCaptchaEndpointCN = "captcha.cn-shanghai.aliyuncs.com"
  47. aliyunCaptchaEndpointSGP = "captcha.ap-southeast-1.aliyuncs.com"
  48. )
  49. // aliyunCaptchaEndpoint 按后台配置的地域返回服务端接入点,未知值回退中国内地
  50. func aliyunCaptchaEndpoint(region string) string {
  51. if region == AliyunCaptchaRegionSGP {
  52. return aliyunCaptchaEndpointSGP
  53. }
  54. return aliyunCaptchaEndpointCN
  55. }
  56. // normalizeAliyunCaptchaRegion 非法值一律视为中国内地
  57. func normalizeAliyunCaptchaRegion(value string) string {
  58. if value == AliyunCaptchaRegionSGP {
  59. return AliyunCaptchaRegionSGP
  60. }
  61. return AliyunCaptchaRegionCN
  62. }
  63. // aliyunCredentialValidationParam 用于后台保存时探测凭证有效性的假验证参数
  64. const aliyunCredentialValidationParam = "sub2api-credential-validation"
  65. // aliyunInvalidCredentialCodes 表示 AK/SK 本身无效的阿里云错误码;
  66. // 其余错误码(如 param 无效)说明签名已通过、凭证可用。
  67. var aliyunInvalidCredentialCodes = map[string]struct{}{
  68. "InvalidAccessKeyId.NotFound": {},
  69. "InvalidAccessKeyId.Inactive": {},
  70. "SignatureDoesNotMatch": {},
  71. "Forbidden.AccessKeyDisabled": {},
  72. "IncompleteSignature": {},
  73. "InvalidSecurityToken.Expired": {},
  74. }
  75. // AliyunCaptchaService 阿里云验证码 2.0 服务端校验
  76. type AliyunCaptchaService struct {
  77. settingService *SettingService
  78. verifier AliyunCaptchaVerifier
  79. }
  80. func NewAliyunCaptchaService(settingService *SettingService, verifier AliyunCaptchaVerifier) *AliyunCaptchaService {
  81. return &AliyunCaptchaService{settingService: settingService, verifier: verifier}
  82. }
  83. func aliyunCaptchaCredentials(config AliyunCaptchaConfig) (AliyunCaptchaCredentials, bool) {
  84. cred := AliyunCaptchaCredentials{
  85. AccessKeyID: strings.TrimSpace(config.AccessKeyID),
  86. AccessKeySecret: strings.TrimSpace(config.AccessKeySecret),
  87. SceneID: strings.TrimSpace(config.SceneID),
  88. Endpoint: aliyunCaptchaEndpoint(config.Region),
  89. }
  90. if cred.AccessKeyID == "" || cred.AccessKeySecret == "" || cred.SceneID == "" {
  91. return AliyunCaptchaCredentials{}, false
  92. }
  93. return cred, true
  94. }
  95. // VerifyParamWithConfig 校验阿里云验证码 2.0 的 captchaVerifyParam。
  96. // 调用异常时返回错误(fail-closed),与 Turnstile 网络错误行为对称。
  97. func (s *AliyunCaptchaService) VerifyParamWithConfig(ctx context.Context, config AliyunCaptchaConfig, captchaVerifyParam string) error {
  98. if s == nil || s.verifier == nil {
  99. return ErrAliyunCaptchaNotConfigured
  100. }
  101. cred, ok := aliyunCaptchaCredentials(config)
  102. if !ok {
  103. logger.LegacyPrintf("service.aliyun_captcha", "%s", "[AliyunCaptcha] credentials not configured")
  104. return ErrAliyunCaptchaNotConfigured
  105. }
  106. if strings.TrimSpace(captchaVerifyParam) == "" {
  107. logger.LegacyPrintf("service.aliyun_captcha", "%s", "[AliyunCaptcha] captchaVerifyParam is empty")
  108. return ErrAliyunCaptchaVerificationFailed
  109. }
  110. result, err := s.verifier.VerifyCaptcha(ctx, cred, captchaVerifyParam)
  111. if err != nil {
  112. logger.LegacyPrintf("service.aliyun_captcha", "[AliyunCaptcha] verify request failed: %v", err)
  113. return fmt.Errorf("%w: verifier request failed", ErrAliyunCaptchaVerificationFailed)
  114. }
  115. if result == nil || !result.VerifyResult {
  116. if result != nil {
  117. logger.LegacyPrintf("service.aliyun_captcha", "[AliyunCaptcha] rejected, verify code: %s", result.VerifyCode)
  118. }
  119. return ErrAliyunCaptchaVerificationFailed
  120. }
  121. return nil
  122. }
  123. // ValidateCredentials 用假验证参数探测阿里云 AK/SK 是否可用(后台保存设置时调用)。
  124. // 凭证类错误码返回 ErrCaptchaInvalidCredentials;正常响应(包括 param 无效导致的
  125. // VerifyResult=false)说明签名通过、凭证有效;其余错误原样返回给管理员排查。
  126. func (s *AliyunCaptchaService) ValidateCredentials(ctx context.Context, accessKeyID, accessKeySecret, sceneID, region string) error {
  127. if s.verifier == nil {
  128. return ErrAliyunCaptchaNotConfigured
  129. }
  130. cred := AliyunCaptchaCredentials{
  131. AccessKeyID: accessKeyID,
  132. AccessKeySecret: accessKeySecret,
  133. SceneID: sceneID,
  134. Endpoint: aliyunCaptchaEndpoint(region),
  135. }
  136. _, err := s.verifier.VerifyCaptcha(ctx, cred, aliyunCredentialValidationParam)
  137. if err != nil {
  138. var apiErr *AliyunCaptchaAPIError
  139. if errors.As(err, &apiErr) {
  140. if _, invalid := aliyunInvalidCredentialCodes[apiErr.Code]; invalid {
  141. return ErrCaptchaInvalidCredentials
  142. }
  143. }
  144. return fmt.Errorf("validate aliyun captcha credentials: %w", err)
  145. }
  146. return nil
  147. }