Files
sub2api/backend/internal/service/aliyun_captcha_service.go
T
feeeei 26e0a89323 人机验证增加阿里云验证码 2.0
沿用腾讯天御验证码引入的多服务商模型:aliyun_captcha_enabled 作为独立
开关,与 Cloudflare Turnstile、腾讯天御三方互斥(保存校验 + 运行时
CAPTCHA_PROVIDER_CONFLICT)。后台「安全与认证」合并为单张人机验证卡片:
总开关 + 服务商单选(Turnstile / 腾讯天御 / 阿里云),选中即启用该家并
关闭其它,落库仍是三个独立开关键,由前端映射保证互斥。

阿里云侧同时支持 aliyun 中国站与国际站(alibabacloud.com):两站前端脚本、
region 取值与服务端 API 完全一致,仅账号与 AccessKey 相互独立,因此由
「服务地域」决定线路即可——中国内地走 captcha.cn-shanghai.aliyuncs.com,
非中国内地(新加坡)走 captcha.ap-southeast-1.aliyuncs.com,AccessKey
取自持有该实例的账号,无需在配置中区分站点。

- AliyunCaptchaService 对称 TencentCaptchaService:服务端校验走官方 SDK
  VerifyIntelligentCaptcha,调用异常按 fail-closed 拦截,与 Turnstile
  网络错误行为对称;保存设置时真实探测 AK/SK 有效性
- 保护面对齐腾讯扩展入口:VerifyTencentCaptchaIfEnabled 通用化为
  VerifyActionCaptchaIfEnabled,OAuth 登录启动、passkey 登录在阿里云
  启用时同样拦截;Turnstile 维持既有覆盖不扩大
- 前端 AliyunCaptchaWidget 为表单内预验证按钮(popup 模式),同时暴露
  verify() 供 OAuth 启动、passkey 等动作入口程序化弹窗;未预验证直接
  提交时弹窗兜底。SDK 按钮绑定异步完成,弹窗未出现前按 tick 重试触发,
  并轮询弹窗可见性识别用户关闭
- captchaVerifyParam 复用 turnstile_token 请求字段提交;公开设置下发
  aliyun_captcha_enabled / scene_id / prefix / region
- CSP 放行验证码 CDN:script-src/style-src 加 *.alicdn.com
2026-08-04 20:57:15 +08:00

172 lines
6.3 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"strings"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
)
var (
ErrAliyunCaptchaVerificationFailed = infraerrors.BadRequest("ALIYUN_CAPTCHA_VERIFICATION_FAILED", "aliyun captcha verification failed")
ErrAliyunCaptchaNotConfigured = infraerrors.ServiceUnavailable("ALIYUN_CAPTCHA_NOT_CONFIGURED", "aliyun captcha not configured")
// ErrCaptchaInvalidCredentials 阿里云验证码凭证无效(仅后台保存校验时返回,公开接口错误码不变)
ErrCaptchaInvalidCredentials = infraerrors.BadRequest("CAPTCHA_INVALID_CREDENTIALS", "invalid aliyun captcha credentials")
)
// AliyunCaptchaCredentials 阿里云验证码 2.0 服务端校验所需的完整凭证
type AliyunCaptchaCredentials struct {
AccessKeyID string
AccessKeySecret string
SceneID string
Endpoint string
}
// AliyunCaptchaVerifyResult VerifyIntelligentCaptcha 的归一化结果
type AliyunCaptchaVerifyResult struct {
VerifyResult bool
VerifyCode string // 阿里云细分结果码,仅用于日志
}
// AliyunCaptchaAPIError 阿里云 OpenAPI 业务错误。
// repository 层负责把 SDK 错误归一化为该类型,service 层不依赖 SDK 包。
type AliyunCaptchaAPIError struct {
Code string
Message string
}
func (e *AliyunCaptchaAPIError) Error() string {
return fmt.Sprintf("aliyun captcha api error: %s: %s", e.Code, e.Message)
}
// AliyunCaptchaVerifier 调用阿里云验证码 2.0 服务端校验的端口
type AliyunCaptchaVerifier interface {
VerifyCaptcha(ctx context.Context, cred AliyunCaptchaCredentials, captchaVerifyParam string) (*AliyunCaptchaVerifyResult, error)
}
const (
// AliyunCaptchaRegionCN 中国内地;AliyunCaptchaRegionSGP 新加坡。
// 该值同时下发给前端 AliyunCaptchaConfig.region,两端必须一致。
AliyunCaptchaRegionCN = "cn"
AliyunCaptchaRegionSGP = "sgp"
aliyunCaptchaEndpointCN = "captcha.cn-shanghai.aliyuncs.com"
aliyunCaptchaEndpointSGP = "captcha.ap-southeast-1.aliyuncs.com"
)
// aliyunCaptchaEndpoint 按后台配置的地域返回服务端接入点,未知值回退中国内地
func aliyunCaptchaEndpoint(region string) string {
if region == AliyunCaptchaRegionSGP {
return aliyunCaptchaEndpointSGP
}
return aliyunCaptchaEndpointCN
}
// normalizeAliyunCaptchaRegion 非法值一律视为中国内地
func normalizeAliyunCaptchaRegion(value string) string {
if value == AliyunCaptchaRegionSGP {
return AliyunCaptchaRegionSGP
}
return AliyunCaptchaRegionCN
}
// aliyunCredentialValidationParam 用于后台保存时探测凭证有效性的假验证参数
const aliyunCredentialValidationParam = "sub2api-credential-validation"
// aliyunInvalidCredentialCodes 表示 AK/SK 本身无效的阿里云错误码;
// 其余错误码(如 param 无效)说明签名已通过、凭证可用。
var aliyunInvalidCredentialCodes = map[string]struct{}{
"InvalidAccessKeyId.NotFound": {},
"InvalidAccessKeyId.Inactive": {},
"SignatureDoesNotMatch": {},
"Forbidden.AccessKeyDisabled": {},
"IncompleteSignature": {},
"InvalidSecurityToken.Expired": {},
}
// AliyunCaptchaService 阿里云验证码 2.0 服务端校验
type AliyunCaptchaService struct {
settingService *SettingService
verifier AliyunCaptchaVerifier
}
func NewAliyunCaptchaService(settingService *SettingService, verifier AliyunCaptchaVerifier) *AliyunCaptchaService {
return &AliyunCaptchaService{settingService: settingService, verifier: verifier}
}
func aliyunCaptchaCredentials(config AliyunCaptchaConfig) (AliyunCaptchaCredentials, bool) {
cred := AliyunCaptchaCredentials{
AccessKeyID: strings.TrimSpace(config.AccessKeyID),
AccessKeySecret: strings.TrimSpace(config.AccessKeySecret),
SceneID: strings.TrimSpace(config.SceneID),
Endpoint: aliyunCaptchaEndpoint(config.Region),
}
if cred.AccessKeyID == "" || cred.AccessKeySecret == "" || cred.SceneID == "" {
return AliyunCaptchaCredentials{}, false
}
return cred, true
}
// VerifyParamWithConfig 校验阿里云验证码 2.0 的 captchaVerifyParam。
// 调用异常时返回错误(fail-closed),与 Turnstile 网络错误行为对称。
func (s *AliyunCaptchaService) VerifyParamWithConfig(ctx context.Context, config AliyunCaptchaConfig, captchaVerifyParam string) error {
if s == nil || s.verifier == nil {
return ErrAliyunCaptchaNotConfigured
}
cred, ok := aliyunCaptchaCredentials(config)
if !ok {
logger.LegacyPrintf("service.aliyun_captcha", "%s", "[AliyunCaptcha] credentials not configured")
return ErrAliyunCaptchaNotConfigured
}
if strings.TrimSpace(captchaVerifyParam) == "" {
logger.LegacyPrintf("service.aliyun_captcha", "%s", "[AliyunCaptcha] captchaVerifyParam is empty")
return ErrAliyunCaptchaVerificationFailed
}
result, err := s.verifier.VerifyCaptcha(ctx, cred, captchaVerifyParam)
if err != nil {
logger.LegacyPrintf("service.aliyun_captcha", "[AliyunCaptcha] verify request failed: %v", err)
return fmt.Errorf("%w: verifier request failed", ErrAliyunCaptchaVerificationFailed)
}
if result == nil || !result.VerifyResult {
if result != nil {
logger.LegacyPrintf("service.aliyun_captcha", "[AliyunCaptcha] rejected, verify code: %s", result.VerifyCode)
}
return ErrAliyunCaptchaVerificationFailed
}
return nil
}
// ValidateCredentials 用假验证参数探测阿里云 AK/SK 是否可用(后台保存设置时调用)。
// 凭证类错误码返回 ErrCaptchaInvalidCredentials;正常响应(包括 param 无效导致的
// VerifyResult=false)说明签名通过、凭证有效;其余错误原样返回给管理员排查。
func (s *AliyunCaptchaService) ValidateCredentials(ctx context.Context, accessKeyID, accessKeySecret, sceneID, region string) error {
if s.verifier == nil {
return ErrAliyunCaptchaNotConfigured
}
cred := AliyunCaptchaCredentials{
AccessKeyID: accessKeyID,
AccessKeySecret: accessKeySecret,
SceneID: sceneID,
Endpoint: aliyunCaptchaEndpoint(region),
}
_, err := s.verifier.VerifyCaptcha(ctx, cred, aliyunCredentialValidationParam)
if err != nil {
var apiErr *AliyunCaptchaAPIError
if errors.As(err, &apiErr) {
if _, invalid := aliyunInvalidCredentialCodes[apiErr.Code]; invalid {
return ErrCaptchaInvalidCredentials
}
}
return fmt.Errorf("validate aliyun captcha credentials: %w", err)
}
return nil
}