diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 6977a0e308..ab83333eb4 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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 ( diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index ecf8feaee3..a9d8b0d88e 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -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, diff --git a/backend/internal/handler/admin/setting_handler_audit.go b/backend/internal/handler/admin/setting_handler_audit.go index d108b6ec01..5594b6c9c1 100644 --- a/backend/internal/handler/admin/setting_handler_audit.go +++ b/backend/internal/handler/admin/setting_handler_audit.go @@ -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") } diff --git a/backend/internal/handler/admin/setting_handler_partial_payload_test.go b/backend/internal/handler/admin/setting_handler_partial_payload_test.go index b27ff908fa..4d009cb0b2 100644 --- a/backend/internal/handler/admin/setting_handler_partial_payload_test.go +++ b/backend/internal/handler/admin/setting_handler_partial_payload_test.go @@ -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", diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index 6635a21899..5619641c53 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -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, diff --git a/backend/internal/handler/auth_captcha_request_test.go b/backend/internal/handler/auth_captcha_request_test.go index bdbda2d82b..d8c5458ba3 100644 --- a/backend/internal/handler/auth_captcha_request_test.go +++ b/backend/internal/handler/auth_captcha_request_test.go @@ -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) + }) + } } diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 061c4ee3f3..6eccb92c8e 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -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"` diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 83029fd360..991d9beed1 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -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, diff --git a/backend/internal/handler/setting_handler_public_test.go b/backend/internal/handler/setting_handler_public_test.go index 9f98eba6af..0353a88628 100644 --- a/backend/internal/handler/setting_handler_public_test.go +++ b/backend/internal/handler/setting_handler_public_test.go @@ -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) { diff --git a/backend/internal/pkg/ip/ip.go b/backend/internal/pkg/ip/ip.go index ff1347d809..367bcc440c 100644 --- a/backend/internal/pkg/ip/ip.go +++ b/backend/internal/pkg/ip/ip.go @@ -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 diff --git a/backend/internal/pkg/ip/ip_test.go b/backend/internal/pkg/ip/ip_test.go index 505ade5eea..8aece8e6a4 100644 --- a/backend/internal/pkg/ip/ip_test.go +++ b/backend/internal/pkg/ip/ip_test.go @@ -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, diff --git a/backend/internal/repository/tencent_captcha_service.go b/backend/internal/repository/tencent_captcha_service.go index 5e7aa489a7..656ef30c92 100644 --- a/backend/internal/repository/tencent_captcha_service.go +++ b/backend/internal/repository/tencent_captcha_service.go @@ -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) } diff --git a/backend/internal/repository/tencent_captcha_service_test.go b/backend/internal/repository/tencent_captcha_service_test.go index 5917b39728..f524dd2639 100644 --- a/backend/internal/repository/tencent_captcha_service_test.go +++ b/backend/internal/repository/tencent_captcha_service_test.go @@ -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) diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 21ffe85542..9a017f5620 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -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, diff --git a/backend/internal/server/middleware/security_headers.go b/backend/internal/server/middleware/security_headers.go index 694199ab21..bb5d99abe2 100644 --- a/backend/internal/server/middleware/security_headers.go +++ b/backend/internal/server/middleware/security_headers.go @@ -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}, diff --git a/backend/internal/server/middleware/security_headers_test.go b/backend/internal/server/middleware/security_headers_test.go index 3581c53b31..cb355e0c4d 100644 --- a/backend/internal/server/middleware/security_headers_test.go +++ b/backend/internal/server/middleware/security_headers_test.go @@ -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) { diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 52197cc659..c0905e11b9 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -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" // 是否启用阿里云验证码 diff --git a/backend/internal/service/setting_features.go b/backend/internal/service/setting_features.go index 83747c57a3..6fba70948f 100644 --- a/backend/internal/service/setting_features.go +++ b/backend/internal/service/setting_features.go @@ -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", diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index b0a356a6f2..05be77086f 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -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] != "", diff --git a/backend/internal/service/setting_public.go b/backend/internal/service/setting_public.go index a05ed44a28..ff11fb6897 100644 --- a/backend/internal/service/setting_public.go +++ b/backend/internal/service/setting_public.go @@ -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, diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index e086a6bcf3..825222fa94 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -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 diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index ceae9f270b..3854f4663e 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -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 diff --git a/backend/internal/service/tencent_captcha_service.go b/backend/internal/service/tencent_captcha_service.go index ccfe3fca2b..e7468f4a20 100644 --- a/backend/internal/service/tencent_captcha_service.go +++ b/backend/internal/service/tencent_captcha_service.go @@ -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 diff --git a/backend/internal/service/tencent_captcha_service_test.go b/backend/internal/service/tencent_captcha_service_test.go index b927ba0b91..b67af70545 100644 --- a/backend/internal/service/tencent_captcha_service_test.go +++ b/backend/internal/service/tencent_captcha_service_test.go @@ -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) diff --git a/backend/internal/service/tencent_captcha_settings_test.go b/backend/internal/service/tencent_captcha_settings_test.go index 8d719ce232..f67b16e8ed 100644 --- a/backend/internal/service/tencent_captcha_settings_test.go +++ b/backend/internal/service/tencent_captcha_settings_test.go @@ -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) } diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 39f6ade423..a6ef8a9813 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -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 证书验证(仅用于调试) diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 079556fe98..c32e0a5d68 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -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; diff --git a/frontend/src/components/CaptchaChallenge.vue b/frontend/src/components/CaptchaChallenge.vue index cfb3bde1fb..f9426c6d49 100644 --- a/frontend/src/components/CaptchaChallenge.vue +++ b/frontend/src/components/CaptchaChallenge.vue @@ -11,6 +11,7 @@ v-else-if="tencentEnabled && tencentAppId" ref="tencentRef" :app-id="tencentAppId" + :region="tencentRegion" /> - +
diff --git a/frontend/src/components/__tests__/TencentCaptchaGate.spec.ts b/frontend/src/components/__tests__/TencentCaptchaGate.spec.ts index c3aa9bd568..d6ffc3bd43 100644 --- a/frontend/src/components/__tests__/TencentCaptchaGate.spec.ts +++ b/frontend/src/components/__tests__/TencentCaptchaGate.spec.ts @@ -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('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( + '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('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]') + }) }) diff --git a/frontend/src/components/auth/PendingOAuthCreateAccountForm.vue b/frontend/src/components/auth/PendingOAuthCreateAccountForm.vue index 319cf364b6..5a8ed84feb 100644 --- a/frontend/src/components/auth/PendingOAuthCreateAccountForm.vue +++ b/frontend/src/components/auth/PendingOAuthCreateAccountForm.vue @@ -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 = '' diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 586a3a53c1..92d562670c 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -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', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 646cf56018..65707928d0 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -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 调用密钥', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 62a3e60895..80cd0d3151 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 diff --git a/frontend/src/utils/tencentCaptcha.ts b/frontend/src/utils/tencentCaptcha.ts index 1e79b0f6a6..48495549a3 100644 --- a/frontend/src/utils/tencentCaptcha.ts +++ b/frontend/src/utils/tencentCaptcha.ts @@ -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 -) => 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 + ): TencentCaptchaInstance + new ( + element: HTMLElement, + appId: string, + callback: TencentCaptchaCallback, + options?: Record + ): 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 = { + 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 | null = null +let loadedRegion: TencentCaptchaRegion | null = null -export function loadTencentCaptcha(): Promise { - 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 { + // 两个站点的 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 { export function resetTencentCaptchaLoaderForTest(): void { scriptPromise = null + loadedRegion = null } diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index a7e066f1c1..9904b0376f 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -2108,6 +2108,42 @@
+
+ +
+ + +
+

+ {{ t("admin.settings.tencentCaptcha.regionHint") }} +

+

@@ -2191,7 +2227,7 @@

({ 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: diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts index 65bf2020ff..edcd879a9f 100644 --- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts +++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts @@ -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", }), ); }); diff --git a/frontend/src/views/auth/EmailVerifyView.vue b/frontend/src/views/auth/EmailVerifyView.vue index 89f59844d0..abab1e3979 100644 --- a/frontend/src/views/auth/EmailVerifyView.vue +++ b/frontend/src/views/auth/EmailVerifyView.vue @@ -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(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('') @@ -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 || '' diff --git a/frontend/src/views/auth/ForgotPasswordView.vue b/frontend/src/views/auth/ForgotPasswordView.vue index d420984b37..6363681a83 100644 --- a/frontend/src/views/auth/ForgotPasswordView.vue +++ b/frontend/src/views/auth/ForgotPasswordView.vue @@ -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(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('') @@ -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 || '' diff --git a/frontend/src/views/auth/LoginView.vue b/frontend/src/views/auth/LoginView.vue index 9e560e1def..c16f927954 100644 --- a/frontend/src/views/auth/LoginView.vue +++ b/frontend/src/views/auth/LoginView.vue @@ -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(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('') @@ -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 || '' diff --git a/frontend/src/views/auth/RegisterView.vue b/frontend/src/views/auth/RegisterView.vue index 5dd46aaf60..a638464d7f 100644 --- a/frontend/src/views/auth/RegisterView.vue +++ b/frontend/src/views/auth/RegisterView.vue @@ -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(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('') @@ -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 || '' diff --git a/frontend/src/views/auth/__tests__/TencentCaptchaForgotPassword.spec.ts b/frontend/src/views/auth/__tests__/TencentCaptchaForgotPassword.spec.ts new file mode 100644 index 0000000000..5ba01a2600 --- /dev/null +++ b/frontend/src/views/auth/__tests__/TencentCaptchaForgotPassword.spec.ts @@ -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('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: '
' }, + 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() + }) +})