mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 13:48:43 +08:00
Merge pull request #5338 from Wei-Shaw/fix/tencent-captcha-region-csp
修复腾讯验证码区域适配、重置与 CSP 加载
This commit is contained in:
@@ -32,7 +32,7 @@ const (
|
||||
|
||||
// DefaultCSPPolicy is the default Content-Security-Policy with nonce support
|
||||
// __CSP_NONCE__ will be replaced with actual nonce at request time by the SecurityHeaders middleware
|
||||
const DefaultCSPPolicy = "default-src 'self'; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
||||
const DefaultCSPPolicy = "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
||||
|
||||
// UMQ(用户消息队列)模式常量
|
||||
const (
|
||||
|
||||
@@ -168,6 +168,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
TencentCaptchaAppSecretKeyConfigured: settings.TencentCaptchaAppSecretKeyConfigured,
|
||||
TencentCaptchaCloudSecretIDConfigured: settings.TencentCaptchaCloudSecretIDConfigured,
|
||||
TencentCaptchaCloudSecretKeyConfigured: settings.TencentCaptchaCloudSecretKeyConfigured,
|
||||
TencentCaptchaRegion: settings.TencentCaptchaRegion,
|
||||
AliyunCaptchaEnabled: settings.AliyunCaptchaEnabled,
|
||||
AliyunCaptchaAccessKeyID: settings.AliyunCaptchaAccessKeyID,
|
||||
AliyunCaptchaAccessKeySecretConfigured: settings.AliyunCaptchaAccessKeySecretConfigured,
|
||||
|
||||
@@ -122,6 +122,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if req.TencentCaptchaCloudSecretKey != "" {
|
||||
changed = append(changed, "tencent_captcha_cloud_secret_key")
|
||||
}
|
||||
if before.TencentCaptchaRegion != after.TencentCaptchaRegion {
|
||||
changed = append(changed, "tencent_captcha_region")
|
||||
}
|
||||
if before.AliyunCaptchaEnabled != after.AliyunCaptchaEnabled {
|
||||
changed = append(changed, "aliyun_captcha_enabled")
|
||||
}
|
||||
|
||||
@@ -121,6 +121,43 @@ func TestUpdateSettingsRetainsStoredTencentCaptchaCredentialsWhenInputsEmpty(t *
|
||||
require.Equal(t, "stored-cloud-secret-key", repo.values[service.SettingKeyTencentCaptchaCloudSecretKey])
|
||||
}
|
||||
|
||||
// 天御站点决定前端加载哪个 SDK 与服务端打哪个接入点,两端必须一致。
|
||||
// 部分载荷把它重置回中国站,会让已配国际站的部署在下一次任意保存后整体失效。
|
||||
func TestUpdateSettingsPartialPayloadKeepsTencentCaptchaRegion(t *testing.T) {
|
||||
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
|
||||
service.SettingKeyTencentCaptchaRegion: service.TencentCaptchaRegionINTL,
|
||||
})
|
||||
|
||||
rec := doUpdateSettings(t, h, map[string]any{"risk_control_enabled": true}, nil)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, service.TencentCaptchaRegionINTL,
|
||||
repo.values[service.SettingKeyTencentCaptchaRegion])
|
||||
}
|
||||
|
||||
func TestUpdateSettingsNormalizesUnknownTencentCaptchaRegion(t *testing.T) {
|
||||
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
|
||||
service.SettingKeyTencentCaptchaRegion: service.TencentCaptchaRegionINTL,
|
||||
})
|
||||
|
||||
rec := doUpdateSettings(t, h, map[string]any{"tencent_captcha_region": "sgp"}, nil)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, service.TencentCaptchaRegionCN,
|
||||
repo.values[service.SettingKeyTencentCaptchaRegion],
|
||||
"未知站点必须落回中国站,不能写入无法识别的值")
|
||||
}
|
||||
|
||||
func TestUpdateSettingsWritesTencentCaptchaRegionWhenSent(t *testing.T) {
|
||||
h, repo := newStepUpSwitchTestHandler(t, map[string]string{})
|
||||
|
||||
rec := doUpdateSettings(t, h, map[string]any{"tencent_captcha_region": "intl"}, nil)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, service.TencentCaptchaRegionINTL,
|
||||
repo.values[service.SettingKeyTencentCaptchaRegion])
|
||||
}
|
||||
|
||||
func TestUpdateSettingsValidatesTencentCaptchaAppIDWhenEnabledFlagIsOmitted(t *testing.T) {
|
||||
h, _ := newStepUpSwitchTestHandler(t, map[string]string{
|
||||
service.SettingKeyTencentCaptchaEnabled: "true",
|
||||
|
||||
@@ -60,6 +60,7 @@ type UpdateSettingsRequest struct {
|
||||
TencentCaptchaAppSecretKey string `json:"tencent_captcha_app_secret_key"`
|
||||
TencentCaptchaCloudSecretID string `json:"tencent_captcha_cloud_secret_id"`
|
||||
TencentCaptchaCloudSecretKey string `json:"tencent_captcha_cloud_secret_key"`
|
||||
TencentCaptchaRegion string `json:"tencent_captcha_region"`
|
||||
|
||||
// 阿里云验证码 2.0 设置
|
||||
AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"`
|
||||
@@ -644,6 +645,13 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
if req.AliyunCaptchaRegion != service.AliyunCaptchaRegionSGP {
|
||||
req.AliyunCaptchaRegion = service.AliyunCaptchaRegionCN
|
||||
}
|
||||
// 天御站点 normalize:未发送保留已存值,非法值一律按中国站落库
|
||||
if _, sent := sentFields["tencent_captcha_region"]; !sent {
|
||||
req.TencentCaptchaRegion = previousSettings.TencentCaptchaRegion
|
||||
}
|
||||
if req.TencentCaptchaRegion != service.TencentCaptchaRegionINTL {
|
||||
req.TencentCaptchaRegion = service.TencentCaptchaRegionCN
|
||||
}
|
||||
|
||||
// Turnstile 参数验证
|
||||
if req.TurnstileEnabled {
|
||||
@@ -1501,6 +1509,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
TencentCaptchaAppSecretKey: req.TencentCaptchaAppSecretKey,
|
||||
TencentCaptchaCloudSecretID: req.TencentCaptchaCloudSecretID,
|
||||
TencentCaptchaCloudSecretKey: req.TencentCaptchaCloudSecretKey,
|
||||
TencentCaptchaRegion: req.TencentCaptchaRegion,
|
||||
AliyunCaptchaEnabled: req.AliyunCaptchaEnabled,
|
||||
AliyunCaptchaAccessKeyID: req.AliyunCaptchaAccessKeyID,
|
||||
AliyunCaptchaAccessKeySecret: req.AliyunCaptchaAccessKeySecret,
|
||||
@@ -2084,6 +2093,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
TencentCaptchaAppSecretKeyConfigured: updatedSettings.TencentCaptchaAppSecretKeyConfigured,
|
||||
TencentCaptchaCloudSecretIDConfigured: updatedSettings.TencentCaptchaCloudSecretIDConfigured,
|
||||
TencentCaptchaCloudSecretKeyConfigured: updatedSettings.TencentCaptchaCloudSecretKeyConfigured,
|
||||
TencentCaptchaRegion: updatedSettings.TencentCaptchaRegion,
|
||||
AliyunCaptchaEnabled: updatedSettings.AliyunCaptchaEnabled,
|
||||
AliyunCaptchaAccessKeyID: updatedSettings.AliyunCaptchaAccessKeyID,
|
||||
AliyunCaptchaAccessKeySecretConfigured: updatedSettings.AliyunCaptchaAccessKeySecretConfigured,
|
||||
|
||||
@@ -6,21 +6,88 @@ import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAuthRequestsBindTencentCaptchaProof(t *testing.T) {
|
||||
const payload = `{"email":"user@example.com","password":"secret-123","tencent_captcha_ticket":"ticket-value","tencent_captcha_randstr":"@rand-value"}`
|
||||
|
||||
var login LoginRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(payload), &login))
|
||||
proof := captchaProof(login.TurnstileToken, login.TencentCaptchaTicket, login.TencentCaptchaRandstr)
|
||||
require.Equal(t, "ticket-value", proof.TencentTicket)
|
||||
require.Equal(t, "@rand-value", proof.TencentRandstr)
|
||||
tests := []struct {
|
||||
name string
|
||||
decode func([]byte) service.CaptchaProof
|
||||
}{
|
||||
{
|
||||
name: "登录",
|
||||
decode: func(raw []byte) service.CaptchaProof {
|
||||
var req LoginRequest
|
||||
require.NoError(t, json.Unmarshal(raw, &req))
|
||||
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "注册",
|
||||
decode: func(raw []byte) service.CaptchaProof {
|
||||
var req RegisterRequest
|
||||
require.NoError(t, json.Unmarshal(raw, &req))
|
||||
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "发送邮箱验证码",
|
||||
decode: func(raw []byte) service.CaptchaProof {
|
||||
var req SendVerifyCodeRequest
|
||||
require.NoError(t, json.Unmarshal(raw, &req))
|
||||
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "忘记密码",
|
||||
decode: func(raw []byte) service.CaptchaProof {
|
||||
var req ForgotPasswordRequest
|
||||
require.NoError(t, json.Unmarshal(raw, &req))
|
||||
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OAuth启动",
|
||||
decode: func(raw []byte) service.CaptchaProof {
|
||||
var req oauthStartCaptchaRequest
|
||||
require.NoError(t, json.Unmarshal(raw, &req))
|
||||
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Passkey登录",
|
||||
decode: func(raw []byte) service.CaptchaProof {
|
||||
var req passkeyBeginLoginRequest
|
||||
require.NoError(t, json.Unmarshal(raw, &req))
|
||||
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OAuth待处理账号发送邮箱验证码",
|
||||
decode: func(raw []byte) service.CaptchaProof {
|
||||
var req sendPendingOAuthVerifyCodeRequest
|
||||
require.NoError(t, json.Unmarshal(raw, &req))
|
||||
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OAuth待处理账号创建",
|
||||
decode: func(raw []byte) service.CaptchaProof {
|
||||
var req createPendingOAuthAccountRequest
|
||||
require.NoError(t, json.Unmarshal(raw, &req))
|
||||
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var pending createPendingOAuthAccountRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(payload), &pending))
|
||||
proof = captchaProof(pending.TurnstileToken, pending.TencentCaptchaTicket, pending.TencentCaptchaRandstr)
|
||||
require.Equal(t, "ticket-value", proof.TencentTicket)
|
||||
require.Equal(t, "@rand-value", proof.TencentRandstr)
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
proof := test.decode([]byte(payload))
|
||||
require.Equal(t, "ticket-value", proof.TencentTicket)
|
||||
require.Equal(t, "@rand-value", proof.TencentRandstr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ type SystemSettings struct {
|
||||
TencentCaptchaAppSecretKeyConfigured bool `json:"tencent_captcha_app_secret_key_configured"`
|
||||
TencentCaptchaCloudSecretIDConfigured bool `json:"tencent_captcha_cloud_secret_id_configured"`
|
||||
TencentCaptchaCloudSecretKeyConfigured bool `json:"tencent_captcha_cloud_secret_key_configured"`
|
||||
TencentCaptchaRegion string `json:"tencent_captcha_region"`
|
||||
AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"`
|
||||
AliyunCaptchaAccessKeyID string `json:"aliyun_captcha_access_key_id"`
|
||||
AliyunCaptchaAccessKeySecretConfigured bool `json:"aliyun_captcha_access_key_secret_configured"`
|
||||
@@ -354,6 +355,7 @@ type PublicSettings struct {
|
||||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||
TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"`
|
||||
TencentCaptchaAppID string `json:"tencent_captcha_app_id"`
|
||||
TencentCaptchaRegion string `json:"tencent_captcha_region"`
|
||||
AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"`
|
||||
AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"`
|
||||
AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"`
|
||||
|
||||
@@ -62,6 +62,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
||||
TurnstileSiteKey: settings.TurnstileSiteKey,
|
||||
TencentCaptchaEnabled: settings.TencentCaptchaEnabled,
|
||||
TencentCaptchaAppID: settings.TencentCaptchaAppID,
|
||||
TencentCaptchaRegion: settings.TencentCaptchaRegion,
|
||||
AliyunCaptchaEnabled: settings.AliyunCaptchaEnabled,
|
||||
AliyunCaptchaSceneID: settings.AliyunCaptchaSceneID,
|
||||
AliyunCaptchaPrefix: settings.AliyunCaptchaPrefix,
|
||||
|
||||
@@ -89,6 +89,7 @@ func TestSettingHandler_GetPublicSettings_ExposesTencentCaptchaConfiguration(t *
|
||||
values: map[string]string{
|
||||
service.SettingKeyTencentCaptchaEnabled: "true",
|
||||
service.SettingKeyTencentCaptchaAppID: "123456789",
|
||||
service.SettingKeyTencentCaptchaRegion: service.TencentCaptchaRegionINTL,
|
||||
},
|
||||
}
|
||||
h := NewSettingHandler(service.NewSettingService(repo, &config.Config{}), "test-version")
|
||||
@@ -106,12 +107,14 @@ func TestSettingHandler_GetPublicSettings_ExposesTencentCaptchaConfiguration(t *
|
||||
Data struct {
|
||||
TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"`
|
||||
TencentCaptchaAppID string `json:"tencent_captcha_app_id"`
|
||||
TencentCaptchaRegion string `json:"tencent_captcha_region"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
|
||||
require.Equal(t, 0, resp.Code)
|
||||
require.True(t, resp.Data.TencentCaptchaEnabled)
|
||||
require.Equal(t, "123456789", resp.Data.TencentCaptchaAppID)
|
||||
require.Equal(t, service.TencentCaptchaRegionINTL, resp.Data.TencentCaptchaRegion)
|
||||
}
|
||||
|
||||
func TestSettingHandler_GetPublicSettings_ExposesWeChatOAuthModeCapabilities(t *testing.T) {
|
||||
|
||||
@@ -113,13 +113,13 @@ func resolveCustomForwardedClientIP(c *gin.Context, headers []string) (string, s
|
||||
|
||||
func resolveLegacyForwardedHeaderIP(c *gin.Context) (string, string) {
|
||||
var fallback string
|
||||
if forwarded := normalizeIP(c.GetHeader("CF-Connecting-IP")); forwarded != "" {
|
||||
if forwarded := normalizeValidIP(c.GetHeader("CF-Connecting-IP")); forwarded != "" {
|
||||
fallback = forwarded
|
||||
if !isPrivateIP(forwarded) {
|
||||
return forwarded, fallback
|
||||
}
|
||||
}
|
||||
if realIP := normalizeIP(c.GetHeader("X-Real-IP")); realIP != "" {
|
||||
if realIP := normalizeValidIP(c.GetHeader("X-Real-IP")); realIP != "" {
|
||||
if fallback == "" {
|
||||
fallback = realIP
|
||||
}
|
||||
@@ -130,13 +130,18 @@ func resolveLegacyForwardedHeaderIP(c *gin.Context) (string, string) {
|
||||
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
|
||||
ips := strings.Split(xff, ",")
|
||||
for _, candidate := range ips {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
candidate = normalizeValidIP(candidate)
|
||||
if candidate != "" && !isPrivateIP(candidate) {
|
||||
return normalizeIP(candidate), fallback
|
||||
return candidate, fallback
|
||||
}
|
||||
}
|
||||
if fallback == "" && len(ips) > 0 {
|
||||
fallback = normalizeIP(strings.TrimSpace(ips[0]))
|
||||
if fallback == "" {
|
||||
for _, candidate := range ips {
|
||||
if candidate = normalizeValidIP(candidate); candidate != "" {
|
||||
fallback = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fallback
|
||||
@@ -176,6 +181,16 @@ func normalizeIP(ip string) string {
|
||||
return ip
|
||||
}
|
||||
|
||||
// normalizeValidIP 规范化并验证代理头中的候选值,避免把 unknown、主机名等非法值传给安全服务。
|
||||
func normalizeValidIP(value string) string {
|
||||
normalized := normalizeIP(value)
|
||||
parsed := net.ParseIP(normalized)
|
||||
if parsed == nil {
|
||||
return ""
|
||||
}
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
// privateNets contains the private/loopback ranges skipped while selecting a
|
||||
// public address from a legacy X-Forwarded-For chain.
|
||||
var privateNets []*net.IPNet
|
||||
|
||||
@@ -153,6 +153,26 @@ func TestGetSecurityClientIPCustomHeaderPrecedenceAndFallback(t *testing.T) {
|
||||
},
|
||||
want: "4.4.4.4",
|
||||
},
|
||||
{
|
||||
name: "invalid legacy values continue to a valid forwarded address",
|
||||
trustForward: true,
|
||||
requestHeaders: map[string]string{
|
||||
"CF-Connecting-IP": "unknown",
|
||||
"X-Real-IP": "proxy.internal",
|
||||
"X-Forwarded-For": "also-invalid, 203.0.113.50",
|
||||
},
|
||||
want: "203.0.113.50",
|
||||
},
|
||||
{
|
||||
name: "all invalid legacy values fall back to the connection address",
|
||||
trustForward: true,
|
||||
requestHeaders: map[string]string{
|
||||
"CF-Connecting-IP": "unknown",
|
||||
"X-Real-IP": "proxy.internal",
|
||||
"X-Forwarded-For": "also-invalid",
|
||||
},
|
||||
want: "9.9.9.9",
|
||||
},
|
||||
{
|
||||
name: "disabled mode ignores custom and legacy headers",
|
||||
trustForward: false,
|
||||
|
||||
@@ -10,13 +10,11 @@ import (
|
||||
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
|
||||
)
|
||||
|
||||
const tencentCaptchaEndpoint = "captcha.tencentcloudapi.com"
|
||||
|
||||
type tencentCaptchaAPI interface {
|
||||
DescribeCaptchaResultWithContext(context.Context, *captcha.DescribeCaptchaResultRequest) (*captcha.DescribeCaptchaResultResponse, error)
|
||||
}
|
||||
|
||||
type tencentCaptchaClientFactory func(secretID, secretKey string) (tencentCaptchaAPI, error)
|
||||
type tencentCaptchaClientFactory func(secretID, secretKey, endpoint string) (tencentCaptchaAPI, error)
|
||||
|
||||
type tencentCaptchaVerifier struct {
|
||||
newClient tencentCaptchaClientFactory
|
||||
@@ -26,16 +24,19 @@ func NewTencentCaptchaVerifier() service.TencentCaptchaVerifier {
|
||||
return &tencentCaptchaVerifier{newClient: newTencentCaptchaSDKClient}
|
||||
}
|
||||
|
||||
func newTencentCaptchaSDKClient(secretID, secretKey string) (tencentCaptchaAPI, error) {
|
||||
// newTencentCaptchaSDKClient 接入点由调用方按站点下发(中国站 / 国际站)。
|
||||
// service 层的 tencentCaptchaEndpoint 已保证 endpoint 非空;即便为空,
|
||||
// 腾讯 SDK 也会回落到服务默认域 captcha.tencentcloudapi.com(即中国站),不会失败。
|
||||
func newTencentCaptchaSDKClient(secretID, secretKey, endpoint string) (tencentCaptchaAPI, error) {
|
||||
clientProfile := profile.NewClientProfile()
|
||||
clientProfile.HttpProfile.Endpoint = tencentCaptchaEndpoint
|
||||
clientProfile.HttpProfile.Endpoint = endpoint
|
||||
clientProfile.HttpProfile.ReqMethod = "POST"
|
||||
clientProfile.HttpProfile.ReqTimeout = 5
|
||||
return captcha.NewClient(common.NewCredential(secretID, secretKey), "", clientProfile)
|
||||
}
|
||||
|
||||
func (v *tencentCaptchaVerifier) VerifyTicket(ctx context.Context, credentials service.TencentCaptchaCredentials, proof service.TencentCaptchaProof, remoteIP string) (*service.TencentCaptchaVerifyResponse, error) {
|
||||
client, err := v.newClient(credentials.CloudSecretID, credentials.CloudSecretKey)
|
||||
client, err := v.newClient(credentials.CloudSecretID, credentials.CloudSecretKey, credentials.Endpoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create tencent captcha client: %w", err)
|
||||
}
|
||||
|
||||
@@ -33,10 +33,10 @@ func TestTencentCaptchaVerifierMapsCredentialsRequestAndResponse(t *testing.T) {
|
||||
RequestId: &requestID,
|
||||
},
|
||||
}}
|
||||
var gotSecretID, gotSecretKey string
|
||||
var gotSecretID, gotSecretKey, gotEndpoint string
|
||||
verifier := &tencentCaptchaVerifier{
|
||||
newClient: func(secretID, secretKey string) (tencentCaptchaAPI, error) {
|
||||
gotSecretID, gotSecretKey = secretID, secretKey
|
||||
newClient: func(secretID, secretKey, endpoint string) (tencentCaptchaAPI, error) {
|
||||
gotSecretID, gotSecretKey, gotEndpoint = secretID, secretKey, endpoint
|
||||
return client, nil
|
||||
},
|
||||
}
|
||||
@@ -46,11 +46,14 @@ func TestTencentCaptchaVerifierMapsCredentialsRequestAndResponse(t *testing.T) {
|
||||
AppSecretKey: "app-secret",
|
||||
CloudSecretID: "cloud-secret-id",
|
||||
CloudSecretKey: "cloud-secret-key",
|
||||
Endpoint: "captcha.intl.tencentcloudapi.com",
|
||||
}, service.TencentCaptchaProof{Ticket: "ticket", Randstr: "@rand"}, "203.0.113.10")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "cloud-secret-id", gotSecretID)
|
||||
require.Equal(t, "cloud-secret-key", gotSecretKey)
|
||||
// 接入点由 service 按站点下发,repository 不得再写死国内站
|
||||
require.Equal(t, "captcha.intl.tencentcloudapi.com", gotEndpoint)
|
||||
require.NotNil(t, client.request)
|
||||
require.Equal(t, uint64(9), *client.request.CaptchaType)
|
||||
require.Equal(t, uint64(123456789), *client.request.CaptchaAppId)
|
||||
|
||||
@@ -746,6 +746,7 @@ func TestAPIContracts(t *testing.T) {
|
||||
"tencent_captcha_app_secret_key_configured": false,
|
||||
"tencent_captcha_cloud_secret_id_configured": false,
|
||||
"tencent_captcha_cloud_secret_key_configured": false,
|
||||
"tencent_captcha_region": "cn",
|
||||
"aliyun_captcha_enabled": false,
|
||||
"aliyun_captcha_access_key_id": "",
|
||||
"aliyun_captcha_access_key_secret_configured": false,
|
||||
@@ -1085,6 +1086,7 @@ func TestAPIContracts(t *testing.T) {
|
||||
"tencent_captcha_app_secret_key_configured": false,
|
||||
"tencent_captcha_cloud_secret_id_configured": false,
|
||||
"tencent_captcha_cloud_secret_key_configured": false,
|
||||
"tencent_captcha_region": "cn",
|
||||
"aliyun_captcha_enabled": false,
|
||||
"aliyun_captcha_access_key_id": "",
|
||||
"aliyun_captcha_access_key_secret_configured": false,
|
||||
|
||||
@@ -18,10 +18,25 @@ const (
|
||||
NonceTemplate = "__CSP_NONCE__"
|
||||
// CloudflareInsightsDomain is the domain for Cloudflare Web Analytics
|
||||
CloudflareInsightsDomain = "https://static.cloudflareinsights.com"
|
||||
// TencentCaptchaDomain is the Tencent Captcha 2.0 Web SDK domain.
|
||||
// TencentCaptchaDomain is the Tencent Captcha 2.0 Web SDK domain (Chinese mainland site).
|
||||
TencentCaptchaDomain = "https://turing.captcha.qcloud.com"
|
||||
// TencentCaptchaStaticDomain is the Tencent Captcha static asset domain.
|
||||
TencentCaptchaStaticDomain = "https://*.captcha.gtimg.com"
|
||||
// TencentCaptchaCDNDomain 是天御国内站的核心 JS CDN 主机:
|
||||
// 入口脚本 TJCaptcha.js 会再从这里加载 /1/tgJCap.*.js,缺失时会被 script-src 拦截。
|
||||
TencentCaptchaCDNDomain = "https://turing.captcha.gtimg.com"
|
||||
// TencentCaptchaGlobalDomain 是天御国际站的 Web SDK 与验证弹窗 iframe 主机。
|
||||
TencentCaptchaGlobalDomain = "https://ca.turing.captcha.qcloud.com"
|
||||
// TencentCaptchaGlobalCDNDomain 是天御国际站的核心 JS CDN 主机。
|
||||
TencentCaptchaGlobalCDNDomain = "https://global.turing.captcha.gtimg.com"
|
||||
// TencentCaptchaPrehandleDomain 是天御 SDK 动态预处理脚本与预处理接口主机。
|
||||
TencentCaptchaPrehandleDomain = "https://www.tycaptcha.com"
|
||||
// TencentCaptchaJQueryDomain 是国内站入口脚本动态加载的 jQuery CDN 主机。
|
||||
TencentCaptchaJQueryDomain = "https://cloudcache.tencentcs.com"
|
||||
// TencentCaptchaRceDomain 是国际站风控校验接口主机。
|
||||
TencentCaptchaRceDomain = "https://rce.tencentrio.com"
|
||||
// TencentCaptchaWorkerSource 是天御国际站创建验证码 Web Worker 时使用的来源。
|
||||
TencentCaptchaWorkerSource = "blob:"
|
||||
// StripeDomain is the domain for Stripe.js SDK
|
||||
StripeDomain = "https://*.stripe.com"
|
||||
// AirwallexStaticDomain 是 Airwallex 生产环境 SDK 脚本域名。
|
||||
@@ -42,6 +57,17 @@ var requiredCSPDirectiveValues = []struct {
|
||||
{"script-src", TencentCaptchaDomain},
|
||||
{"frame-src", TencentCaptchaDomain},
|
||||
{"style-src", TencentCaptchaStaticDomain},
|
||||
{"script-src", TencentCaptchaCDNDomain},
|
||||
{"script-src", TencentCaptchaGlobalDomain},
|
||||
{"script-src", TencentCaptchaGlobalCDNDomain},
|
||||
{"script-src", TencentCaptchaPrehandleDomain},
|
||||
{"script-src", TencentCaptchaJQueryDomain},
|
||||
{"connect-src", TencentCaptchaDomain},
|
||||
{"connect-src", TencentCaptchaPrehandleDomain},
|
||||
{"connect-src", TencentCaptchaRceDomain},
|
||||
{"frame-src", TencentCaptchaGlobalDomain},
|
||||
{"frame-src", TencentCaptchaPrehandleDomain},
|
||||
{"worker-src", TencentCaptchaWorkerSource},
|
||||
{"script-src", StripeDomain},
|
||||
{"frame-src", StripeDomain},
|
||||
{"script-src", AirwallexStaticDomain},
|
||||
|
||||
@@ -129,6 +129,7 @@ func TestSecurityHeaders(t *testing.T) {
|
||||
assert.Contains(t, csp, "default-src 'self'")
|
||||
assert.Contains(t, csp, "'nonce-")
|
||||
assert.Contains(t, csp, CloudflareInsightsDomain)
|
||||
assert.Equal(t, 1, countDirectiveValue(csp, "worker-src", TencentCaptchaWorkerSource))
|
||||
})
|
||||
|
||||
t.Run("api_route_skips_csp_nonce_generation", func(t *testing.T) {
|
||||
@@ -322,6 +323,35 @@ func TestEnhanceCSPPolicy(t *testing.T) {
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", TencentCaptchaDomain))
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "style-src", TencentCaptchaStaticDomain))
|
||||
assert.Contains(t, config.DefaultCSPPolicy, "style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com")
|
||||
|
||||
// 入口脚本会再从 CDN 拉核心 JS,国际站还会换用 ca./global. 两个主机;
|
||||
// 缺任意一个都会让天御 SDK 触发 script-src 拦截。
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaCDNDomain))
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaGlobalDomain))
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaGlobalCDNDomain))
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaPrehandleDomain))
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaJQueryDomain))
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "connect-src", TencentCaptchaDomain))
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "connect-src", TencentCaptchaPrehandleDomain))
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "connect-src", TencentCaptchaRceDomain))
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", TencentCaptchaGlobalDomain))
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", TencentCaptchaPrehandleDomain))
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "worker-src", TencentCaptchaWorkerSource))
|
||||
})
|
||||
|
||||
t.Run("does_not_duplicate_tencent_captcha_worker_source", func(t *testing.T) {
|
||||
policy := "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__"
|
||||
enhanced := enhanceCSPPolicy(policy)
|
||||
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "worker-src", TencentCaptchaWorkerSource))
|
||||
})
|
||||
|
||||
t.Run("default_policy_already_carries_tencent_captcha_domains", func(t *testing.T) {
|
||||
// 默认策略与中间件强制注入表必须同形,否则 config.example.yaml 会误导自建用户
|
||||
for _, required := range requiredCSPDirectiveValues {
|
||||
assert.Equal(t, 1, countDirectiveValue(config.DefaultCSPPolicy, required.directive, required.value),
|
||||
"DefaultCSPPolicy 缺少 %s %s", required.directive, required.value)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("handles_policy_without_script_src", func(t *testing.T) {
|
||||
|
||||
@@ -170,6 +170,7 @@ const (
|
||||
SettingKeyTencentCaptchaAppSecretKey = "tencent_captcha_app_secret_key"
|
||||
SettingKeyTencentCaptchaCloudSecretID = "tencent_captcha_cloud_secret_id"
|
||||
SettingKeyTencentCaptchaCloudSecretKey = "tencent_captcha_cloud_secret_key"
|
||||
SettingKeyTencentCaptchaRegion = "tencent_captcha_region" // 站点:"cn"|"intl",决定前端 SDK 脚本与服务端接入点
|
||||
|
||||
// 阿里云验证码 2.0 设置(与 Turnstile、腾讯天御互斥,同一时间仅可启用一家)
|
||||
SettingKeyAliyunCaptchaEnabled = "aliyun_captcha_enabled" // 是否启用阿里云验证码
|
||||
|
||||
@@ -463,6 +463,7 @@ type TencentCaptchaConfig struct {
|
||||
AppSecretKey string
|
||||
CloudSecretID string
|
||||
CloudSecretKey string
|
||||
Region string
|
||||
}
|
||||
|
||||
// AliyunCaptchaConfig contains the credentials required by Aliyun Captcha 2.0's
|
||||
@@ -491,6 +492,7 @@ func (s *SettingService) GetCaptchaProviderConfig(ctx context.Context) (CaptchaP
|
||||
SettingKeyTencentCaptchaAppSecretKey,
|
||||
SettingKeyTencentCaptchaCloudSecretID,
|
||||
SettingKeyTencentCaptchaCloudSecretKey,
|
||||
SettingKeyTencentCaptchaRegion,
|
||||
SettingKeyAliyunCaptchaEnabled,
|
||||
SettingKeyAliyunCaptchaAccessKeyID,
|
||||
SettingKeyAliyunCaptchaAccessKeySecret,
|
||||
@@ -509,6 +511,7 @@ func (s *SettingService) GetCaptchaProviderConfig(ctx context.Context) (CaptchaP
|
||||
AppSecretKey: values[SettingKeyTencentCaptchaAppSecretKey],
|
||||
CloudSecretID: values[SettingKeyTencentCaptchaCloudSecretID],
|
||||
CloudSecretKey: values[SettingKeyTencentCaptchaCloudSecretKey],
|
||||
Region: normalizeTencentCaptchaRegion(values[SettingKeyTencentCaptchaRegion]),
|
||||
},
|
||||
Aliyun: AliyunCaptchaConfig{
|
||||
Enabled: values[SettingKeyAliyunCaptchaEnabled] == "true",
|
||||
|
||||
@@ -329,6 +329,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
||||
TencentCaptchaAppSecretKeyConfigured: settings[SettingKeyTencentCaptchaAppSecretKey] != "",
|
||||
TencentCaptchaCloudSecretIDConfigured: settings[SettingKeyTencentCaptchaCloudSecretID] != "",
|
||||
TencentCaptchaCloudSecretKeyConfigured: settings[SettingKeyTencentCaptchaCloudSecretKey] != "",
|
||||
TencentCaptchaRegion: normalizeTencentCaptchaRegion(settings[SettingKeyTencentCaptchaRegion]),
|
||||
AliyunCaptchaEnabled: settings[SettingKeyAliyunCaptchaEnabled] == "true",
|
||||
AliyunCaptchaAccessKeyID: settings[SettingKeyAliyunCaptchaAccessKeyID],
|
||||
AliyunCaptchaAccessKeySecretConfigured: settings[SettingKeyAliyunCaptchaAccessKeySecret] != "",
|
||||
|
||||
@@ -173,6 +173,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
SettingKeyTurnstileSiteKey,
|
||||
SettingKeyTencentCaptchaEnabled,
|
||||
SettingKeyTencentCaptchaAppID,
|
||||
SettingKeyTencentCaptchaRegion,
|
||||
SettingKeyAliyunCaptchaEnabled,
|
||||
SettingKeyAliyunCaptchaSceneID,
|
||||
SettingKeyAliyunCaptchaPrefix,
|
||||
@@ -309,6 +310,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
|
||||
TencentCaptchaEnabled: settings[SettingKeyTencentCaptchaEnabled] == "true",
|
||||
TencentCaptchaAppID: settings[SettingKeyTencentCaptchaAppID],
|
||||
TencentCaptchaRegion: normalizeTencentCaptchaRegion(settings[SettingKeyTencentCaptchaRegion]),
|
||||
AliyunCaptchaEnabled: settings[SettingKeyAliyunCaptchaEnabled] == "true",
|
||||
AliyunCaptchaSceneID: settings[SettingKeyAliyunCaptchaSceneID],
|
||||
AliyunCaptchaPrefix: settings[SettingKeyAliyunCaptchaPrefix],
|
||||
@@ -504,6 +506,7 @@ type PublicSettingsInjectionPayload struct {
|
||||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||
TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"`
|
||||
TencentCaptchaAppID string `json:"tencent_captcha_app_id"`
|
||||
TencentCaptchaRegion string `json:"tencent_captcha_region"`
|
||||
AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"`
|
||||
AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"`
|
||||
AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"`
|
||||
@@ -583,6 +586,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
||||
TurnstileSiteKey: settings.TurnstileSiteKey,
|
||||
TencentCaptchaEnabled: settings.TencentCaptchaEnabled,
|
||||
TencentCaptchaAppID: settings.TencentCaptchaAppID,
|
||||
TencentCaptchaRegion: settings.TencentCaptchaRegion,
|
||||
AliyunCaptchaEnabled: settings.AliyunCaptchaEnabled,
|
||||
AliyunCaptchaSceneID: settings.AliyunCaptchaSceneID,
|
||||
AliyunCaptchaPrefix: settings.AliyunCaptchaPrefix,
|
||||
|
||||
@@ -221,6 +221,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
|
||||
if settings.TencentCaptchaCloudSecretKey != "" {
|
||||
updates[SettingKeyTencentCaptchaCloudSecretKey] = settings.TencentCaptchaCloudSecretKey
|
||||
}
|
||||
updates[SettingKeyTencentCaptchaRegion] = normalizeTencentCaptchaRegion(settings.TencentCaptchaRegion)
|
||||
// 阿里云验证码 2.0 设置(只有非空才更新密钥)
|
||||
updates[SettingKeyAliyunCaptchaEnabled] = strconv.FormatBool(settings.AliyunCaptchaEnabled)
|
||||
updates[SettingKeyAliyunCaptchaAccessKeyID] = settings.AliyunCaptchaAccessKeyID
|
||||
|
||||
@@ -50,6 +50,7 @@ type SystemSettings struct {
|
||||
TencentCaptchaCloudSecretIDConfigured bool
|
||||
TencentCaptchaCloudSecretKey string
|
||||
TencentCaptchaCloudSecretKeyConfigured bool
|
||||
TencentCaptchaRegion string
|
||||
AliyunCaptchaEnabled bool
|
||||
AliyunCaptchaAccessKeyID string
|
||||
AliyunCaptchaAccessKeySecret string
|
||||
@@ -319,6 +320,7 @@ type PublicSettings struct {
|
||||
TurnstileSiteKey string
|
||||
TencentCaptchaEnabled bool
|
||||
TencentCaptchaAppID string
|
||||
TencentCaptchaRegion string
|
||||
AliyunCaptchaEnabled bool
|
||||
AliyunCaptchaSceneID string
|
||||
AliyunCaptchaPrefix string
|
||||
|
||||
@@ -25,6 +25,35 @@ type TencentCaptchaCredentials struct {
|
||||
AppSecretKey string
|
||||
CloudSecretID string
|
||||
CloudSecretKey string
|
||||
// Endpoint 服务端票据校验接入点,由地域推导,repository 层直接使用
|
||||
Endpoint string
|
||||
}
|
||||
|
||||
const (
|
||||
// TencentCaptchaRegionCN 中国站(cloud.tencent.com);TencentCaptchaRegionINTL 国际站(tencentcloud.com)。
|
||||
// 该值同时决定前端加载的 SDK 脚本与服务端校验接入点,两端必须一致:
|
||||
// 国际站 CaptchaAppId 配国内站 SDK 会被腾讯直接判为「appid 所属地域与实际使用地域不符」。
|
||||
TencentCaptchaRegionCN = "cn"
|
||||
TencentCaptchaRegionINTL = "intl"
|
||||
|
||||
tencentCaptchaEndpointCN = "captcha.tencentcloudapi.com"
|
||||
tencentCaptchaEndpointINTL = "captcha.intl.tencentcloudapi.com"
|
||||
)
|
||||
|
||||
// tencentCaptchaEndpoint 按后台配置的地域返回服务端接入点,未知值回退中国站
|
||||
func tencentCaptchaEndpoint(region string) string {
|
||||
if region == TencentCaptchaRegionINTL {
|
||||
return tencentCaptchaEndpointINTL
|
||||
}
|
||||
return tencentCaptchaEndpointCN
|
||||
}
|
||||
|
||||
// normalizeTencentCaptchaRegion 非法值一律视为中国站
|
||||
func normalizeTencentCaptchaRegion(value string) string {
|
||||
if value == TencentCaptchaRegionINTL {
|
||||
return TencentCaptchaRegionINTL
|
||||
}
|
||||
return TencentCaptchaRegionCN
|
||||
}
|
||||
|
||||
type TencentCaptchaVerifyResponse struct {
|
||||
@@ -78,12 +107,31 @@ func (s *TencentCaptchaService) VerifyTicketWithConfig(ctx context.Context, conf
|
||||
|
||||
result, err := s.verifier.VerifyTicket(ctx, credentials, proof, remoteIP)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.tencent_captcha", "%s", "[TencentCaptcha] verification request failed")
|
||||
logger.LegacyPrintf(
|
||||
"service.tencent_captcha",
|
||||
"[TencentCaptcha] verification request failed region=%s endpoint=%s error=%v",
|
||||
normalizeTencentCaptchaRegion(config.Region),
|
||||
credentials.Endpoint,
|
||||
err,
|
||||
)
|
||||
return fmt.Errorf("%w: verifier request failed", ErrTencentCaptchaVerificationFailed)
|
||||
}
|
||||
if result == nil || result.CaptchaCode != 1 {
|
||||
if result != nil {
|
||||
logger.LegacyPrintf("service.tencent_captcha", "[TencentCaptcha] rejected code=%d request_id=%s", result.CaptchaCode, result.RequestID)
|
||||
logger.LegacyPrintf(
|
||||
"service.tencent_captcha",
|
||||
"[TencentCaptcha] rejected region=%s code=%d message=%q request_id=%q",
|
||||
normalizeTencentCaptchaRegion(config.Region),
|
||||
result.CaptchaCode,
|
||||
result.CaptchaMsg,
|
||||
result.RequestID,
|
||||
)
|
||||
} else {
|
||||
logger.LegacyPrintf(
|
||||
"service.tencent_captcha",
|
||||
"[TencentCaptcha] rejected region=%s empty_response=true",
|
||||
normalizeTencentCaptchaRegion(config.Region),
|
||||
)
|
||||
}
|
||||
return ErrTencentCaptchaVerificationFailed
|
||||
}
|
||||
@@ -100,6 +148,7 @@ func parseTencentCaptchaCredentials(config TencentCaptchaConfig) (TencentCaptcha
|
||||
AppSecretKey: strings.TrimSpace(config.AppSecretKey),
|
||||
CloudSecretID: strings.TrimSpace(config.CloudSecretID),
|
||||
CloudSecretKey: strings.TrimSpace(config.CloudSecretKey),
|
||||
Endpoint: tencentCaptchaEndpoint(config.Region),
|
||||
}
|
||||
if credentials.AppSecretKey == "" || credentials.CloudSecretID == "" || credentials.CloudSecretKey == "" {
|
||||
return TencentCaptchaCredentials{}, false
|
||||
|
||||
@@ -12,31 +12,65 @@ import (
|
||||
)
|
||||
|
||||
type tencentCaptchaVerifierStub struct {
|
||||
response *TencentCaptchaVerifyResponse
|
||||
err error
|
||||
calls int
|
||||
proof TencentCaptchaProof
|
||||
remoteIP string
|
||||
response *TencentCaptchaVerifyResponse
|
||||
err error
|
||||
calls int
|
||||
proof TencentCaptchaProof
|
||||
remoteIP string
|
||||
credentials TencentCaptchaCredentials
|
||||
}
|
||||
|
||||
func (s *tencentCaptchaVerifierStub) VerifyTicket(_ context.Context, _ TencentCaptchaCredentials, proof TencentCaptchaProof, remoteIP string) (*TencentCaptchaVerifyResponse, error) {
|
||||
func (s *tencentCaptchaVerifierStub) VerifyTicket(_ context.Context, credentials TencentCaptchaCredentials, proof TencentCaptchaProof, remoteIP string) (*TencentCaptchaVerifyResponse, error) {
|
||||
s.calls++
|
||||
s.credentials = credentials
|
||||
s.proof = proof
|
||||
s.remoteIP = remoteIP
|
||||
return s.response, s.err
|
||||
}
|
||||
|
||||
func newTencentCaptchaTestService(verifier TencentCaptchaVerifier) *TencentCaptchaService {
|
||||
settings := NewSettingService(&settingPublicRepoStub{values: map[string]string{
|
||||
return newTencentCaptchaTestServiceWithRegion(verifier, "")
|
||||
}
|
||||
|
||||
func newTencentCaptchaTestServiceWithRegion(verifier TencentCaptchaVerifier, region string) *TencentCaptchaService {
|
||||
values := map[string]string{
|
||||
SettingKeyTencentCaptchaEnabled: "true",
|
||||
SettingKeyTencentCaptchaAppID: "123456789",
|
||||
SettingKeyTencentCaptchaAppSecretKey: "app-secret",
|
||||
SettingKeyTencentCaptchaCloudSecretID: "cloud-secret-id",
|
||||
SettingKeyTencentCaptchaCloudSecretKey: "cloud-secret-key",
|
||||
}}, &config.Config{})
|
||||
}
|
||||
if region != "" {
|
||||
values[SettingKeyTencentCaptchaRegion] = region
|
||||
}
|
||||
settings := NewSettingService(&settingPublicRepoStub{values: values}, &config.Config{})
|
||||
return NewTencentCaptchaService(settings, verifier)
|
||||
}
|
||||
|
||||
// 站点决定服务端票据校验接入点:国际站账号的密钥在国内站接入点上无法通过鉴权,
|
||||
// 因此这条映射一旦错位,国际站验证码会整体失效。
|
||||
func TestTencentCaptchaServiceRoutesVerifyEndpointByRegion(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
region string
|
||||
wantEndpoint string
|
||||
}{
|
||||
{"未配置回落中国站", "", "captcha.tencentcloudapi.com"},
|
||||
{"中国站", TencentCaptchaRegionCN, "captcha.tencentcloudapi.com"},
|
||||
{"国际站", TencentCaptchaRegionINTL, "captcha.intl.tencentcloudapi.com"},
|
||||
{"非法值回落中国站", "sgp", "captcha.tencentcloudapi.com"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}}
|
||||
svc := newTencentCaptchaTestServiceWithRegion(verifier, tc.region)
|
||||
|
||||
require.NoError(t, svc.VerifyTicket(context.Background(), "ticket", "@rand", "203.0.113.10"))
|
||||
require.Equal(t, tc.wantEndpoint, verifier.credentials.Endpoint)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTencentCaptchaServiceAcceptsCaptchaCodeOne(t *testing.T) {
|
||||
verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}}
|
||||
svc := newTencentCaptchaTestService(verifier)
|
||||
|
||||
@@ -40,12 +40,15 @@ func TestSettingService_GetPublicSettingsExposesOnlyTencentCaptchaAppID(t *testi
|
||||
SettingKeyTencentCaptchaAppSecretKey: "app-secret",
|
||||
SettingKeyTencentCaptchaCloudSecretID: "cloud-secret-id",
|
||||
SettingKeyTencentCaptchaCloudSecretKey: "cloud-secret-key",
|
||||
SettingKeyTencentCaptchaRegion: TencentCaptchaRegionINTL,
|
||||
}}, &config.Config{})
|
||||
|
||||
settings, err := svc.GetPublicSettings(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.True(t, settings.TencentCaptchaEnabled)
|
||||
require.Equal(t, "123456789", settings.TencentCaptchaAppID)
|
||||
// 站点必须原样公开下发:前端据此决定加载哪个站点的 SDK 脚本与构造函数形态。
|
||||
require.Equal(t, TencentCaptchaRegionINTL, settings.TencentCaptchaRegion)
|
||||
|
||||
raw, err := json.Marshal(settings)
|
||||
require.NoError(t, err)
|
||||
@@ -72,5 +75,7 @@ func TestSettingService_GetTencentCaptchaConfig(t *testing.T) {
|
||||
AppSecretKey: "app-secret",
|
||||
CloudSecretID: "cloud-secret-id",
|
||||
CloudSecretKey: "cloud-secret-key",
|
||||
// 未配置站点时回落中国站,保持存量部署行为不变
|
||||
Region: TencentCaptchaRegionCN,
|
||||
}, got)
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ security:
|
||||
# 默认 CSP 策略(如果静态资源托管在其他域名,请自行覆盖)
|
||||
# Note: __CSP_NONCE__ will be replaced with 'nonce-xxx' at request time for inline script security
|
||||
# 注意:__CSP_NONCE__ 会在请求时被替换为 'nonce-xxx',用于内联脚本安全
|
||||
policy: "default-src 'self'; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
||||
policy: "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
||||
proxy_probe:
|
||||
# Allow skipping TLS verification for proxy probe (debug only)
|
||||
# 允许代理探测时跳过 TLS 证书验证(仅用于调试)
|
||||
|
||||
@@ -464,6 +464,7 @@ export interface SystemSettings {
|
||||
tencent_captcha_app_secret_key_configured: boolean;
|
||||
tencent_captcha_cloud_secret_id_configured: boolean;
|
||||
tencent_captcha_cloud_secret_key_configured: boolean;
|
||||
tencent_captcha_region: string;
|
||||
aliyun_captcha_enabled: boolean;
|
||||
aliyun_captcha_access_key_id: string;
|
||||
aliyun_captcha_access_key_secret_configured: boolean;
|
||||
@@ -789,6 +790,7 @@ export interface UpdateSettingsRequest {
|
||||
tencent_captcha_app_secret_key?: string;
|
||||
tencent_captcha_cloud_secret_id?: string;
|
||||
tencent_captcha_cloud_secret_key?: string;
|
||||
tencent_captcha_region?: string;
|
||||
aliyun_captcha_enabled?: boolean;
|
||||
aliyun_captcha_access_key_id?: string;
|
||||
aliyun_captcha_access_key_secret?: string;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
v-else-if="tencentEnabled && tencentAppId"
|
||||
ref="tencentRef"
|
||||
:app-id="tencentAppId"
|
||||
:region="tencentRegion"
|
||||
/>
|
||||
<AliyunCaptchaWidget
|
||||
v-else-if="aliyunEnabled && aliyunSceneId && aliyunPrefix"
|
||||
@@ -43,6 +44,7 @@ const props = defineProps<{
|
||||
turnstileSiteKey: string
|
||||
tencentEnabled: boolean
|
||||
tencentAppId: string
|
||||
tencentRegion?: string
|
||||
aliyunEnabled?: boolean
|
||||
aliyunSceneId?: string
|
||||
aliyunPrefix?: string
|
||||
|
||||
@@ -1,44 +1,74 @@
|
||||
<template>
|
||||
<span v-if="false" />
|
||||
<div
|
||||
v-if="isInternational"
|
||||
ref="internationalContainerRef"
|
||||
data-testid="tencent-captcha-international-container"
|
||||
:class="
|
||||
internationalContainerVisible
|
||||
? 'flex min-h-[60px] w-full justify-center'
|
||||
: 'pointer-events-none fixed left-1/2 top-0 flex h-[60px] w-[302px] -translate-x-1/2 scale-[0.01] opacity-0'
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
loadTencentCaptcha,
|
||||
normalizeTencentCaptchaRegion,
|
||||
type TencentCaptchaProof,
|
||||
type TencentCaptchaResult
|
||||
} from '@/utils/tencentCaptcha'
|
||||
|
||||
const { locale } = useI18n()
|
||||
const props = defineProps<{ appId: string }>()
|
||||
const props = withDefaults(defineProps<{ appId: string; region?: string }>(), { region: 'cn' })
|
||||
const isInternational = computed(
|
||||
() => normalizeTencentCaptchaRegion(props.region) === 'intl'
|
||||
)
|
||||
const internationalContainerRef = ref<HTMLDivElement | null>(null)
|
||||
const internationalContainerVisible = ref<boolean>(isInternational.value)
|
||||
|
||||
let instance: { show(): void; destroy(): void } | null = null
|
||||
let pending: Promise<TencentCaptchaProof | null> | null = null
|
||||
let cancelPending: (() => void) | null = null
|
||||
let cachedProof: TencentCaptchaProof | null = null
|
||||
let cachedProofCreatedAt = 0
|
||||
let isMounted = false
|
||||
|
||||
function createVerificationPromise(): Promise<TencentCaptchaProof | null> {
|
||||
// 腾讯国际站票据官方有效期为 5 分钟,提前 1 分钟放弃缓存,避免提交时刚好过期。
|
||||
const cachedProofMaxAgeMs = 4 * 60 * 1000
|
||||
|
||||
function createVerificationPromise(revealInternational: boolean = true): Promise<TencentCaptchaProof | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
|
||||
const finish = (callback: () => void): void => {
|
||||
const finish = (callback: () => void, keepInternationalContainer: boolean = false): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (cancelPending === cancel) cancelPending = null
|
||||
instance?.destroy()
|
||||
instance = null
|
||||
if (!keepInternationalContainer) {
|
||||
instance?.destroy()
|
||||
instance = null
|
||||
internationalContainerVisible.value = false
|
||||
}
|
||||
callback()
|
||||
}
|
||||
const cancel = (): void => finish(() => resolve(null))
|
||||
|
||||
cancelPending = cancel
|
||||
void loadTencentCaptcha()
|
||||
const region = normalizeTencentCaptchaRegion(props.region)
|
||||
if (region === 'intl' && revealInternational) {
|
||||
internationalContainerVisible.value = true
|
||||
}
|
||||
|
||||
void nextTick()
|
||||
.then(() => loadTencentCaptcha(region))
|
||||
.then((TencentCaptcha) => {
|
||||
if (cancelPending !== cancel) return
|
||||
|
||||
const userLanguage = locale.value.toLowerCase().startsWith('zh') ? 'zh-cn' : 'en'
|
||||
instance = new TencentCaptcha(props.appId, (result: TencentCaptchaResult) => {
|
||||
const handleResult = (result: TencentCaptchaResult): void => {
|
||||
if (result.ret === 2) {
|
||||
finish(() => resolve(null))
|
||||
return
|
||||
@@ -51,8 +81,27 @@ function createVerificationPromise(): Promise<TencentCaptchaProof | null> {
|
||||
return
|
||||
}
|
||||
|
||||
finish(() => resolve({ ticket, randstr }))
|
||||
}, { userLanguage })
|
||||
// 国际站保留已勾选的控件,避免成功回调后页面出现跳动;登录流程结束时由 reset 清理。
|
||||
finish(() => resolve({ ticket, randstr }), region === 'intl')
|
||||
}
|
||||
|
||||
if (region === 'intl') {
|
||||
// 新版国际站先在指定容器内展示 Robot checkbox,用户点击后才弹出挑战。
|
||||
// document.body 会把 checkbox 追加到整个页面末尾,在登录页上通常落到视口外,
|
||||
// 表现为 SDK 已成功初始化但页面没有任何可见组件。
|
||||
const container = internationalContainerRef.value
|
||||
if (!container) {
|
||||
throw new Error('Tencent Captcha international container is unavailable')
|
||||
}
|
||||
instance = new TencentCaptcha(container, props.appId, handleResult, {
|
||||
// 国际站与国内站保持一致:页面加载后显示 Robot checkbox,由用户主动确认。
|
||||
enableAutoCheck: false,
|
||||
userLanguage,
|
||||
type: 'popup'
|
||||
})
|
||||
} else {
|
||||
instance = new TencentCaptcha(props.appId, handleResult, { userLanguage })
|
||||
}
|
||||
instance.show()
|
||||
})
|
||||
.catch((error: unknown) => finish(() => reject(error)))
|
||||
@@ -60,20 +109,95 @@ function createVerificationPromise(): Promise<TencentCaptchaProof | null> {
|
||||
}
|
||||
|
||||
function verify(): Promise<TencentCaptchaProof | null> {
|
||||
if (pending) return pending
|
||||
pending = createVerificationPromise().finally(() => {
|
||||
pending = null
|
||||
})
|
||||
return pending
|
||||
if (cachedProof) {
|
||||
if (Date.now() - cachedProofCreatedAt >= cachedProofMaxAgeMs) {
|
||||
// 过期票据不能再次提交;先同步销毁旧实例,再在本次调用中创建新验证。
|
||||
reset(false)
|
||||
} else {
|
||||
const proof = cachedProof
|
||||
cachedProof = null
|
||||
cachedProofCreatedAt = 0
|
||||
// 预加载 promise 已经完成,消费缓存时同步清除引用,避免同一票据被并发复用。
|
||||
pending = null
|
||||
return Promise.resolve(proof)
|
||||
}
|
||||
}
|
||||
|
||||
if (pending) {
|
||||
const verification = pending
|
||||
internationalContainerVisible.value = true
|
||||
// 预加载阶段可能因容器不可见而被 SDK 延迟绘制,提交时在容器显示后重新触发同一个实例。
|
||||
void nextTick().then(() => {
|
||||
if (pending === verification) instance?.show()
|
||||
})
|
||||
return verification.then((proof) => {
|
||||
if (cachedProof === proof) {
|
||||
cachedProof = null
|
||||
cachedProofCreatedAt = 0
|
||||
}
|
||||
return proof
|
||||
})
|
||||
}
|
||||
|
||||
internationalContainerVisible.value = true
|
||||
const verification = createVerificationPromise(true)
|
||||
pending = verification
|
||||
void verification.then(
|
||||
() => {
|
||||
if (pending === verification) pending = null
|
||||
},
|
||||
() => {
|
||||
if (pending === verification) pending = null
|
||||
}
|
||||
)
|
||||
return verification
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
function preload(): void {
|
||||
if (!isInternational.value || pending || cachedProof) return
|
||||
|
||||
// 国际站要求首屏直接展示 checkbox,避免用户点击登录后才看到验证码。
|
||||
const verification = createVerificationPromise(true)
|
||||
pending = verification
|
||||
void verification
|
||||
.then((proof) => {
|
||||
if (proof) {
|
||||
cachedProof = proof
|
||||
cachedProofCreatedAt = Date.now()
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (pending === verification) pending = null
|
||||
})
|
||||
}
|
||||
|
||||
function reset(reinitialize: boolean = true): void {
|
||||
instance?.destroy()
|
||||
instance = null
|
||||
cancelPending?.()
|
||||
cancelPending = null
|
||||
pending = null
|
||||
cachedProof = null
|
||||
cachedProofCreatedAt = 0
|
||||
internationalContainerVisible.value = isInternational.value
|
||||
|
||||
// 国际站的票据一次性使用,登录失败后需要立即创建新的 checkbox,
|
||||
// 不能等下一次点击登录才重新初始化。卸载阶段不再启动预加载。
|
||||
if (reinitialize && isMounted && isInternational.value) {
|
||||
void nextTick().then(() => {
|
||||
if (isMounted) preload()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(reset)
|
||||
onMounted(() => {
|
||||
isMounted = true
|
||||
preload()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
isMounted = false
|
||||
reset()
|
||||
})
|
||||
defineExpose({ verify, reset })
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import TencentCaptchaGate from '@/components/TencentCaptchaGate.vue'
|
||||
import { resetTencentCaptchaLoaderForTest } from '@/utils/tencentCaptcha'
|
||||
import { loadTencentCaptcha, resetTencentCaptchaLoaderForTest } from '@/utils/tencentCaptcha'
|
||||
|
||||
const locale = { value: 'zh' }
|
||||
|
||||
@@ -20,7 +20,10 @@ describe('TencentCaptchaGate', () => {
|
||||
beforeEach(() => {
|
||||
locale.value = 'zh'
|
||||
delete window.TencentCaptcha
|
||||
document.head.querySelectorAll('script[src*="TJCaptcha.js"]').forEach((node) => node.remove())
|
||||
delete window.TCaptchaGlobal
|
||||
document.head
|
||||
.querySelectorAll('script[src*="TJCaptcha.js"], script[src*="TJNCaptcha-global.js"]')
|
||||
.forEach((node) => node.remove())
|
||||
resetTencentCaptchaLoaderForTest()
|
||||
})
|
||||
|
||||
@@ -69,6 +72,7 @@ describe('TencentCaptchaGate', () => {
|
||||
it('rejects SDK load failures and disaster-recovery tickets', async () => {
|
||||
const failedLoad = mount(TencentCaptchaGate, { props: { appId: '123456789' } })
|
||||
const loadVerification = failedLoad.vm.verify()
|
||||
await flushPromises()
|
||||
const script = document.head.querySelector<HTMLScriptElement>('script[src*="TJCaptcha.js"]')
|
||||
expect(script).not.toBeNull()
|
||||
script?.dispatchEvent(new Event('error'))
|
||||
@@ -112,6 +116,195 @@ describe('TencentCaptchaGate', () => {
|
||||
expect(show).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// 国际站新版 SDK 会先把 Robot checkbox 渲染到首参容器,点击后才弹出挑战。
|
||||
// 容器必须位于当前表单中;传 document.body 会让 checkbox 落在页面末尾并超出视口。
|
||||
it('passes a visible in-form container first for the international site', async () => {
|
||||
const args: unknown[][] = []
|
||||
let callback: ((result: CaptchaResult) => void) | undefined
|
||||
window.TCaptchaGlobal = true
|
||||
window.TencentCaptcha = class {
|
||||
constructor(...received: unknown[]) {
|
||||
args.push(received)
|
||||
callback = received[2] as (result: CaptchaResult) => void
|
||||
}
|
||||
show = vi.fn()
|
||||
destroy = vi.fn()
|
||||
} as unknown as typeof window.TencentCaptcha
|
||||
const wrapper = mount(TencentCaptchaGate, {
|
||||
props: { appId: '123456789', region: 'intl' }
|
||||
})
|
||||
|
||||
const verification = wrapper.vm.verify()
|
||||
await flushPromises()
|
||||
|
||||
const container = wrapper.get('[data-testid="tencent-captcha-international-container"]')
|
||||
expect(args).toHaveLength(1)
|
||||
expect(args[0][0]).toBe(container.element)
|
||||
expect(args[0][0]).not.toBe(document.body)
|
||||
await vi.waitFor(() => expect(container.classes()).not.toContain('scale-[0.01]'))
|
||||
expect(args[0][1]).toBe('123456789')
|
||||
expect(typeof args[0][2]).toBe('function')
|
||||
expect(args[0][3]).toMatchObject({ enableAutoCheck: false, type: 'popup' })
|
||||
|
||||
callback?.({ ret: 0, ticket: 'ticket-value', randstr: 'rand-value' })
|
||||
await expect(verification).resolves.toEqual({
|
||||
ticket: 'ticket-value',
|
||||
randstr: 'rand-value'
|
||||
})
|
||||
expect(container.classes()).not.toContain('scale-[0.01]')
|
||||
})
|
||||
|
||||
it('preloads and displays the international checkbox on mount', async () => {
|
||||
window.TCaptchaGlobal = true
|
||||
window.TencentCaptcha = class {
|
||||
constructor() {}
|
||||
show = vi.fn()
|
||||
destroy = vi.fn()
|
||||
} as unknown as typeof window.TencentCaptcha
|
||||
const wrapper = mount(TencentCaptchaGate, {
|
||||
props: { appId: '123456789', region: 'intl' }
|
||||
})
|
||||
|
||||
const container = wrapper.get('[data-testid="tencent-captcha-international-container"]')
|
||||
await flushPromises()
|
||||
await vi.waitFor(() => expect(container.classes()).not.toContain('scale-[0.01]'))
|
||||
})
|
||||
|
||||
it('reuses a preloaded proof when the SDK reports success', async () => {
|
||||
let callback: ((result: CaptchaResult) => void) | undefined
|
||||
window.TCaptchaGlobal = true
|
||||
window.TencentCaptcha = class {
|
||||
constructor(...received: unknown[]) {
|
||||
callback = received[2] as (result: CaptchaResult) => void
|
||||
}
|
||||
show = vi.fn()
|
||||
destroy = vi.fn()
|
||||
} as unknown as typeof window.TencentCaptcha
|
||||
const wrapper = mount(TencentCaptchaGate, {
|
||||
props: { appId: '123456789', region: 'intl' }
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
callback?.({ ret: 0, ticket: 'preloaded-ticket', randstr: 'preloaded-rand' })
|
||||
await flushPromises()
|
||||
|
||||
await expect(wrapper.vm.verify()).resolves.toEqual({
|
||||
ticket: 'preloaded-ticket',
|
||||
randstr: 'preloaded-rand'
|
||||
})
|
||||
expect(wrapper.get('[data-testid="tencent-captcha-international-container"]').classes()).not.toContain(
|
||||
'scale-[0.01]'
|
||||
)
|
||||
})
|
||||
|
||||
it('refreshes a preloaded proof before the provider ticket expires', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let callback: ((result: CaptchaResult) => void) | undefined
|
||||
let constructorCount = 0
|
||||
window.TCaptchaGlobal = true
|
||||
window.TencentCaptcha = class {
|
||||
constructor(...received: unknown[]) {
|
||||
constructorCount += 1
|
||||
callback = received[2] as (result: CaptchaResult) => void
|
||||
}
|
||||
show = vi.fn()
|
||||
destroy = vi.fn()
|
||||
} as unknown as typeof window.TencentCaptcha
|
||||
const wrapper = mount(TencentCaptchaGate, {
|
||||
props: { appId: '123456789', region: 'intl' }
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
callback?.({ ret: 0, ticket: 'expired-ticket', randstr: 'expired-rand' })
|
||||
await flushPromises()
|
||||
vi.advanceTimersByTime(4 * 60 * 1000)
|
||||
|
||||
const verification = wrapper.vm.verify()
|
||||
await flushPromises()
|
||||
expect(constructorCount).toBe(2)
|
||||
|
||||
callback?.({ ret: 0, ticket: 'fresh-ticket', randstr: 'fresh-rand' })
|
||||
await expect(verification).resolves.toEqual({
|
||||
ticket: 'fresh-ticket',
|
||||
randstr: 'fresh-rand'
|
||||
})
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the three-argument form for the Chinese mainland site', async () => {
|
||||
const args: unknown[][] = []
|
||||
window.TencentCaptcha = class {
|
||||
constructor(...received: unknown[]) {
|
||||
args.push(received)
|
||||
}
|
||||
show = vi.fn()
|
||||
destroy = vi.fn()
|
||||
} as unknown as typeof window.TencentCaptcha
|
||||
const wrapper = mount(TencentCaptchaGate, { props: { appId: '123456789' } })
|
||||
|
||||
void wrapper.vm.verify()
|
||||
await flushPromises()
|
||||
|
||||
expect(args[0][0]).toBe('123456789')
|
||||
expect(typeof args[0][1]).toBe('function')
|
||||
})
|
||||
|
||||
it('loads the international SDK script for the international site', async () => {
|
||||
const wrapper = mount(TencentCaptchaGate, {
|
||||
props: { appId: '123456789', region: 'intl' }
|
||||
})
|
||||
void wrapper.vm.verify()
|
||||
await flushPromises()
|
||||
|
||||
const script = document.head.querySelector<HTMLScriptElement>(
|
||||
'script[src*="TJNCaptcha-global.js"]'
|
||||
)
|
||||
expect(script?.src).toBe('https://ca.turing.captcha.qcloud.com/TJNCaptcha-global.js')
|
||||
})
|
||||
|
||||
// 页面上已有的全局构造函数可能来自另一个站点(例如开发期 HMR 重置了模块状态)。
|
||||
// 两个站点签名不兼容,错配会抛错,因此站点不一致时必须重新加载对应脚本而不是复用。
|
||||
it('does not reuse a mainland global for the international site', async () => {
|
||||
window.TencentCaptcha = class {
|
||||
constructor() {}
|
||||
show = vi.fn()
|
||||
destroy = vi.fn()
|
||||
} as unknown as typeof window.TencentCaptcha
|
||||
|
||||
const wrapper = mount(TencentCaptchaGate, {
|
||||
props: { appId: '123456789', region: 'intl' }
|
||||
})
|
||||
void wrapper.vm.verify()
|
||||
await flushPromises()
|
||||
|
||||
expect(
|
||||
document.head.querySelector('script[src*="TJNCaptcha-global.js"]')
|
||||
).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not inject a second SDK when the region changes in one page', async () => {
|
||||
const constructor = class {
|
||||
constructor() {}
|
||||
show = vi.fn()
|
||||
destroy = vi.fn()
|
||||
} as unknown as typeof window.TencentCaptcha
|
||||
|
||||
const firstLoad = loadTencentCaptcha('cn')
|
||||
const firstScript = document.head.querySelector<HTMLScriptElement>('script[src*="TJCaptcha.js"]')
|
||||
expect(firstScript).not.toBeNull()
|
||||
window.TencentCaptcha = constructor
|
||||
firstScript?.dispatchEvent(new Event('load'))
|
||||
await expect(firstLoad).resolves.toBe(constructor)
|
||||
|
||||
await expect(loadTencentCaptcha('intl')).rejects.toThrow(
|
||||
'Tencent Captcha region changed; reload the page to apply it'
|
||||
)
|
||||
expect(document.head.querySelector('script[src*="TJNCaptcha-global.js"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('settles a pending verification when reset', async () => {
|
||||
const destroy = vi.fn()
|
||||
window.TencentCaptcha = class {
|
||||
@@ -128,4 +321,31 @@ describe('TencentCaptchaGate', () => {
|
||||
await expect(verification).resolves.toBeNull()
|
||||
expect(destroy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reinitializes the international checkbox after reset', async () => {
|
||||
const destroy = vi.fn()
|
||||
let constructorCount = 0
|
||||
window.TCaptchaGlobal = true
|
||||
window.TencentCaptcha = class {
|
||||
constructor() {
|
||||
constructorCount += 1
|
||||
}
|
||||
show = vi.fn()
|
||||
destroy = destroy
|
||||
} as unknown as typeof window.TencentCaptcha
|
||||
const wrapper = mount(TencentCaptchaGate, {
|
||||
props: { appId: '123456789', region: 'intl' }
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
expect(constructorCount).toBe(1)
|
||||
wrapper.vm.reset()
|
||||
await flushPromises()
|
||||
|
||||
expect(destroy).toHaveBeenCalledOnce()
|
||||
expect(constructorCount).toBe(2)
|
||||
expect(
|
||||
wrapper.get('[data-testid="tencent-captcha-international-container"]').classes()
|
||||
).not.toContain('scale-[0.01]')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
:turnstile-site-key="turnstileSiteKey"
|
||||
:tencent-enabled="tencentCaptchaEnabled"
|
||||
:tencent-app-id="tencentCaptchaAppId"
|
||||
:tencent-region="tencentCaptchaRegion"
|
||||
:aliyun-enabled="aliyunCaptchaEnabled"
|
||||
:aliyun-scene-id="aliyunCaptchaSceneId"
|
||||
:aliyun-prefix="aliyunCaptchaPrefix"
|
||||
@@ -141,6 +142,7 @@ const turnstileEnabled = ref(false)
|
||||
const turnstileSiteKey = ref('')
|
||||
const tencentCaptchaEnabled = ref(false)
|
||||
const tencentCaptchaAppId = ref('')
|
||||
const tencentCaptchaRegion = ref('cn')
|
||||
const aliyunCaptchaEnabled = ref(false)
|
||||
const aliyunCaptchaSceneId = ref('')
|
||||
const aliyunCaptchaPrefix = ref('')
|
||||
@@ -350,6 +352,7 @@ onMounted(async () => {
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true
|
||||
tencentCaptchaAppId.value = settings.tencent_captcha_app_id || ''
|
||||
tencentCaptchaRegion.value = settings.tencent_captcha_region || 'cn'
|
||||
aliyunCaptchaEnabled.value = settings.aliyun_captcha_enabled === true
|
||||
aliyunCaptchaSceneId.value = settings.aliyun_captcha_scene_id || ''
|
||||
aliyunCaptchaPrefix.value = settings.aliyun_captcha_prefix || ''
|
||||
@@ -361,6 +364,7 @@ onMounted(async () => {
|
||||
turnstileSiteKey.value = ''
|
||||
tencentCaptchaEnabled.value = false
|
||||
tencentCaptchaAppId.value = ''
|
||||
tencentCaptchaRegion.value = 'cn'
|
||||
aliyunCaptchaEnabled.value = false
|
||||
aliyunCaptchaSceneId.value = ''
|
||||
aliyunCaptchaPrefix.value = ''
|
||||
|
||||
@@ -202,6 +202,10 @@ export default {
|
||||
configured: 'Configured. Leave empty to keep it.',
|
||||
required: 'Required before enabling.',
|
||||
mutualExclusion: 'Tencent Captcha, Cloudflare Turnstile and Aliyun Captcha are mutually exclusive. Enabling one disables the others.',
|
||||
region: 'Service site',
|
||||
regionCn: 'Chinese mainland',
|
||||
regionIntl: 'International',
|
||||
regionHint: 'Selects the SDK script and the server-side verification endpoint. It must match the site that issued your CaptchaAppId; international apps are created in the tencentcloud.com console.',
|
||||
appCredentialsTitle: 'Captcha application credentials',
|
||||
appCredentialsHint: 'Get CaptchaAppId and AppSecretKey from Verification Management in the Captcha console.',
|
||||
cloudCredentialsTitle: 'Cloud API credentials',
|
||||
|
||||
@@ -202,6 +202,10 @@ export default {
|
||||
configured: '已配置,留空不会覆盖。',
|
||||
required: '启用前必须填写此项。',
|
||||
mutualExclusion: '腾讯天御验证码与 Cloudflare Turnstile、阿里云验证码互斥,开启其中一个会自动关闭其它。',
|
||||
region: '服务站点',
|
||||
regionCn: '中国站',
|
||||
regionIntl: '国际站',
|
||||
regionHint: '决定前端加载的 SDK 脚本与服务端校验接入点,需与 CaptchaAppId 所属站点一致;国际站请在 tencentcloud.com 控制台创建验证',
|
||||
appCredentialsTitle: '验证码应用密钥',
|
||||
appCredentialsHint: 'CaptchaAppId 与 AppSecretKey 来自验证码控制台的验证管理页面。',
|
||||
cloudCredentialsTitle: '云 API 调用密钥',
|
||||
|
||||
@@ -220,6 +220,7 @@ export interface PublicSettings {
|
||||
turnstile_enabled: boolean
|
||||
tencent_captcha_enabled?: boolean
|
||||
tencent_captcha_app_id?: string
|
||||
tencent_captcha_region?: string
|
||||
passkey_enabled?: boolean
|
||||
turnstile_site_key: string
|
||||
aliyun_captcha_enabled?: boolean
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// 腾讯天御验证码站点:cn=中国站(cloud.tencent.com),intl=国际站(tencentcloud.com)。
|
||||
// 两个站点的 CaptchaAppId 不互通,SDK 脚本与服务端校验接入点都必须成对使用。
|
||||
export type TencentCaptchaRegion = 'cn' | 'intl'
|
||||
|
||||
export interface TencentCaptchaProof {
|
||||
ticket: string
|
||||
randstr: string
|
||||
@@ -16,39 +20,92 @@ interface TencentCaptchaInstance {
|
||||
destroy(): void
|
||||
}
|
||||
|
||||
type TencentCaptchaConstructor = new (
|
||||
appId: string,
|
||||
callback: (result: TencentCaptchaResult) => void,
|
||||
options?: Record<string, unknown>
|
||||
) => TencentCaptchaInstance
|
||||
type TencentCaptchaCallback = (result: TencentCaptchaResult) => void
|
||||
|
||||
// 两个站点的构造函数签名不同,且不可互换:
|
||||
// - 中国站 TJCaptcha.js:第一个参数是 CaptchaAppId 字符串(传 DOM 走另一条分支)。
|
||||
// - 国际站 TJNCaptcha-global.js:第一个参数必须是承载 Robot checkbox 的 DOM 容器,传字符串会直接抛
|
||||
// "The parameter of the constructor of the Captcha is passed incorrectly"。
|
||||
type TencentCaptchaConstructor = {
|
||||
new (
|
||||
appId: string,
|
||||
callback: TencentCaptchaCallback,
|
||||
options?: Record<string, unknown>
|
||||
): TencentCaptchaInstance
|
||||
new (
|
||||
element: HTMLElement,
|
||||
appId: string,
|
||||
callback: TencentCaptchaCallback,
|
||||
options?: Record<string, unknown>
|
||||
): TencentCaptchaInstance
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
TencentCaptcha?: TencentCaptchaConstructor
|
||||
// 国际站入口脚本会置为 true;国内站脚本只读取、从不写入。
|
||||
// 用于判定页面上已存在的 TencentCaptcha 属于哪个站点。
|
||||
TCaptchaGlobal?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const SCRIPT_SRC = 'https://turing.captcha.qcloud.com/TJCaptcha.js'
|
||||
const SCRIPT_SRC: Record<TencentCaptchaRegion, string> = {
|
||||
cn: 'https://turing.captcha.qcloud.com/TJCaptcha.js',
|
||||
intl: 'https://ca.turing.captcha.qcloud.com/TJNCaptcha-global.js'
|
||||
}
|
||||
|
||||
export function normalizeTencentCaptchaRegion(value?: string | null): TencentCaptchaRegion {
|
||||
return value === 'intl' ? 'intl' : 'cn'
|
||||
}
|
||||
|
||||
let scriptPromise: Promise<TencentCaptchaConstructor> | null = null
|
||||
let loadedRegion: TencentCaptchaRegion | null = null
|
||||
|
||||
export function loadTencentCaptcha(): Promise<TencentCaptchaConstructor> {
|
||||
if (window.TencentCaptcha) return Promise.resolve(window.TencentCaptcha)
|
||||
if (scriptPromise) return scriptPromise
|
||||
// existingGlobalRegion 推断页面上已存在的全局构造函数属于哪个站点。
|
||||
// 两个站点的构造函数签名不兼容(国际站首参必须是 DOM 元素),错配会直接抛错,
|
||||
// 因此复用已存在的全局前必须先确认站点一致。
|
||||
function existingGlobalRegion(): TencentCaptchaRegion {
|
||||
return window.TCaptchaGlobal === true ? 'intl' : 'cn'
|
||||
}
|
||||
|
||||
export function loadTencentCaptcha(
|
||||
region: TencentCaptchaRegion = 'cn'
|
||||
): Promise<TencentCaptchaConstructor> {
|
||||
// 两个站点的 SDK 注册同名全局 TencentCaptcha,缓存必须按站点隔离,
|
||||
// 否则管理员切换站点后仍会复用上一个站点的构造函数。
|
||||
// loadedRegion 为 null 表示全局不是本模块注入的(如开发期 HMR 后模块状态被重置),
|
||||
// 此时靠 TCaptchaGlobal 判定站点,一致才复用。
|
||||
const globalRegion = window.TencentCaptcha ? existingGlobalRegion() : null
|
||||
if (window.TencentCaptcha && (loadedRegion === region || globalRegion === region)) {
|
||||
return Promise.resolve(window.TencentCaptcha)
|
||||
}
|
||||
if (window.TencentCaptcha && loadedRegion !== null && loadedRegion !== region) {
|
||||
// 两个站点共享 TencentCaptcha 全局且构造签名不兼容,不能在同一页面注入第二份 SDK。
|
||||
// 管理员切换区域后由部署流程刷新页面,刷新前直接失败可避免全局构造函数被污染。
|
||||
return Promise.reject(new Error('Tencent Captcha region changed; reload the page to apply it'))
|
||||
}
|
||||
if (scriptPromise && loadedRegion === region) return scriptPromise
|
||||
|
||||
loadedRegion = region
|
||||
scriptPromise = new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script')
|
||||
script.src = SCRIPT_SRC
|
||||
script.src = SCRIPT_SRC[region]
|
||||
script.async = true
|
||||
script.onload = () => {
|
||||
if (window.TencentCaptcha) {
|
||||
// 入口脚本被重复引入时腾讯会在求值阶段抛错("请勿多次引用腾讯验证码的接入js"),
|
||||
// 此时 onload 仍会触发而全局仍是上一个站点的构造函数。校验站点,避免把
|
||||
// 签名不兼容的构造函数当成本站点的返回出去。
|
||||
if (window.TencentCaptcha && existingGlobalRegion() === region) {
|
||||
resolve(window.TencentCaptcha)
|
||||
return
|
||||
}
|
||||
scriptPromise = null
|
||||
loadedRegion = null
|
||||
reject(new Error('Tencent Captcha SDK is unavailable'))
|
||||
}
|
||||
script.onerror = () => {
|
||||
scriptPromise = null
|
||||
loadedRegion = null
|
||||
reject(new Error('Failed to load Tencent Captcha SDK'))
|
||||
}
|
||||
document.head.appendChild(script)
|
||||
@@ -59,4 +116,5 @@ export function loadTencentCaptcha(): Promise<TencentCaptchaConstructor> {
|
||||
|
||||
export function resetTencentCaptchaLoaderForTest(): void {
|
||||
scriptPromise = null
|
||||
loadedRegion = null
|
||||
}
|
||||
|
||||
@@ -2108,6 +2108,42 @@
|
||||
|
||||
<!-- Tencent Captcha fields -->
|
||||
<div v-else-if="captchaProviderSelection === 'tencent'">
|
||||
<div class="mb-6 max-w-sm">
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t("admin.settings.tencentCaptcha.region") }}
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-2 rounded-lg bg-gray-100 p-1 dark:bg-dark-700">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="tencent-captcha-region-cn"
|
||||
class="inline-flex items-center justify-center rounded-md px-3 py-1.5 text-sm font-medium transition"
|
||||
:class="
|
||||
form.tencent_captcha_region !== 'intl'
|
||||
? 'bg-white text-primary-700 shadow-sm dark:bg-dark-800 dark:text-primary-300'
|
||||
: 'text-gray-600 hover:text-gray-900 dark:text-dark-300 dark:hover:text-white'
|
||||
"
|
||||
@click="form.tencent_captcha_region = 'cn'"
|
||||
>
|
||||
{{ t("admin.settings.tencentCaptcha.regionCn") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="tencent-captcha-region-intl"
|
||||
class="inline-flex items-center justify-center rounded-md px-3 py-1.5 text-sm font-medium transition"
|
||||
:class="
|
||||
form.tencent_captcha_region === 'intl'
|
||||
? 'bg-white text-primary-700 shadow-sm dark:bg-dark-800 dark:text-primary-300'
|
||||
: 'text-gray-600 hover:text-gray-900 dark:text-dark-300 dark:hover:text-white'
|
||||
"
|
||||
@click="form.tencent_captcha_region = 'intl'"
|
||||
>
|
||||
{{ t("admin.settings.tencentCaptcha.regionIntl") }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.tencentCaptcha.regionHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div class="md:col-span-2">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
@@ -2191,7 +2227,7 @@
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap gap-x-4 gap-y-2 text-sm">
|
||||
<a
|
||||
href="https://console.cloud.tencent.com/captcha"
|
||||
:href="tencentCaptchaLinks.console"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-500"
|
||||
@@ -2199,7 +2235,7 @@
|
||||
{{ t("admin.settings.tencentCaptcha.openCaptchaConsole") }}
|
||||
</a>
|
||||
<a
|
||||
href="https://console.cloud.tencent.com/cam/capi"
|
||||
:href="tencentCaptchaLinks.cloudKeys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-500"
|
||||
@@ -2207,7 +2243,7 @@
|
||||
{{ t("admin.settings.tencentCaptcha.createCloudKeys") }}
|
||||
</a>
|
||||
<a
|
||||
href="https://cloud.tencent.com/document/product/1110/36841"
|
||||
:href="tencentCaptchaLinks.webDocs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-500"
|
||||
@@ -9264,6 +9300,7 @@ const form = reactive<SettingsForm>({
|
||||
tencent_captcha_cloud_secret_id_configured: false,
|
||||
tencent_captcha_cloud_secret_key: "",
|
||||
tencent_captcha_cloud_secret_key_configured: false,
|
||||
tencent_captcha_region: "cn",
|
||||
aliyun_captcha_enabled: false,
|
||||
aliyun_captcha_access_key_id: "",
|
||||
aliyun_captcha_access_key_secret: "",
|
||||
@@ -9460,6 +9497,22 @@ function selectCaptchaProvider(provider: CaptchaProviderSelection): void {
|
||||
applyCaptchaSelection(provider);
|
||||
}
|
||||
|
||||
// 天御中国站与国际站是两套独立账号体系,控制台与文档入口不通用,
|
||||
// 按当前选择的站点给出对应链接,避免管理员在错误的控制台里找不到 CaptchaAppId。
|
||||
const tencentCaptchaLinks = computed(() =>
|
||||
form.tencent_captcha_region === "intl"
|
||||
? {
|
||||
console: "https://console.tencentcloud.com/captcha/graphical",
|
||||
cloudKeys: "https://console.tencentcloud.com/cam/capi",
|
||||
webDocs: "https://www.tencentcloud.com/document/product/1159/49680",
|
||||
}
|
||||
: {
|
||||
console: "https://console.cloud.tencent.com/captcha",
|
||||
cloudKeys: "https://console.cloud.tencent.com/cam/capi",
|
||||
webDocs: "https://cloud.tencent.com/document/product/1110/36841",
|
||||
},
|
||||
);
|
||||
|
||||
function syncCaptchaProviderSelection(): void {
|
||||
if (form.tencent_captcha_enabled) {
|
||||
captchaProviderSelection.value = "tencent";
|
||||
@@ -10817,6 +10870,7 @@ async function saveSettings() {
|
||||
form.tencent_captcha_cloud_secret_id || undefined,
|
||||
tencent_captcha_cloud_secret_key:
|
||||
form.tencent_captcha_cloud_secret_key || undefined,
|
||||
tencent_captcha_region: form.tencent_captcha_region,
|
||||
aliyun_captcha_enabled: form.aliyun_captcha_enabled,
|
||||
aliyun_captcha_access_key_id: form.aliyun_captcha_access_key_id,
|
||||
aliyun_captcha_access_key_secret:
|
||||
|
||||
@@ -813,6 +813,36 @@ describe("admin SettingsView payment visible method controls", () => {
|
||||
tencent_captcha_app_secret_key: "app-secret-value",
|
||||
tencent_captcha_cloud_secret_id: "cloud-secret-id-value",
|
||||
tencent_captcha_cloud_secret_key: "cloud-secret-key-value",
|
||||
tencent_captcha_region: "cn",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("腾讯天御切换到国际站后保存站点并更新控制台入口", async () => {
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
await openSecurityTab(wrapper);
|
||||
|
||||
await wrapper.get('[data-testid="captcha-enabled-toggle"]').setValue(true);
|
||||
await wrapper.get('[data-testid="captcha-provider-tencent"]').trigger("click");
|
||||
await wrapper.get('[data-testid="tencent-captcha-region-intl"]').trigger("click");
|
||||
|
||||
const card = wrapper
|
||||
.findAll(".card")
|
||||
.find((node) => node.text().includes("admin.settings.captcha.title"));
|
||||
expect(card).toBeDefined();
|
||||
expect(card!.get('a[href="https://console.tencentcloud.com/captcha/graphical"]').exists()).toBe(
|
||||
true,
|
||||
);
|
||||
expect(card!.get('a[href="https://console.tencentcloud.com/cam/capi"]').exists()).toBe(true);
|
||||
|
||||
await wrapper.find("form").trigger("submit.prevent");
|
||||
await flushPromises();
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tencent_captcha_enabled: true,
|
||||
tencent_captcha_region: "intl",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
:turnstile-site-key="turnstileSiteKey"
|
||||
:tencent-enabled="tencentCaptchaEnabled"
|
||||
:tencent-app-id="tencentCaptchaAppId"
|
||||
:tencent-region="tencentCaptchaRegion"
|
||||
:aliyun-enabled="aliyunCaptchaEnabled"
|
||||
:aliyun-scene-id="aliyunCaptchaSceneId"
|
||||
:aliyun-prefix="aliyunCaptchaPrefix"
|
||||
@@ -93,6 +94,7 @@
|
||||
:turnstile-site-key="turnstileSiteKey"
|
||||
:tencent-enabled="tencentCaptchaEnabled"
|
||||
:tencent-app-id="tencentCaptchaAppId"
|
||||
:tencent-region="tencentCaptchaRegion"
|
||||
:aliyun-enabled="aliyunCaptchaEnabled"
|
||||
:aliyun-scene-id="aliyunCaptchaSceneId"
|
||||
:aliyun-prefix="aliyunCaptchaPrefix"
|
||||
@@ -262,6 +264,7 @@ const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const tencentCaptchaEnabled = ref<boolean>(false)
|
||||
const tencentCaptchaAppId = ref<string>('')
|
||||
const tencentCaptchaRegion = ref<string>('cn')
|
||||
const aliyunCaptchaEnabled = ref<boolean>(false)
|
||||
const aliyunCaptchaSceneId = ref<string>('')
|
||||
const aliyunCaptchaPrefix = ref<string>('')
|
||||
@@ -361,6 +364,7 @@ onMounted(async () => {
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true
|
||||
tencentCaptchaAppId.value = settings.tencent_captcha_app_id || ''
|
||||
tencentCaptchaRegion.value = settings.tencent_captcha_region || 'cn'
|
||||
aliyunCaptchaEnabled.value = settings.aliyun_captcha_enabled === true
|
||||
aliyunCaptchaSceneId.value = settings.aliyun_captcha_scene_id || ''
|
||||
aliyunCaptchaPrefix.value = settings.aliyun_captcha_prefix || ''
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
:turnstile-site-key="turnstileSiteKey"
|
||||
:tencent-enabled="tencentCaptchaEnabled"
|
||||
:tencent-app-id="tencentCaptchaAppId"
|
||||
:tencent-region="tencentCaptchaRegion"
|
||||
:aliyun-enabled="aliyunCaptchaEnabled"
|
||||
:aliyun-scene-id="aliyunCaptchaSceneId"
|
||||
:aliyun-prefix="aliyunCaptchaPrefix"
|
||||
@@ -157,6 +158,7 @@ const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const tencentCaptchaEnabled = ref<boolean>(false)
|
||||
const tencentCaptchaAppId = ref<string>('')
|
||||
const tencentCaptchaRegion = ref<string>('cn')
|
||||
const aliyunCaptchaEnabled = ref<boolean>(false)
|
||||
const aliyunCaptchaSceneId = ref<string>('')
|
||||
const aliyunCaptchaPrefix = ref<string>('')
|
||||
@@ -209,6 +211,7 @@ onMounted(async () => {
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true
|
||||
tencentCaptchaAppId.value = settings.tencent_captcha_app_id || ''
|
||||
tencentCaptchaRegion.value = settings.tencent_captcha_region || 'cn'
|
||||
aliyunCaptchaEnabled.value = settings.aliyun_captcha_enabled === true
|
||||
aliyunCaptchaSceneId.value = settings.aliyun_captcha_scene_id || ''
|
||||
aliyunCaptchaPrefix.value = settings.aliyun_captcha_prefix || ''
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
:turnstile-site-key="turnstileSiteKey"
|
||||
:tencent-enabled="tencentCaptchaEnabled"
|
||||
:tencent-app-id="tencentCaptchaAppId"
|
||||
:tencent-region="tencentCaptchaRegion"
|
||||
:aliyun-enabled="aliyunCaptchaEnabled"
|
||||
:aliyun-scene-id="aliyunCaptchaSceneId"
|
||||
:aliyun-prefix="aliyunCaptchaPrefix"
|
||||
@@ -273,6 +274,7 @@ const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const tencentCaptchaEnabled = ref<boolean>(false)
|
||||
const tencentCaptchaAppId = ref<string>('')
|
||||
const tencentCaptchaRegion = ref<string>('cn')
|
||||
const aliyunCaptchaEnabled = ref<boolean>(false)
|
||||
const aliyunCaptchaSceneId = ref<string>('')
|
||||
const aliyunCaptchaPrefix = ref<string>('')
|
||||
@@ -383,6 +385,7 @@ onMounted(async () => {
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true
|
||||
tencentCaptchaAppId.value = settings.tencent_captcha_app_id || ''
|
||||
tencentCaptchaRegion.value = settings.tencent_captcha_region || 'cn'
|
||||
aliyunCaptchaEnabled.value = settings.aliyun_captcha_enabled === true
|
||||
aliyunCaptchaSceneId.value = settings.aliyun_captcha_scene_id || ''
|
||||
aliyunCaptchaPrefix.value = settings.aliyun_captcha_prefix || ''
|
||||
|
||||
@@ -211,6 +211,7 @@
|
||||
:turnstile-site-key="turnstileSiteKey"
|
||||
:tencent-enabled="tencentCaptchaEnabled"
|
||||
:tencent-app-id="tencentCaptchaAppId"
|
||||
:tencent-region="tencentCaptchaRegion"
|
||||
:aliyun-enabled="aliyunCaptchaEnabled"
|
||||
:aliyun-scene-id="aliyunCaptchaSceneId"
|
||||
:aliyun-prefix="aliyunCaptchaPrefix"
|
||||
@@ -392,6 +393,7 @@ const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const tencentCaptchaEnabled = ref<boolean>(false)
|
||||
const tencentCaptchaAppId = ref<string>('')
|
||||
const tencentCaptchaRegion = ref<string>('cn')
|
||||
const aliyunCaptchaEnabled = ref<boolean>(false)
|
||||
const aliyunCaptchaSceneId = ref<string>('')
|
||||
const aliyunCaptchaPrefix = ref<string>('')
|
||||
@@ -524,6 +526,7 @@ onMounted(async () => {
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true
|
||||
tencentCaptchaAppId.value = settings.tencent_captcha_app_id || ''
|
||||
tencentCaptchaRegion.value = settings.tencent_captcha_region || 'cn'
|
||||
aliyunCaptchaEnabled.value = settings.aliyun_captcha_enabled === true
|
||||
aliyunCaptchaSceneId.value = settings.aliyun_captcha_scene_id || ''
|
||||
aliyunCaptchaPrefix.value = settings.aliyun_captcha_prefix || ''
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ForgotPasswordView from '@/views/auth/ForgotPasswordView.vue'
|
||||
|
||||
const getPublicSettingsMock = vi.fn()
|
||||
const forgotPasswordMock = vi.fn()
|
||||
const verifyActionMock = vi.fn()
|
||||
const captchaResetMock = vi.fn()
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({ t: (key: string) => key })
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useAppStore: () => ({
|
||||
showError: vi.fn(),
|
||||
showSuccess: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
getPublicSettings: (...args: unknown[]) => getPublicSettingsMock(...args),
|
||||
forgotPassword: (...args: unknown[]) => forgotPasswordMock(...args)
|
||||
}))
|
||||
|
||||
const CaptchaChallengeStub = defineComponent({
|
||||
props: {
|
||||
tencentEnabled: Boolean,
|
||||
tencentAppId: String,
|
||||
tencentRegion: String
|
||||
},
|
||||
setup(_, { expose }) {
|
||||
expose({
|
||||
verifyAction: verifyActionMock,
|
||||
reset: captchaResetMock
|
||||
})
|
||||
return () => h('div', { 'data-testid': 'captcha-challenge' })
|
||||
}
|
||||
})
|
||||
|
||||
function mountForgotPassword() {
|
||||
return mount(ForgotPasswordView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /><slot name="footer" /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: true,
|
||||
TurnstileWidget: CaptchaChallengeStub
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('忘记密码腾讯验证码门禁', () => {
|
||||
beforeEach(() => {
|
||||
getPublicSettingsMock.mockReset()
|
||||
forgotPasswordMock.mockReset()
|
||||
verifyActionMock.mockReset()
|
||||
captchaResetMock.mockReset()
|
||||
getPublicSettingsMock.mockResolvedValue({
|
||||
turnstile_enabled: false,
|
||||
turnstile_site_key: '',
|
||||
tencent_captcha_enabled: true,
|
||||
tencent_captcha_app_id: '123456789',
|
||||
tencent_captcha_region: 'intl',
|
||||
aliyun_captcha_enabled: false
|
||||
})
|
||||
verifyActionMock.mockResolvedValue({ token: 'ticket-value', randstr: '@rand-value' })
|
||||
forgotPasswordMock.mockResolvedValue({ message: 'ok' })
|
||||
})
|
||||
|
||||
it('把公开设置中的站点传给验证码组件', async () => {
|
||||
const wrapper = mountForgotPassword()
|
||||
await flushPromises()
|
||||
|
||||
const captcha = wrapper.findComponent(CaptchaChallengeStub)
|
||||
expect(captcha.props('tencentEnabled')).toBe(true)
|
||||
expect(captcha.props('tencentAppId')).toBe('123456789')
|
||||
expect(captcha.props('tencentRegion')).toBe('intl')
|
||||
})
|
||||
|
||||
it('提交前获取新票据并原样发送到忘记密码接口', async () => {
|
||||
const wrapper = mountForgotPassword()
|
||||
await flushPromises()
|
||||
await wrapper.get('#email').setValue('user@example.com')
|
||||
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(verifyActionMock).toHaveBeenCalledOnce()
|
||||
expect(forgotPasswordMock).toHaveBeenCalledWith({
|
||||
email: 'user@example.com',
|
||||
turnstile_token: undefined,
|
||||
tencent_captcha_ticket: 'ticket-value',
|
||||
tencent_captcha_randstr: '@rand-value'
|
||||
})
|
||||
expect(captchaResetMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user