From e592c5f9e055cb796e32eb1f74fe97cc46fd67f3 Mon Sep 17 00:00:00 2001 From: lyen1688 Date: Tue, 4 Aug 2026 15:09:29 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=85=BE=E8=AE=AF=E5=A4=A9?= =?UTF-8?q?=E5=BE=A1=E9=AA=8C=E8=AF=81=E7=A0=81=E8=AE=A4=E8=AF=81=E9=97=A8?= =?UTF-8?q?=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/wire_gen.go | 4 +- backend/go.mod | 2 + backend/go.sum | 14 + backend/internal/config/config.go | 2 +- .../internal/handler/admin/setting_handler.go | 5 + .../handler/admin/setting_handler_audit.go | 15 + .../setting_handler_partial_payload_test.go | 72 +++++ .../setting_handler_platform_quota_test.go | 17 + .../handler/admin/setting_handler_update.go | 77 ++++- .../handler/auth_captcha_request_test.go | 26 ++ .../internal/handler/auth_dingtalk_oauth.go | 5 +- backend/internal/handler/auth_email_oauth.go | 5 +- backend/internal/handler/auth_handler.go | 61 ++-- .../internal/handler/auth_linuxdo_oauth.go | 5 +- .../handler/auth_oauth_captcha_start.go | 47 +++ .../handler/auth_oauth_captcha_start_test.go | 178 +++++++++++ .../handler/auth_oauth_pending_flow.go | 35 ++- backend/internal/handler/auth_oidc_oauth.go | 5 +- backend/internal/handler/auth_wechat_oauth.go | 5 +- backend/internal/handler/dto/settings.go | 17 +- backend/internal/handler/passkey_handler.go | 15 + .../internal/handler/passkey_handler_test.go | 100 +++++- backend/internal/handler/setting_handler.go | 2 + .../handler/setting_handler_public_test.go | 32 ++ .../repository/tencent_captcha_service.go | 76 +++++ .../tencent_captcha_service_test.go | 66 ++++ backend/internal/repository/wire.go | 1 + backend/internal/server/api_contract_test.go | 10 + .../server/middleware/security_headers.go | 11 +- .../middleware/security_headers_test.go | 11 + backend/internal/server/routes/auth.go | 18 ++ backend/internal/service/auth_service.go | 111 +++++-- .../service/auth_service_captcha_test.go | 188 +++++++++++ .../service/auth_service_register_test.go | 8 +- backend/internal/service/domain_constants.go | 7 + backend/internal/service/setting_features.go | 55 ++++ backend/internal/service/setting_parse.go | 90 +++--- backend/internal/service/setting_public.go | 8 + .../service/setting_service_public_test.go | 4 + backend/internal/service/setting_update.go | 12 + backend/internal/service/settings_view.go | 22 +- .../service/tencent_captcha_service.go | 108 +++++++ .../service/tencent_captcha_service_test.go | 108 +++++++ .../service/tencent_captcha_settings_test.go | 76 +++++ backend/internal/service/turnstile_service.go | 7 + backend/internal/service/wire.go | 40 ++- deploy/config.example.yaml | 2 +- .../auth-captcha-oauth-start.spec.ts | 45 +++ frontend/src/api/__tests__/passkey.spec.ts | 33 ++ frontend/src/api/admin/settings.ts | 10 + frontend/src/api/auth.ts | 40 +++ frontend/src/api/passkey.ts | 10 +- frontend/src/components/CaptchaChallenge.vue | 56 ++++ .../src/components/TencentCaptchaGate.vue | 79 +++++ .../__tests__/TencentCaptchaGate.spec.ts | 131 ++++++++ .../components/auth/DingTalkOAuthSection.vue | 9 +- .../src/components/auth/EmailOAuthButtons.vue | 13 +- .../components/auth/LinuxDoOAuthSection.vue | 9 +- .../src/components/auth/OidcOAuthSection.vue | 9 +- .../auth/PendingOAuthCreateAccountForm.vue | 76 ++++- .../components/auth/WechatOAuthSection.vue | 13 +- .../auth/__tests__/EmailOAuthButtons.spec.ts | 44 ++- .../auth/__tests__/OAuthLoginSections.spec.ts | 44 +++ .../PendingOAuthCreateAccountForm.spec.ts | 95 +++++- .../auth/__tests__/WechatOAuthSection.spec.ts | 34 +- .../src/i18n/locales/en/admin/settings.ts | 23 ++ frontend/src/i18n/locales/en/common.ts | 2 + .../src/i18n/locales/zh/admin/settings.ts | 23 ++ frontend/src/i18n/locales/zh/common.ts | 2 + frontend/src/stores/auth.ts | 12 +- frontend/src/types/index.ts | 13 + frontend/src/utils/tencentCaptcha.ts | 62 ++++ frontend/src/views/admin/SettingsView.vue | 181 ++++++++++- .../admin/__tests__/SettingsView.spec.ts | 49 +++ .../src/views/auth/DingTalkCallbackView.vue | 7 + .../auth/DingTalkEmailCompletionView.vue | 7 + frontend/src/views/auth/EmailVerifyView.vue | 191 ++++++++++-- .../src/views/auth/ForgotPasswordView.vue | 59 +++- .../src/views/auth/LinuxDoCallbackView.vue | 7 + frontend/src/views/auth/LoginView.vue | 128 +++++++- frontend/src/views/auth/OidcCallbackView.vue | 7 + frontend/src/views/auth/RegisterView.vue | 101 +++++- .../src/views/auth/WechatCallbackView.vue | 9 +- .../auth/__tests__/EmailVerifyView.spec.ts | 295 ++++++++++++++++++ .../TencentCaptchaActionGate.spec.ts | 229 ++++++++++++++ .../auth/__tests__/WechatCallbackView.spec.ts | 3 +- 86 files changed, 3683 insertions(+), 262 deletions(-) create mode 100644 backend/internal/handler/auth_captcha_request_test.go create mode 100644 backend/internal/handler/auth_oauth_captcha_start.go create mode 100644 backend/internal/handler/auth_oauth_captcha_start_test.go create mode 100644 backend/internal/repository/tencent_captcha_service.go create mode 100644 backend/internal/repository/tencent_captcha_service_test.go create mode 100644 backend/internal/service/auth_service_captcha_test.go create mode 100644 backend/internal/service/tencent_captcha_service.go create mode 100644 backend/internal/service/tencent_captcha_service_test.go create mode 100644 backend/internal/service/tencent_captcha_settings_test.go create mode 100644 frontend/src/api/__tests__/auth-captcha-oauth-start.spec.ts create mode 100644 frontend/src/components/CaptchaChallenge.vue create mode 100644 frontend/src/components/TencentCaptchaGate.vue create mode 100644 frontend/src/components/__tests__/TencentCaptchaGate.spec.ts create mode 100644 frontend/src/components/auth/__tests__/OAuthLoginSections.spec.ts create mode 100644 frontend/src/utils/tencentCaptcha.ts create mode 100644 frontend/src/views/auth/__tests__/TencentCaptchaActionGate.spec.ts diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index ec2db0e11c..c095f70f9c 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -57,6 +57,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { emailService := service.NewEmailService(settingRepository, emailCache) turnstileVerifier := repository.NewTurnstileVerifier() turnstileService := service.NewTurnstileService(settingService, turnstileVerifier) + tencentCaptchaVerifier := repository.NewTencentCaptchaVerifier() + tencentCaptchaService := service.NewTencentCaptchaService(settingService, tencentCaptchaVerifier) emailQueueService := service.ProvideEmailQueueService(emailService) promoCodeRepository := repository.NewPromoCodeRepository(client) billingCache := repository.NewBillingCache(redisClient) @@ -78,7 +80,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService, client, configConfig) affiliateRepository := repository.NewAffiliateRepository(client, db) affiliateService := service.NewAffiliateService(affiliateRepository, settingService, apiKeyAuthCacheInvalidator, billingCacheService) - authService := service.NewAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService, subscriptionService, affiliateService, serviceUserPlatformQuotaRepository) + authService := service.ProvideAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, tencentCaptchaService, emailQueueService, promoService, subscriptionService, affiliateService, serviceUserPlatformQuotaRepository) userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache) redeemCache := repository.NewRedeemCache(redisClient) redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator, affiliateService) diff --git a/backend/go.mod b/backend/go.mod index e57756f77a..ebe7adf6c2 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -161,6 +161,8 @@ require ( github.com/spf13/cast v1.6.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/captcha v1.3.52 // indirect + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.52 // indirect github.com/testcontainers/testcontainers-go v0.40.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 896cfa3be6..24d3133355 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -178,6 +178,8 @@ github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= @@ -232,6 +234,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= @@ -265,6 +269,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -298,6 +304,8 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -330,6 +338,8 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= @@ -353,6 +363,10 @@ github.com/stripe/stripe-go/v85 v85.0.0 h1:HMlFJXW6I/9WvkeSAtj8V7dI5pzeDu4gS1Taq github.com/stripe/stripe-go/v85 v85.0.0/go.mod h1:5P+HGFenpWgak27T5Is6JMsmDfUC1yJnjhhmquz7kXw= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/captcha v1.3.52 h1:bPz4h9cPAD2psXNdVNHZUM/V13P05rtXoFIFn1IIPoA= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/captcha v1.3.52/go.mod h1:KDlcSxrt2pw9nenvXpw/VHipuokbA0j2iRXV+2gF5j8= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.52 h1:agyo5WB5bclK346U0Y4G40c//eA5qZbtBVGdp3HhuK8= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.52/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index ce99a302b8..8c8f262bd8 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://static.cloudflareinsights.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://fonts.googleapis.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://*.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'; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.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://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'" // UMQ(用户消息队列)模式常量 const ( diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 8ad27e122a..a229cdcf2e 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -156,6 +156,11 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { TurnstileEnabled: settings.TurnstileEnabled, TurnstileSiteKey: settings.TurnstileSiteKey, TurnstileSecretKeyConfigured: settings.TurnstileSecretKeyConfigured, + TencentCaptchaEnabled: settings.TencentCaptchaEnabled, + TencentCaptchaAppID: settings.TencentCaptchaAppID, + TencentCaptchaAppSecretKeyConfigured: settings.TencentCaptchaAppSecretKeyConfigured, + TencentCaptchaCloudSecretIDConfigured: settings.TencentCaptchaCloudSecretIDConfigured, + TencentCaptchaCloudSecretKeyConfigured: settings.TencentCaptchaCloudSecretKeyConfigured, APIKeyACLTrustForwardedIP: settings.APIKeyACLTrustForwardedIP, ForwardedClientIPHeaders: settings.ForwardedClientIPHeaders, LinuxDoConnectEnabled: settings.LinuxDoConnectEnabled, diff --git a/backend/internal/handler/admin/setting_handler_audit.go b/backend/internal/handler/admin/setting_handler_audit.go index ab91ac9f52..0678e6f4de 100644 --- a/backend/internal/handler/admin/setting_handler_audit.go +++ b/backend/internal/handler/admin/setting_handler_audit.go @@ -107,6 +107,21 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if req.TurnstileSecretKey != "" { changed = append(changed, "turnstile_secret_key") } + if before.TencentCaptchaEnabled != after.TencentCaptchaEnabled { + changed = append(changed, "tencent_captcha_enabled") + } + if before.TencentCaptchaAppID != after.TencentCaptchaAppID { + changed = append(changed, "tencent_captcha_app_id") + } + if req.TencentCaptchaAppSecretKey != "" { + changed = append(changed, "tencent_captcha_app_secret_key") + } + if req.TencentCaptchaCloudSecretID != "" { + changed = append(changed, "tencent_captcha_cloud_secret_id") + } + if req.TencentCaptchaCloudSecretKey != "" { + changed = append(changed, "tencent_captcha_cloud_secret_key") + } if before.APIKeyACLTrustForwardedIP != after.APIKeyACLTrustForwardedIP { changed = append(changed, "api_key_acl_trust_forwarded_ip") } 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 e39e664a3e..b27ff908fa 100644 --- a/backend/internal/handler/admin/setting_handler_partial_payload_test.go +++ b/backend/internal/handler/admin/setting_handler_partial_payload_test.go @@ -65,3 +65,75 @@ func TestUpdateSettingsSMTPFromAliasIsWritable(t *testing.T) { require.Equal(t, "new@example.com", repo.values[service.SettingKeySMTPFrom]) } + +func TestUpdateSettingsRejectsTwoCaptchaProviders(t *testing.T) { + h, _ := newStepUpSwitchTestHandler(t, map[string]string{ + service.SettingKeyTurnstileEnabled: "true", + service.SettingKeyTurnstileSiteKey: "site-key", + service.SettingKeyTurnstileSecretKey: "turnstile-secret", + }) + + rec := doUpdateSettings(t, h, map[string]any{ + "turnstile_enabled": true, + "turnstile_site_key": "site-key", + "turnstile_secret_key": "turnstile-secret", + "tencent_captcha_enabled": true, + "tencent_captcha_app_id": "123456789", + "tencent_captcha_app_secret_key": "app-secret", + "tencent_captcha_cloud_secret_id": "cloud-secret-id", + "tencent_captcha_cloud_secret_key": "cloud-secret-key", + }, nil) + + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Contains(t, rec.Body.String(), "cannot be enabled at the same time") +} + +func TestUpdateSettingsRequiresFourTencentCaptchaCredentialsWhenEnabled(t *testing.T) { + h, _ := newStepUpSwitchTestHandler(t, map[string]string{}) + + rec := doUpdateSettings(t, h, map[string]any{ + "tencent_captcha_enabled": true, + "tencent_captcha_app_id": "123456789", + }, nil) + + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Contains(t, rec.Body.String(), "AppSecretKey") +} + +func TestUpdateSettingsRetainsStoredTencentCaptchaCredentialsWhenInputsEmpty(t *testing.T) { + h, repo := newStepUpSwitchTestHandler(t, map[string]string{ + service.SettingKeyTencentCaptchaAppSecretKey: "stored-app-secret", + service.SettingKeyTencentCaptchaCloudSecretID: "stored-cloud-secret-id", + service.SettingKeyTencentCaptchaCloudSecretKey: "stored-cloud-secret-key", + }) + + rec := doUpdateSettings(t, h, map[string]any{ + "tencent_captcha_enabled": true, + "tencent_captcha_app_id": "123456789", + "tencent_captcha_app_secret_key": "", + "tencent_captcha_cloud_secret_id": "", + "tencent_captcha_cloud_secret_key": "", + }, nil) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "stored-app-secret", repo.values[service.SettingKeyTencentCaptchaAppSecretKey]) + require.Equal(t, "stored-cloud-secret-id", repo.values[service.SettingKeyTencentCaptchaCloudSecretID]) + require.Equal(t, "stored-cloud-secret-key", repo.values[service.SettingKeyTencentCaptchaCloudSecretKey]) +} + +func TestUpdateSettingsValidatesTencentCaptchaAppIDWhenEnabledFlagIsOmitted(t *testing.T) { + h, _ := newStepUpSwitchTestHandler(t, map[string]string{ + service.SettingKeyTencentCaptchaEnabled: "true", + service.SettingKeyTencentCaptchaAppID: "123456789", + service.SettingKeyTencentCaptchaAppSecretKey: "stored-app-secret", + service.SettingKeyTencentCaptchaCloudSecretID: "stored-cloud-secret-id", + service.SettingKeyTencentCaptchaCloudSecretKey: "stored-cloud-secret-key", + }) + + rec := doUpdateSettings(t, h, map[string]any{ + "tencent_captcha_app_id": "not-a-number", + }, nil) + + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Contains(t, rec.Body.String(), "positive integer") +} diff --git a/backend/internal/handler/admin/setting_handler_platform_quota_test.go b/backend/internal/handler/admin/setting_handler_platform_quota_test.go index 39048394e2..1014b07ab9 100644 --- a/backend/internal/handler/admin/setting_handler_platform_quota_test.go +++ b/backend/internal/handler/admin/setting_handler_platform_quota_test.go @@ -64,6 +64,23 @@ func TestDiffSettings_NoChangeWhenEqual(t *testing.T) { } } +func TestSettingsAuditRequestDoesNotInheritStoredTencentSecrets(t *testing.T) { + req := UpdateSettingsRequest{ + TencentCaptchaAppSecretKey: " ", + TencentCaptchaCloudSecretID: "\t", + TencentCaptchaCloudSecretKey: "\n", + } + + auditReq := settingsAuditRequest(req) + req.TencentCaptchaAppSecretKey = "stored-app-secret" + req.TencentCaptchaCloudSecretID = "stored-secret-id" + req.TencentCaptchaCloudSecretKey = "stored-secret-key" + + require.Empty(t, auditReq.TencentCaptchaAppSecretKey) + require.Empty(t, auditReq.TencentCaptchaCloudSecretID) + require.Empty(t, auditReq.TencentCaptchaCloudSecretKey) +} + func TestDiffSettings_DetectsCompactHomeChange(t *testing.T) { changed := diffSettings( &service.SystemSettings{}, diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index 54569bc47a..843b67a366 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -7,6 +7,7 @@ import ( "log/slog" "net/http" "reflect" + "strconv" "strings" "github.com/Wei-Shaw/sub2api/internal/config" @@ -53,6 +54,13 @@ type UpdateSettingsRequest struct { TurnstileSiteKey string `json:"turnstile_site_key"` TurnstileSecretKey string `json:"turnstile_secret_key"` + // 腾讯天御验证码设置 + TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"` + TencentCaptchaAppID string `json:"tencent_captcha_app_id"` + TencentCaptchaAppSecretKey string `json:"tencent_captcha_app_secret_key"` + TencentCaptchaCloudSecretID string `json:"tencent_captcha_cloud_secret_id"` + TencentCaptchaCloudSecretKey string `json:"tencent_captcha_cloud_secret_key"` + // API Key IP 访问控制设置 APIKeyACLTrustForwardedIP *bool `json:"api_key_acl_trust_forwarded_ip"` ForwardedClientIPHeaders *[]string `json:"forwarded_client_ip_headers"` @@ -436,6 +444,13 @@ func omittedSettingKeys(sentFields map[string]json.RawMessage) service.OmittedSe return omitted } +func settingsAuditRequest(req UpdateSettingsRequest) UpdateSettingsRequest { + req.TencentCaptchaAppSecretKey = strings.TrimSpace(req.TencentCaptchaAppSecretKey) + req.TencentCaptchaCloudSecretID = strings.TrimSpace(req.TencentCaptchaCloudSecretID) + req.TencentCaptchaCloudSecretKey = strings.TrimSpace(req.TencentCaptchaCloudSecretKey) + return req +} + func (h *SettingHandler) UpdateSettings(c *gin.Context) { var sentFields map[string]json.RawMessage if err := c.ShouldBindBodyWith(&sentFields, binding.JSON); err != nil { @@ -447,6 +462,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { response.BadRequest(c, "Invalid request: "+err.Error()) return } + auditReq := settingsAuditRequest(req) omitted := omittedSettingKeys(sentFields) previousSettings, err := h.settingService.GetAllSettings(c.Request.Context()) @@ -563,6 +579,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { req.SMTPPassword = strings.TrimSpace(req.SMTPPassword) req.SMTPFrom = strings.TrimSpace(req.SMTPFrom) req.SMTPFromName = strings.TrimSpace(req.SMTPFromName) + req.TencentCaptchaAppID = strings.TrimSpace(req.TencentCaptchaAppID) + req.TencentCaptchaAppSecretKey = strings.TrimSpace(req.TencentCaptchaAppSecretKey) + req.TencentCaptchaCloudSecretID = strings.TrimSpace(req.TencentCaptchaCloudSecretID) + req.TencentCaptchaCloudSecretKey = strings.TrimSpace(req.TencentCaptchaCloudSecretKey) if req.SMTPPort <= 0 { req.SMTPPort = 587 } @@ -584,6 +604,19 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { req.SMTPUseTLS = previousSettings.SMTPUseTLS } + turnstileEnabled := req.TurnstileEnabled + if _, sent := sentFields["turnstile_enabled"]; !sent { + turnstileEnabled = previousSettings.TurnstileEnabled + } + tencentCaptchaEnabled := req.TencentCaptchaEnabled + if _, sent := sentFields["tencent_captcha_enabled"]; !sent { + tencentCaptchaEnabled = previousSettings.TencentCaptchaEnabled + } + if turnstileEnabled && tencentCaptchaEnabled { + response.BadRequest(c, "Cloudflare Turnstile and Tencent Captcha cannot be enabled at the same time") + return + } + // Turnstile 参数验证 if req.TurnstileEnabled { // 检查必填字段 @@ -611,6 +644,38 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } } + if tencentCaptchaEnabled { + if _, sent := sentFields["tencent_captcha_app_id"]; !sent { + req.TencentCaptchaAppID = previousSettings.TencentCaptchaAppID + } + appID, err := strconv.ParseUint(req.TencentCaptchaAppID, 10, 64) + if err != nil || appID == 0 { + response.BadRequest(c, "Tencent Captcha CaptchaAppId must be a positive integer when enabled") + return + } + if req.TencentCaptchaAppSecretKey == "" { + req.TencentCaptchaAppSecretKey = previousSettings.TencentCaptchaAppSecretKey + } + if req.TencentCaptchaCloudSecretID == "" { + req.TencentCaptchaCloudSecretID = previousSettings.TencentCaptchaCloudSecretID + } + if req.TencentCaptchaCloudSecretKey == "" { + req.TencentCaptchaCloudSecretKey = previousSettings.TencentCaptchaCloudSecretKey + } + if req.TencentCaptchaAppSecretKey == "" { + response.BadRequest(c, "Tencent Captcha AppSecretKey is required when enabled") + return + } + if req.TencentCaptchaCloudSecretID == "" { + response.BadRequest(c, "Tencent Cloud SecretId is required when Tencent Captcha is enabled") + return + } + if req.TencentCaptchaCloudSecretKey == "" { + response.BadRequest(c, "Tencent Cloud SecretKey is required when Tencent Captcha is enabled") + return + } + } + // TOTP 双因素认证参数验证 // 只有手动配置了加密密钥才允许启用 TOTP 功能 if req.TotpEnabled && !previousSettings.TotpEnabled { @@ -1349,6 +1414,11 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { TurnstileEnabled: req.TurnstileEnabled, TurnstileSiteKey: req.TurnstileSiteKey, TurnstileSecretKey: req.TurnstileSecretKey, + TencentCaptchaEnabled: req.TencentCaptchaEnabled, + TencentCaptchaAppID: req.TencentCaptchaAppID, + TencentCaptchaAppSecretKey: req.TencentCaptchaAppSecretKey, + TencentCaptchaCloudSecretID: req.TencentCaptchaCloudSecretID, + TencentCaptchaCloudSecretKey: req.TencentCaptchaCloudSecretKey, APIKeyACLTrustForwardedIP: func() bool { if req.APIKeyACLTrustForwardedIP != nil { return *req.APIKeyACLTrustForwardedIP @@ -1844,7 +1914,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } } - h.auditSettingsUpdate(c, previousSettings, settings, previousAuthSourceDefaults, authSourceDefaults, req) + h.auditSettingsUpdate(c, previousSettings, settings, previousAuthSourceDefaults, authSourceDefaults, auditReq) // 重新获取设置返回 updatedSettings, err := h.settingService.GetAllSettings(c.Request.Context()) @@ -1907,6 +1977,11 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { TurnstileEnabled: updatedSettings.TurnstileEnabled, TurnstileSiteKey: updatedSettings.TurnstileSiteKey, TurnstileSecretKeyConfigured: updatedSettings.TurnstileSecretKeyConfigured, + TencentCaptchaEnabled: updatedSettings.TencentCaptchaEnabled, + TencentCaptchaAppID: updatedSettings.TencentCaptchaAppID, + TencentCaptchaAppSecretKeyConfigured: updatedSettings.TencentCaptchaAppSecretKeyConfigured, + TencentCaptchaCloudSecretIDConfigured: updatedSettings.TencentCaptchaCloudSecretIDConfigured, + TencentCaptchaCloudSecretKeyConfigured: updatedSettings.TencentCaptchaCloudSecretKeyConfigured, APIKeyACLTrustForwardedIP: updatedSettings.APIKeyACLTrustForwardedIP, ForwardedClientIPHeaders: updatedSettings.ForwardedClientIPHeaders, LinuxDoConnectEnabled: updatedSettings.LinuxDoConnectEnabled, diff --git a/backend/internal/handler/auth_captcha_request_test.go b/backend/internal/handler/auth_captcha_request_test.go new file mode 100644 index 0000000000..bdbda2d82b --- /dev/null +++ b/backend/internal/handler/auth_captcha_request_test.go @@ -0,0 +1,26 @@ +//go:build unit + +package handler + +import ( + "encoding/json" + "testing" + + "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) + + 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) +} diff --git a/backend/internal/handler/auth_dingtalk_oauth.go b/backend/internal/handler/auth_dingtalk_oauth.go index 25c04ede79..7a0e2b1c05 100644 --- a/backend/internal/handler/auth_dingtalk_oauth.go +++ b/backend/internal/handler/auth_dingtalk_oauth.go @@ -113,6 +113,9 @@ func clearDingTalkCookie(c *gin.Context, name string, secure bool) { // DingTalkOAuthStart 启动 DingTalk Connect OAuth 登录流程。 // GET /api/v1/auth/oauth/dingtalk/start?redirect=/dashboard&intent=login func (h *AuthHandler) DingTalkOAuthStart(c *gin.Context) { + if !h.requireTencentCaptchaForOAuthLoginStart(c) { + return + } cfg, err := h.getDingTalkOAuthConfig(c.Request.Context()) if err != nil { frontendCB := dingTalkOAuthDefaultFrontendCB @@ -165,7 +168,7 @@ func (h *AuthHandler) DingTalkOAuthStart(c *gin.Context) { return } - c.Redirect(http.StatusFound, authURL) + respondOAuthStart(c, authURL) } // ─── buildDingTalkAuthorizeURL ───────────────────────────────────────────── diff --git a/backend/internal/handler/auth_email_oauth.go b/backend/internal/handler/auth_email_oauth.go index 09567b1b3d..9e29a43597 100644 --- a/backend/internal/handler/auth_email_oauth.go +++ b/backend/internal/handler/auth_email_oauth.go @@ -59,6 +59,9 @@ func (h *AuthHandler) CompleteGoogleOAuthRegistration(c *gin.Context) { } func (h *AuthHandler) emailOAuthStart(c *gin.Context, provider string) { + if !h.requireTencentCaptchaForOAuthLoginStart(c) { + return + } cfg, err := h.getEmailOAuthConfig(c.Request.Context(), provider) if err != nil { response.ErrorFrom(c, err) @@ -90,7 +93,7 @@ func (h *AuthHandler) emailOAuthStart(c *gin.Context, provider string) { response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build oauth authorization url").WithCause(err)) return } - c.Redirect(http.StatusFound, authURL) + respondOAuthStart(c, authURL) } func (h *AuthHandler) emailOAuthCallback(c *gin.Context, provider string) { diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 7b38ee130e..2e267877f9 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -48,19 +48,23 @@ func NewAuthHandler(cfg *config.Config, authService *service.AuthService, userSe // RegisterRequest represents the registration request payload type RegisterRequest struct { - Email string `json:"email" binding:"required,email"` - Password string `json:"password" binding:"required,min=6"` - VerifyCode string `json:"verify_code"` - TurnstileToken string `json:"turnstile_token"` - PromoCode string `json:"promo_code"` // 注册优惠码 - InvitationCode string `json:"invitation_code"` // 邀请码 - AffCode string `json:"aff_code"` // 邀请返利码 + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=6"` + VerifyCode string `json:"verify_code"` + TurnstileToken string `json:"turnstile_token"` + TencentCaptchaTicket string `json:"tencent_captcha_ticket"` + TencentCaptchaRandstr string `json:"tencent_captcha_randstr"` + PromoCode string `json:"promo_code"` // 注册优惠码 + InvitationCode string `json:"invitation_code"` // 邀请码 + AffCode string `json:"aff_code"` // 邀请返利码 } // SendVerifyCodeRequest 发送验证码请求 type SendVerifyCodeRequest struct { - Email string `json:"email" binding:"required,email"` - TurnstileToken string `json:"turnstile_token"` + Email string `json:"email" binding:"required,email"` + TurnstileToken string `json:"turnstile_token"` + TencentCaptchaTicket string `json:"tencent_captcha_ticket"` + TencentCaptchaRandstr string `json:"tencent_captcha_randstr"` } // SendVerifyCodeResponse 发送验证码响应 @@ -71,9 +75,19 @@ type SendVerifyCodeResponse struct { // LoginRequest represents the login request payload type LoginRequest struct { - Email string `json:"email" binding:"required,email"` - Password string `json:"password" binding:"required"` - TurnstileToken string `json:"turnstile_token"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` + TurnstileToken string `json:"turnstile_token"` + TencentCaptchaTicket string `json:"tencent_captcha_ticket"` + TencentCaptchaRandstr string `json:"tencent_captcha_randstr"` +} + +func captchaProof(turnstileToken, tencentTicket, tencentRandstr string) service.CaptchaProof { + return service.CaptchaProof{ + TurnstileToken: turnstileToken, + TencentTicket: tencentTicket, + TencentRandstr: tencentRandstr, + } } // AuthResponse 认证响应格式(匹配前端期望) @@ -169,8 +183,9 @@ func (h *AuthHandler) Register(c *gin.Context) { return } - // Turnstile 验证(邮箱验证码注册场景避免重复校验一次性 token) - if err := h.authService.VerifyTurnstileForRegister(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c), req.VerifyCode); err != nil { + // 验证当前启用的验证码(邮箱验证码注册场景避免重复校验一次性票据) + proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr) + if err := h.authService.VerifyCaptchaForRegister(c.Request.Context(), proof, ip.GetClientIP(c), req.VerifyCode); err != nil { response.ErrorFrom(c, err) return } @@ -201,8 +216,8 @@ func (h *AuthHandler) SendVerifyCode(c *gin.Context) { return } - // Turnstile 验证 - if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil { + proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr) + if err := h.authService.VerifyCaptcha(c.Request.Context(), proof, ip.GetClientIP(c)); err != nil { response.ErrorFrom(c, err) return } @@ -228,8 +243,8 @@ func (h *AuthHandler) Login(c *gin.Context) { return } - // Turnstile 验证 - if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil { + proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr) + if err := h.authService.VerifyCaptcha(c.Request.Context(), proof, ip.GetClientIP(c)); err != nil { response.ErrorFrom(c, err) return } @@ -573,8 +588,10 @@ func (h *AuthHandler) ValidateInvitationCode(c *gin.Context) { // ForgotPasswordRequest 忘记密码请求 type ForgotPasswordRequest struct { - Email string `json:"email" binding:"required,email"` - TurnstileToken string `json:"turnstile_token"` + Email string `json:"email" binding:"required,email"` + TurnstileToken string `json:"turnstile_token"` + TencentCaptchaTicket string `json:"tencent_captcha_ticket"` + TencentCaptchaRandstr string `json:"tencent_captcha_randstr"` } // ForgotPasswordResponse 忘记密码响应 @@ -591,8 +608,8 @@ func (h *AuthHandler) ForgotPassword(c *gin.Context) { return } - // Turnstile 验证 - if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil { + proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr) + if err := h.authService.VerifyCaptcha(c.Request.Context(), proof, ip.GetClientIP(c)); err != nil { response.ErrorFrom(c, err) return } diff --git a/backend/internal/handler/auth_linuxdo_oauth.go b/backend/internal/handler/auth_linuxdo_oauth.go index 92720495b9..e1e0e4c73d 100644 --- a/backend/internal/handler/auth_linuxdo_oauth.go +++ b/backend/internal/handler/auth_linuxdo_oauth.go @@ -82,6 +82,9 @@ func (e *linuxDoTokenExchangeError) Error() string { // LinuxDoOAuthStart 启动 LinuxDo Connect OAuth 登录流程。 // GET /api/v1/auth/oauth/linuxdo/start?redirect=/dashboard func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) { + if !h.requireTencentCaptchaForOAuthLoginStart(c) { + return + } cfg, err := h.getLinuxDoOAuthConfig(c.Request.Context()) if err != nil { response.ErrorFrom(c, err) @@ -147,7 +150,7 @@ func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) { return } - c.Redirect(http.StatusFound, authURL) + respondOAuthStart(c, authURL) } // LinuxDoOAuthCallback 处理 OAuth 回调:创建/登录用户,然后重定向到前端。 diff --git a/backend/internal/handler/auth_oauth_captcha_start.go b/backend/internal/handler/auth_oauth_captcha_start.go new file mode 100644 index 0000000000..56e0bd4ada --- /dev/null +++ b/backend/internal/handler/auth_oauth_captcha_start.go @@ -0,0 +1,47 @@ +package handler + +import ( + "net/http" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +type oauthStartCaptchaRequest struct { + TencentCaptchaTicket string `json:"tencent_captcha_ticket"` + TencentCaptchaRandstr string `json:"tencent_captcha_randstr"` +} + +type oauthStartResponse struct { + AuthorizeURL string `json:"authorize_url"` +} + +func (h *AuthHandler) requireTencentCaptchaForOAuthLoginStart(c *gin.Context) bool { + if strings.HasSuffix(strings.TrimRight(c.Request.URL.Path, "/"), "/bind/start") { + return true + } + + var req oauthStartCaptchaRequest + if c.Request.Method == http.MethodPost { + _ = c.ShouldBindJSON(&req) + } + if err := h.authService.VerifyTencentCaptchaIfEnabled(c.Request.Context(), service.CaptchaProof{ + TencentTicket: req.TencentCaptchaTicket, + TencentRandstr: req.TencentCaptchaRandstr, + }, ip.GetClientIP(c)); err != nil { + response.ErrorFrom(c, err) + return false + } + return true +} + +func respondOAuthStart(c *gin.Context, authorizeURL string) { + if c.Request.Method == http.MethodPost { + response.Success(c, oauthStartResponse{AuthorizeURL: authorizeURL}) + return + } + c.Redirect(http.StatusFound, authorizeURL) +} diff --git a/backend/internal/handler/auth_oauth_captcha_start_test.go b/backend/internal/handler/auth_oauth_captcha_start_test.go new file mode 100644 index 0000000000..f630acde0c --- /dev/null +++ b/backend/internal/handler/auth_oauth_captcha_start_test.go @@ -0,0 +1,178 @@ +//go:build unit + +package handler + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type oauthCaptchaSettingRepo struct { + values map[string]string +} + +func (r *oauthCaptchaSettingRepo) Get(context.Context, string) (*service.Setting, error) { + return nil, service.ErrSettingNotFound +} +func (r *oauthCaptchaSettingRepo) GetValue(_ context.Context, key string) (string, error) { + value, ok := r.values[key] + if !ok { + return "", service.ErrSettingNotFound + } + return value, nil +} +func (r *oauthCaptchaSettingRepo) Set(context.Context, string, string) error { return nil } +func (r *oauthCaptchaSettingRepo) GetMultiple(_ context.Context, keys []string) (map[string]string, error) { + values := make(map[string]string, len(keys)) + for _, key := range keys { + if value, ok := r.values[key]; ok { + values[key] = value + } + } + return values, nil +} +func (r *oauthCaptchaSettingRepo) SetMultiple(context.Context, map[string]string) error { + return nil +} +func (r *oauthCaptchaSettingRepo) GetAll(context.Context) (map[string]string, error) { + return r.values, nil +} +func (r *oauthCaptchaSettingRepo) Delete(context.Context, string) error { return nil } + +type oauthCaptchaVerifier struct { + calls int + proof service.TencentCaptchaProof +} + +func (v *oauthCaptchaVerifier) VerifyTicket(_ context.Context, _ service.TencentCaptchaCredentials, proof service.TencentCaptchaProof, _ string) (*service.TencentCaptchaVerifyResponse, error) { + v.calls++ + v.proof = proof + return &service.TencentCaptchaVerifyResponse{CaptchaCode: 1}, nil +} + +func newOAuthCaptchaTestHandler(enabled bool) (*AuthHandler, *oauthCaptchaVerifier) { + values := map[string]string{} + if enabled { + values = map[string]string{ + service.SettingKeyTencentCaptchaEnabled: "true", + service.SettingKeyTencentCaptchaAppID: "123456789", + service.SettingKeyTencentCaptchaAppSecretKey: "app-secret", + service.SettingKeyTencentCaptchaCloudSecretID: "cloud-secret-id", + service.SettingKeyTencentCaptchaCloudSecretKey: "cloud-secret-key", + } + } + cfg := &config.Config{} + settings := service.NewSettingService(&oauthCaptchaSettingRepo{values: values}, cfg) + verifier := &oauthCaptchaVerifier{} + authService := service.NewAuthService(nil, nil, nil, nil, cfg, settings, nil, nil, nil, nil, nil, nil, nil) + authService.SetTencentCaptchaService(service.NewTencentCaptchaService(settings, verifier)) + return &AuthHandler{authService: authService, settingSvc: settings, cfg: cfg}, verifier +} + +func oauthStartHandlers() map[string]func(*AuthHandler, *gin.Context) { + return map[string]func(*AuthHandler, *gin.Context){ + "github": func(h *AuthHandler, c *gin.Context) { h.GitHubOAuthStart(c) }, + "google": func(h *AuthHandler, c *gin.Context) { h.GoogleOAuthStart(c) }, + "linuxdo": func(h *AuthHandler, c *gin.Context) { h.LinuxDoOAuthStart(c) }, + "dingtalk": func(h *AuthHandler, c *gin.Context) { h.DingTalkOAuthStart(c) }, + "wechat": func(h *AuthHandler, c *gin.Context) { h.WeChatOAuthStart(c) }, + "oidc": func(h *AuthHandler, c *gin.Context) { h.OIDCOAuthStart(c) }, + } +} + +func TestOAuthStartGetRejectsAnonymousLoginWhenTencentEnabledWithoutSideEffects(t *testing.T) { + gin.SetMode(gin.TestMode) + for provider, start := range oauthStartHandlers() { + t.Run(provider, func(t *testing.T) { + handler, verifier := newOAuthCaptchaTestHandler(true) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/"+provider+"/start?intent=bind_current_user", nil) + + start(handler, c) + + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Contains(t, recorder.Body.String(), "TENCENT_CAPTCHA_VERIFICATION_FAILED") + require.Empty(t, recorder.Header().Get("Location")) + require.Empty(t, recorder.Header().Values("Set-Cookie")) + require.Zero(t, verifier.calls) + }) + } +} + +func TestOAuthStartPostReturnsAuthorizeURLAfterTencentVerification(t *testing.T) { + gin.SetMode(gin.TestMode) + for provider := range oauthStartHandlers() { + t.Run(provider, func(t *testing.T) { + handler, verifier := newOAuthCaptchaTestHandler(true) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest( + http.MethodPost, + "/api/v1/auth/oauth/"+provider+"/start", + strings.NewReader(`{"tencent_captcha_ticket":"ticket-value","tencent_captcha_randstr":"@rand-value"}`), + ) + c.Request.Header.Set("Content-Type", "application/json") + + require.True(t, handler.requireTencentCaptchaForOAuthLoginStart(c)) + respondOAuthStart(c, "https://provider.example/authorize") + + require.Equal(t, http.StatusOK, recorder.Code) + require.Contains(t, recorder.Body.String(), `"authorize_url":"https://provider.example/authorize"`) + require.Equal(t, 1, verifier.calls) + require.Equal(t, service.TencentCaptchaProof{Ticket: "ticket-value", Randstr: "@rand-value"}, verifier.proof) + }) + } +} + +func TestOAuthStartPostRequiresTencentProofWhenEnabled(t *testing.T) { + gin.SetMode(gin.TestMode) + for provider := range oauthStartHandlers() { + t.Run(provider, func(t *testing.T) { + handler, verifier := newOAuthCaptchaTestHandler(true) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/"+provider+"/start", strings.NewReader(`{}`)) + c.Request.Header.Set("Content-Type", "application/json") + + require.False(t, handler.requireTencentCaptchaForOAuthLoginStart(c)) + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Contains(t, recorder.Body.String(), "TENCENT_CAPTCHA_VERIFICATION_FAILED") + require.Zero(t, verifier.calls) + }) + } +} + +func TestOAuthBindingPathRemainsOutsideTencentGate(t *testing.T) { + gin.SetMode(gin.TestMode) + handler := &AuthHandler{} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/bind/start", nil) + + require.True(t, handler.requireTencentCaptchaForOAuthLoginStart(c)) + require.Equal(t, http.StatusOK, recorder.Code) +} + +func TestOAuthStartGetRemainsCompatibleWhenTencentDisabled(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, verifier := newOAuthCaptchaTestHandler(false) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/github/start", nil) + + require.True(t, handler.requireTencentCaptchaForOAuthLoginStart(c)) + respondOAuthStart(c, "https://provider.example/authorize") + + require.Equal(t, http.StatusFound, recorder.Code) + require.Equal(t, "https://provider.example/authorize", recorder.Header().Get("Location")) + require.Zero(t, verifier.calls) +} diff --git a/backend/internal/handler/auth_oauth_pending_flow.go b/backend/internal/handler/auth_oauth_pending_flow.go index 79e9f0a80b..32e669b226 100644 --- a/backend/internal/handler/auth_oauth_pending_flow.go +++ b/backend/internal/handler/auth_oauth_pending_flow.go @@ -66,20 +66,25 @@ type bindPendingOAuthLoginRequest struct { } type createPendingOAuthAccountRequest struct { - Email string `json:"email" binding:"required,email"` - VerifyCode string `json:"verify_code,omitempty"` - Password string `json:"password" binding:"required,min=6"` - InvitationCode string `json:"invitation_code,omitempty"` - AffCode string `json:"aff_code,omitempty"` - AdoptDisplayName *bool `json:"adopt_display_name,omitempty"` - AdoptAvatar *bool `json:"adopt_avatar,omitempty"` + Email string `json:"email" binding:"required,email"` + VerifyCode string `json:"verify_code,omitempty"` + Password string `json:"password" binding:"required,min=6"` + TurnstileToken string `json:"turnstile_token,omitempty"` + TencentCaptchaTicket string `json:"tencent_captcha_ticket,omitempty"` + TencentCaptchaRandstr string `json:"tencent_captcha_randstr,omitempty"` + InvitationCode string `json:"invitation_code,omitempty"` + AffCode string `json:"aff_code,omitempty"` + AdoptDisplayName *bool `json:"adopt_display_name,omitempty"` + AdoptAvatar *bool `json:"adopt_avatar,omitempty"` } type sendPendingOAuthVerifyCodeRequest struct { - Email string `json:"email" binding:"required,email"` - TurnstileToken string `json:"turnstile_token,omitempty"` - PendingAuthToken string `json:"pending_auth_token,omitempty"` - PendingOAuthToken string `json:"pending_oauth_token,omitempty"` + Email string `json:"email" binding:"required,email"` + TurnstileToken string `json:"turnstile_token,omitempty"` + TencentCaptchaTicket string `json:"tencent_captcha_ticket,omitempty"` + TencentCaptchaRandstr string `json:"tencent_captcha_randstr,omitempty"` + PendingAuthToken string `json:"pending_auth_token,omitempty"` + PendingOAuthToken string `json:"pending_oauth_token,omitempty"` } func (r bindPendingOAuthLoginRequest) adoptionDecision() oauthAdoptionDecisionRequest { @@ -564,7 +569,8 @@ func (h *AuthHandler) SendPendingOAuthVerifyCode(c *gin.Context) { return } - if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil { + proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr) + if err := h.authService.VerifyCaptcha(c.Request.Context(), proof, ip.GetClientIP(c)); err != nil { response.ErrorFrom(c, err) return } @@ -1754,6 +1760,11 @@ func (h *AuthHandler) createPendingOAuthAccount(c *gin.Context, provider string) response.ErrorFrom(c, err) return } + proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr) + if err := h.authService.VerifyCaptcha(c.Request.Context(), proof, ip.GetClientIP(c)); err != nil { + response.ErrorFrom(c, err) + return + } tokenPair, user, err := h.authService.RegisterOAuthEmailAccount( c.Request.Context(), diff --git a/backend/internal/handler/auth_oidc_oauth.go b/backend/internal/handler/auth_oidc_oauth.go index 904bdc2250..ee9e195c12 100644 --- a/backend/internal/handler/auth_oidc_oauth.go +++ b/backend/internal/handler/auth_oidc_oauth.go @@ -115,6 +115,9 @@ type oidcJWK struct { // OIDCOAuthStart 启动通用 OIDC OAuth 登录流程。 // GET /api/v1/auth/oauth/oidc/start?redirect=/dashboard func (h *AuthHandler) OIDCOAuthStart(c *gin.Context) { + if !h.requireTencentCaptchaForOAuthLoginStart(c) { + return + } cfg, err := h.getOIDCOAuthConfig(c.Request.Context()) if err != nil { response.ErrorFrom(c, err) @@ -190,7 +193,7 @@ func (h *AuthHandler) OIDCOAuthStart(c *gin.Context) { return } - c.Redirect(http.StatusFound, authURL) + respondOAuthStart(c, authURL) } // OIDCOAuthCallback 处理 OIDC 回调:校验 id_token、创建/登录用户并重定向到前端。 diff --git a/backend/internal/handler/auth_wechat_oauth.go b/backend/internal/handler/auth_wechat_oauth.go index 3e8a21dbab..43a076bbb3 100644 --- a/backend/internal/handler/auth_wechat_oauth.go +++ b/backend/internal/handler/auth_wechat_oauth.go @@ -96,6 +96,9 @@ type wechatPaymentOAuthContext struct { // WeChatOAuthStart starts the WeChat OAuth login flow and stores the short-lived // browser cookies required by the rebuild pending-auth bridge. func (h *AuthHandler) WeChatOAuthStart(c *gin.Context) { + if !h.requireTencentCaptchaForOAuthLoginStart(c) { + return + } cfg, err := h.getWeChatOAuthConfig(c.Request.Context(), c.Query("mode"), c) if err != nil { response.ErrorFrom(c, err) @@ -145,7 +148,7 @@ func (h *AuthHandler) WeChatOAuthStart(c *gin.Context) { return } - c.Redirect(http.StatusFound, authURL) + respondOAuthStart(c, authURL) } // WeChatOAuthCallback exchanges the code with WeChat, resolves openid/unionid, diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index da4c6abc10..5df972724a 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -56,11 +56,16 @@ type SystemSettings struct { SMTPFromName string `json:"smtp_from_name"` SMTPUseTLS bool `json:"smtp_use_tls"` - TurnstileEnabled bool `json:"turnstile_enabled"` - TurnstileSiteKey string `json:"turnstile_site_key"` - TurnstileSecretKeyConfigured bool `json:"turnstile_secret_key_configured"` - APIKeyACLTrustForwardedIP bool `json:"api_key_acl_trust_forwarded_ip"` - ForwardedClientIPHeaders []string `json:"forwarded_client_ip_headers"` + TurnstileEnabled bool `json:"turnstile_enabled"` + TurnstileSiteKey string `json:"turnstile_site_key"` + TurnstileSecretKeyConfigured bool `json:"turnstile_secret_key_configured"` + TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"` + TencentCaptchaAppID string `json:"tencent_captcha_app_id"` + 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"` + APIKeyACLTrustForwardedIP bool `json:"api_key_acl_trust_forwarded_ip"` + ForwardedClientIPHeaders []string `json:"forwarded_client_ip_headers"` LinuxDoConnectEnabled bool `json:"linuxdo_connect_enabled"` LinuxDoConnectClientID string `json:"linuxdo_connect_client_id"` @@ -338,6 +343,8 @@ type PublicSettings struct { LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"` TurnstileEnabled bool `json:"turnstile_enabled"` TurnstileSiteKey string `json:"turnstile_site_key"` + TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"` + TencentCaptchaAppID string `json:"tencent_captcha_app_id"` SiteName string `json:"site_name"` SiteLogo string `json:"site_logo"` SiteSubtitle string `json:"site_subtitle"` diff --git a/backend/internal/handler/passkey_handler.go b/backend/internal/handler/passkey_handler.go index 8ddb16e4b3..35cb176e88 100644 --- a/backend/internal/handler/passkey_handler.go +++ b/backend/internal/handler/passkey_handler.go @@ -10,6 +10,7 @@ import ( "strings" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/pkg/response" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" @@ -45,6 +46,11 @@ type passkeyFinishRequest struct { Credential json.RawMessage `json:"credential" binding:"required"` } +type passkeyBeginLoginRequest struct { + TencentCaptchaTicket string `json:"tencent_captcha_ticket"` + TencentCaptchaRandstr string `json:"tencent_captcha_randstr"` +} + type passkeyRenameRequest struct { Name string `json:"name" binding:"required"` } @@ -70,6 +76,15 @@ func (h *PasskeyHandler) BeginLogin(c *gin.Context) { if !h.requirePasskeysEnabled(c) { return } + var req passkeyBeginLoginRequest + _ = c.ShouldBindJSON(&req) + if err := h.authService.VerifyTencentCaptchaIfEnabled(c.Request.Context(), service.CaptchaProof{ + TencentTicket: req.TencentCaptchaTicket, + TencentRandstr: req.TencentCaptchaRandstr, + }, ip.GetClientIP(c)); err != nil { + response.ErrorFrom(c, err) + return + } assertion, token, err := h.passkeys.BeginLogin(c.Request.Context()) if err != nil { response.ErrorFrom(c, err) diff --git a/backend/internal/handler/passkey_handler_test.go b/backend/internal/handler/passkey_handler_test.go index 0d3b57fae4..961c0cdc41 100644 --- a/backend/internal/handler/passkey_handler_test.go +++ b/backend/internal/handler/passkey_handler_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/service" @@ -15,8 +16,30 @@ import ( ) type passkeySwitchSettingRepo struct { - value string - err error + value string + values map[string]string + err error +} + +type passkeyCaptchaVerifierStub struct { + calls int + proof service.TencentCaptchaProof +} + +func (s *passkeyCaptchaVerifierStub) VerifyTicket(_ context.Context, _ service.TencentCaptchaCredentials, proof service.TencentCaptchaProof, _ string) (*service.TencentCaptchaVerifyResponse, error) { + s.calls++ + s.proof = proof + return &service.TencentCaptchaVerifyResponse{CaptchaCode: 1}, nil +} + +type passkeyBeginSessionStoreStub struct { + service.PasskeySessionStore + storeCalls int +} + +func (s *passkeyBeginSessionStoreStub) Store(context.Context, *service.PasskeySession, time.Duration) (string, error) { + s.storeCalls++ + return "passkey-session", nil } func (r *passkeySwitchSettingRepo) Get(context.Context, string) (*service.Setting, error) { @@ -27,7 +50,10 @@ func (r *passkeySwitchSettingRepo) GetValue(context.Context, string) (string, er } func (r *passkeySwitchSettingRepo) Set(context.Context, string, string) error { return nil } func (r *passkeySwitchSettingRepo) GetMultiple(context.Context, []string) (map[string]string, error) { - return map[string]string{}, nil + if r.err != nil { + return nil, r.err + } + return r.values, nil } func (r *passkeySwitchSettingRepo) SetMultiple(context.Context, map[string]string) error { return nil @@ -87,6 +113,74 @@ func TestPasskeyBeginLoginReportsSettingStoreFailure(t *testing.T) { require.NotContains(t, recorder.Body.String(), "PASSKEY_DISABLED") } +func newTencentProtectedPasskeyHandler(t *testing.T) (*PasskeyHandler, *passkeyCaptchaVerifierStub, *passkeyBeginSessionStoreStub) { + t.Helper() + cfg := &config.Config{WebAuthn: config.WebAuthnConfig{ + Enabled: true, + RPDisplayName: "Sub2API", + RPID: "sub2api.example.com", + RPOrigins: []string{"https://sub2api.example.com"}, + }} + repo := &passkeySwitchSettingRepo{ + value: "true", + values: map[string]string{ + service.SettingKeyTencentCaptchaEnabled: "true", + service.SettingKeyTencentCaptchaAppID: "123456789", + service.SettingKeyTencentCaptchaAppSecretKey: "app-secret", + service.SettingKeyTencentCaptchaCloudSecretID: "cloud-secret-id", + service.SettingKeyTencentCaptchaCloudSecretKey: "cloud-secret-key", + }, + } + settings := service.NewSettingService(repo, cfg) + verifier := &passkeyCaptchaVerifierStub{} + authService := service.NewAuthService(nil, nil, nil, nil, cfg, settings, nil, nil, nil, nil, nil, nil, nil) + authService.SetTencentCaptchaService(service.NewTencentCaptchaService(settings, verifier)) + sessions := &passkeyBeginSessionStoreStub{} + passkeys, err := service.NewPasskeyService(cfg, nil, sessions, nil) + require.NoError(t, err) + return NewPasskeyHandler(passkeys, authService, settings), verifier, sessions +} + +func newPasskeyBeginLoginContext(body string) (*gin.Context, *httptest.ResponseRecorder) { + recorder := httptest.NewRecorder() + ginContext, _ := gin.CreateTestContext(recorder) + ginContext.Request = httptest.NewRequest( + http.MethodPost, + "/api/v1/auth/passkey/login/begin", + strings.NewReader(body), + ) + ginContext.Request.Header.Set("Content-Type", "application/json") + return ginContext, recorder +} + +func TestPasskeyBeginLoginRejectsMissingTencentCaptchaProof(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, verifier, sessions := newTencentProtectedPasskeyHandler(t) + ginContext, recorder := newPasskeyBeginLoginContext(`{}`) + + handler.BeginLogin(ginContext) + + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Contains(t, recorder.Body.String(), "TENCENT_CAPTCHA_VERIFICATION_FAILED") + require.Zero(t, verifier.calls) + require.Zero(t, sessions.storeCalls) +} + +func TestPasskeyBeginLoginAcceptsTencentCaptchaProofBeforeCeremony(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, verifier, sessions := newTencentProtectedPasskeyHandler(t) + ginContext, recorder := newPasskeyBeginLoginContext( + `{"tencent_captcha_ticket":"ticket-value","tencent_captcha_randstr":"@rand-value"}`, + ) + + handler.BeginLogin(ginContext) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, 1, verifier.calls) + require.Equal(t, service.TencentCaptchaProof{Ticket: "ticket-value", Randstr: "@rand-value"}, verifier.proof) + require.Equal(t, 1, sessions.storeCalls) +} + func TestPasskeyCredentialListRemainsAvailableWhenSignInDisabled(t *testing.T) { gin.SetMode(gin.TestMode) handler := NewPasskeyHandler(nil, nil, nil) diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index d825d7ef63..5070ed35a5 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -60,6 +60,8 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { LoginAgreementDocuments: publicLoginAgreementDocumentsToDTO(settings.LoginAgreementDocuments), TurnstileEnabled: settings.TurnstileEnabled, TurnstileSiteKey: settings.TurnstileSiteKey, + TencentCaptchaEnabled: settings.TencentCaptchaEnabled, + TencentCaptchaAppID: settings.TencentCaptchaAppID, SiteName: settings.SiteName, SiteLogo: settings.SiteLogo, SiteSubtitle: settings.SiteSubtitle, diff --git a/backend/internal/handler/setting_handler_public_test.go b/backend/internal/handler/setting_handler_public_test.go index 45d66f8e33..9f98eba6af 100644 --- a/backend/internal/handler/setting_handler_public_test.go +++ b/backend/internal/handler/setting_handler_public_test.go @@ -82,6 +82,38 @@ func TestSettingHandler_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t require.True(t, resp.Data.ForceEmailOnThirdPartySignup) } +func TestSettingHandler_GetPublicSettings_ExposesTencentCaptchaConfiguration(t *testing.T) { + gin.SetMode(gin.TestMode) + + repo := &settingHandlerPublicRepoStub{ + values: map[string]string{ + service.SettingKeyTencentCaptchaEnabled: "true", + service.SettingKeyTencentCaptchaAppID: "123456789", + }, + } + h := NewSettingHandler(service.NewSettingService(repo, &config.Config{}), "test-version") + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/settings/public", nil) + + h.GetPublicSettings(c) + + require.Equal(t, http.StatusOK, recorder.Code) + + var resp struct { + Code int `json:"code"` + Data struct { + TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"` + TencentCaptchaAppID string `json:"tencent_captcha_app_id"` + } `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) +} + func TestSettingHandler_GetPublicSettings_ExposesWeChatOAuthModeCapabilities(t *testing.T) { gin.SetMode(gin.TestMode) h := NewSettingHandler(service.NewSettingService(&settingHandlerPublicRepoStub{ diff --git a/backend/internal/repository/tencent_captcha_service.go b/backend/internal/repository/tencent_captcha_service.go new file mode 100644 index 0000000000..5e7aa489a7 --- /dev/null +++ b/backend/internal/repository/tencent_captcha_service.go @@ -0,0 +1,76 @@ +package repository + +import ( + "context" + "fmt" + + "github.com/Wei-Shaw/sub2api/internal/service" + captcha "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/captcha/v20190722" + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" + "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 tencentCaptchaVerifier struct { + newClient tencentCaptchaClientFactory +} + +func NewTencentCaptchaVerifier() service.TencentCaptchaVerifier { + return &tencentCaptchaVerifier{newClient: newTencentCaptchaSDKClient} +} + +func newTencentCaptchaSDKClient(secretID, secretKey string) (tencentCaptchaAPI, error) { + clientProfile := profile.NewClientProfile() + clientProfile.HttpProfile.Endpoint = tencentCaptchaEndpoint + 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) + if err != nil { + return nil, fmt.Errorf("create tencent captcha client: %w", err) + } + request := captcha.NewDescribeCaptchaResultRequest() + request.CaptchaType = common.Uint64Ptr(9) + request.Ticket = common.StringPtr(proof.Ticket) + request.UserIp = common.StringPtr(remoteIP) + request.Randstr = common.StringPtr(proof.Randstr) + request.CaptchaAppId = common.Uint64Ptr(credentials.AppID) + request.AppSecretKey = common.StringPtr(credentials.AppSecretKey) + + response, err := client.DescribeCaptchaResultWithContext(ctx, request) + if err != nil { + return nil, fmt.Errorf("describe captcha result: %w", err) + } + if response == nil || response.Response == nil { + return nil, fmt.Errorf("describe captcha result: empty response") + } + return &service.TencentCaptchaVerifyResponse{ + CaptchaCode: valueOrZero(response.Response.CaptchaCode), + CaptchaMsg: valueOrEmpty(response.Response.CaptchaMsg), + RequestID: valueOrEmpty(response.Response.RequestId), + }, nil +} + +func valueOrZero(value *int64) int64 { + if value == nil { + return 0 + } + return *value +} + +func valueOrEmpty(value *string) string { + if value == nil { + return "" + } + return *value +} diff --git a/backend/internal/repository/tencent_captcha_service_test.go b/backend/internal/repository/tencent_captcha_service_test.go new file mode 100644 index 0000000000..5917b39728 --- /dev/null +++ b/backend/internal/repository/tencent_captcha_service_test.go @@ -0,0 +1,66 @@ +//go:build unit + +package repository + +import ( + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" + capcha "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/captcha/v20190722" +) + +type tencentCaptchaAPIStub struct { + request *capcha.DescribeCaptchaResultRequest + response *capcha.DescribeCaptchaResultResponse + err error +} + +func (s *tencentCaptchaAPIStub) DescribeCaptchaResultWithContext(_ context.Context, request *capcha.DescribeCaptchaResultRequest) (*capcha.DescribeCaptchaResultResponse, error) { + s.request = request + return s.response, s.err +} + +func TestTencentCaptchaVerifierMapsCredentialsRequestAndResponse(t *testing.T) { + code := int64(1) + message := "OK" + requestID := "request-id" + client := &tencentCaptchaAPIStub{response: &capcha.DescribeCaptchaResultResponse{ + Response: &capcha.DescribeCaptchaResultResponseParams{ + CaptchaCode: &code, + CaptchaMsg: &message, + RequestId: &requestID, + }, + }} + var gotSecretID, gotSecretKey string + verifier := &tencentCaptchaVerifier{ + newClient: func(secretID, secretKey string) (tencentCaptchaAPI, error) { + gotSecretID, gotSecretKey = secretID, secretKey + return client, nil + }, + } + + result, err := verifier.VerifyTicket(context.Background(), service.TencentCaptchaCredentials{ + AppID: 123456789, + AppSecretKey: "app-secret", + CloudSecretID: "cloud-secret-id", + CloudSecretKey: "cloud-secret-key", + }, 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) + require.NotNil(t, client.request) + require.Equal(t, uint64(9), *client.request.CaptchaType) + require.Equal(t, uint64(123456789), *client.request.CaptchaAppId) + require.Equal(t, "app-secret", *client.request.AppSecretKey) + require.Equal(t, "ticket", *client.request.Ticket) + require.Equal(t, "@rand", *client.request.Randstr) + require.Equal(t, "203.0.113.10", *client.request.UserIp) + require.Equal(t, &service.TencentCaptchaVerifyResponse{ + CaptchaCode: 1, + CaptchaMsg: "OK", + RequestID: "request-id", + }, result) +} diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index 782b1b9ce5..5fbb007f37 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -149,6 +149,7 @@ var ProviderSet = wire.NewSet( // HTTP service ports (DI Strategy A: return interface directly) NewTurnstileVerifier, + NewTencentCaptchaVerifier, ProvidePricingRemoteClient, ProvideGitHubReleaseClient, NewProxyExitInfoProber, diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index f0ad8dcdb3..bdf4230874 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -741,6 +741,11 @@ func TestAPIContracts(t *testing.T) { "turnstile_enabled": true, "turnstile_site_key": "site-key", "turnstile_secret_key_configured": true, + "tencent_captcha_enabled": false, + "tencent_captcha_app_id": "", + "tencent_captcha_app_secret_key_configured": false, + "tencent_captcha_cloud_secret_id_configured": false, + "tencent_captcha_cloud_secret_key_configured": false, "linuxdo_connect_enabled": false, "linuxdo_connect_client_id": "", "linuxdo_connect_client_secret_configured": false, @@ -1066,6 +1071,11 @@ func TestAPIContracts(t *testing.T) { "turnstile_enabled": false, "turnstile_site_key": "", "turnstile_secret_key_configured": false, + "tencent_captcha_enabled": false, + "tencent_captcha_app_id": "", + "tencent_captcha_app_secret_key_configured": false, + "tencent_captcha_cloud_secret_id_configured": false, + "tencent_captcha_cloud_secret_key_configured": false, "linuxdo_connect_enabled": false, "linuxdo_connect_client_id": "", "linuxdo_connect_client_secret_configured": false, diff --git a/backend/internal/server/middleware/security_headers.go b/backend/internal/server/middleware/security_headers.go index e71f6318d4..694199ab21 100644 --- a/backend/internal/server/middleware/security_headers.go +++ b/backend/internal/server/middleware/security_headers.go @@ -18,6 +18,10 @@ 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 = "https://turing.captcha.qcloud.com" + // TencentCaptchaStaticDomain is the Tencent Captcha static asset domain. + TencentCaptchaStaticDomain = "https://*.captcha.gtimg.com" // StripeDomain is the domain for Stripe.js SDK StripeDomain = "https://*.stripe.com" // AirwallexStaticDomain 是 Airwallex 生产环境 SDK 脚本域名。 @@ -35,6 +39,9 @@ var requiredCSPDirectiveValues = []struct { value string }{ {"script-src", CloudflareInsightsDomain}, + {"script-src", TencentCaptchaDomain}, + {"frame-src", TencentCaptchaDomain}, + {"style-src", TencentCaptchaStaticDomain}, {"script-src", StripeDomain}, {"frame-src", StripeDomain}, {"script-src", AirwallexStaticDomain}, @@ -127,8 +134,8 @@ func isAPIRoutePath(c *gin.Context) bool { strings.HasPrefix(path, "/images") } -// enhanceCSPPolicy 确保 CSP 策略包含 nonce 支持和支付 SDK 必需域名。 -// 这样旧配置文件没有及时补域名时,前端支付组件仍能正常加载。 +// enhanceCSPPolicy 确保 CSP 策略包含 nonce 支持和运行时组件必需域名。 +// 这样旧配置文件没有及时补域名时,验证码和支付组件仍能正常加载。 func enhanceCSPPolicy(policy string) string { // Add nonce placeholder to script-src if not present if !strings.Contains(policy, NonceTemplate) && !strings.Contains(policy, "'nonce-") { diff --git a/backend/internal/server/middleware/security_headers_test.go b/backend/internal/server/middleware/security_headers_test.go index c980f7e6e4..3581c53b31 100644 --- a/backend/internal/server/middleware/security_headers_test.go +++ b/backend/internal/server/middleware/security_headers_test.go @@ -192,6 +192,7 @@ func TestSecurityHeaders(t *testing.T) { assert.NotEmpty(t, csp) // Default policy should contain these elements assert.Contains(t, csp, "default-src 'self'") + assert.Contains(t, csp, TencentCaptchaDomain) }) t.Run("uses_default_policy_when_whitespace_only", func(t *testing.T) { @@ -313,6 +314,16 @@ func TestEnhanceCSPPolicy(t *testing.T) { assert.Equal(t, 1, count) }) + t.Run("adds_tencent_captcha_domain_for_web_sdk", func(t *testing.T) { + policy := "default-src 'self'; script-src 'self' __CSP_NONCE__" + enhanced := enhanceCSPPolicy(policy) + + assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaDomain)) + 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") + }) + t.Run("handles_policy_without_script_src", func(t *testing.T) { policy := "default-src 'self'" enhanced := enhanceCSPPolicy(policy) diff --git a/backend/internal/server/routes/auth.go b/backend/internal/server/routes/auth.go index 2b4b9bd31d..18f39ed314 100644 --- a/backend/internal/server/routes/auth.go +++ b/backend/internal/server/routes/auth.go @@ -73,7 +73,13 @@ func RegisterAuthRoutes( FailureMode: middleware.RateLimitFailClose, }), h.Auth.ResetPassword) auth.GET("/oauth/linuxdo/start", h.Auth.LinuxDoOAuthStart) + auth.POST("/oauth/linuxdo/start", rateLimiter.LimitWithOptions("oauth-linuxdo-start", 20, time.Minute, middleware.RateLimitOptions{ + FailureMode: middleware.RateLimitFailClose, + }), h.Auth.LinuxDoOAuthStart) auth.GET("/oauth/github/start", h.Auth.GitHubOAuthStart) + auth.POST("/oauth/github/start", rateLimiter.LimitWithOptions("oauth-github-start", 20, time.Minute, middleware.RateLimitOptions{ + FailureMode: middleware.RateLimitFailClose, + }), h.Auth.GitHubOAuthStart) auth.GET("/oauth/github/callback", h.Auth.GitHubOAuthCallback) auth.POST("/oauth/github/complete-registration", rateLimiter.LimitWithOptions("oauth-github-complete", 10, time.Minute, middleware.RateLimitOptions{ @@ -82,6 +88,9 @@ func RegisterAuthRoutes( h.Auth.CompleteGitHubOAuthRegistration, ) auth.GET("/oauth/google/start", h.Auth.GoogleOAuthStart) + auth.POST("/oauth/google/start", rateLimiter.LimitWithOptions("oauth-google-start", 20, time.Minute, middleware.RateLimitOptions{ + FailureMode: middleware.RateLimitFailClose, + }), h.Auth.GoogleOAuthStart) auth.GET("/oauth/google/callback", h.Auth.GoogleOAuthCallback) auth.POST("/oauth/google/complete-registration", rateLimiter.LimitWithOptions("oauth-google-complete", 10, time.Minute, middleware.RateLimitOptions{ @@ -97,6 +106,9 @@ func RegisterAuthRoutes( }) auth.GET("/oauth/linuxdo/callback", h.Auth.LinuxDoOAuthCallback) auth.GET("/oauth/wechat/start", h.Auth.WeChatOAuthStart) + auth.POST("/oauth/wechat/start", rateLimiter.LimitWithOptions("oauth-wechat-start", 20, time.Minute, middleware.RateLimitOptions{ + FailureMode: middleware.RateLimitFailClose, + }), h.Auth.WeChatOAuthStart) auth.GET("/oauth/wechat/bind/start", func(c *gin.Context) { query := c.Request.URL.Query() query.Set("intent", "bind_current_user") @@ -167,6 +179,9 @@ func RegisterAuthRoutes( h.Auth.CreateWeChatOAuthAccount, ) auth.GET("/oauth/oidc/start", h.Auth.OIDCOAuthStart) + auth.POST("/oauth/oidc/start", rateLimiter.LimitWithOptions("oauth-oidc-start", 20, time.Minute, middleware.RateLimitOptions{ + FailureMode: middleware.RateLimitFailClose, + }), h.Auth.OIDCOAuthStart) auth.GET("/oauth/oidc/bind/start", func(c *gin.Context) { query := c.Request.URL.Query() query.Set("intent", "bind_current_user") @@ -193,6 +208,9 @@ func RegisterAuthRoutes( h.Auth.CreateOIDCOAuthAccount, ) auth.GET("/oauth/dingtalk/start", h.Auth.DingTalkOAuthStart) + auth.POST("/oauth/dingtalk/start", rateLimiter.LimitWithOptions("oauth-dingtalk-start", 20, time.Minute, middleware.RateLimitOptions{ + FailureMode: middleware.RateLimitFailClose, + }), h.Auth.DingTalkOAuthStart) auth.GET("/oauth/dingtalk/bind/start", func(c *gin.Context) { query := c.Request.URL.Query() query.Set("intent", "bind_current_user") diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index 5617f5f4ec..3de2d4f6bb 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -43,6 +43,7 @@ var ( ErrInvitationCodeRequired = infraerrors.BadRequest("INVITATION_CODE_REQUIRED", "invitation code is required") ErrInvitationCodeInvalid = infraerrors.BadRequest("INVITATION_CODE_INVALID", "invalid or used invitation code") ErrOAuthInvitationRequired = infraerrors.Forbidden("OAUTH_INVITATION_REQUIRED", "invitation code required to complete oauth registration") + ErrCaptchaProviderConflict = infraerrors.ServiceUnavailable("CAPTCHA_PROVIDER_CONFLICT", "multiple captcha providers are enabled") ) // maxTokenLength 限制 token 大小,避免超长 header 触发解析时的异常内存分配。 @@ -74,6 +75,7 @@ type AuthService struct { settingService *SettingService emailService *EmailService turnstileService *TurnstileService + tencentCaptchaService *TencentCaptchaService emailQueueService *EmailQueueService promoService *PromoService affiliateService *AffiliateService @@ -81,6 +83,12 @@ type AuthService struct { userPlatformQuotaRepo UserPlatformQuotaRepository } +type CaptchaProof struct { + TurnstileToken string + TencentTicket string + TencentRandstr string +} + type DefaultSubscriptionAssigner interface { AssignOrExtendSubscription(ctx context.Context, input *AssignSubscriptionInput) (*UserSubscription, bool, error) } @@ -132,6 +140,10 @@ func (s *AuthService) EntClient() *dbent.Client { return s.entClient } +func (s *AuthService) SetTencentCaptchaService(tencentCaptchaService *TencentCaptchaService) { + s.tencentCaptchaService = tencentCaptchaService +} + // Register 用户注册,返回token和用户 func (s *AuthService) Register(ctx context.Context, email, password string) (string, *User, error) { return s.RegisterWithVerification(ctx, email, password, "", "", "", "") @@ -373,47 +385,92 @@ func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string, loc }, nil } -// VerifyTurnstileForRegister 在注册场景下验证 Turnstile。 -// 当邮箱验证开启且已提交验证码时,说明验证码发送阶段已完成 Turnstile 校验, +// VerifyCaptchaForRegister 在注册场景下验证当前启用的验证码。 +// 当邮箱验证开启且已提交验证码时,说明验证码发送阶段已完成验证码校验, // 此处跳过二次校验,避免一次性 token 在注册提交时重复使用导致误报失败。 -func (s *AuthService) VerifyTurnstileForRegister(ctx context.Context, token, remoteIP, verifyCode string) error { +func (s *AuthService) VerifyCaptchaForRegister(ctx context.Context, proof CaptchaProof, remoteIP, verifyCode string) error { if s.IsEmailVerifyEnabled(ctx) && strings.TrimSpace(verifyCode) != "" { - logger.LegacyPrintf("service.auth", "%s", "[Auth] Email verify flow detected, skip duplicate Turnstile check on register") + logger.LegacyPrintf("service.auth", "%s", "[Auth] Email verify flow detected, skip duplicate captcha check on register") return nil } - return s.VerifyTurnstile(ctx, token, remoteIP) + return s.VerifyCaptcha(ctx, proof, remoteIP) } -// VerifyTurnstile 验证Turnstile token -func (s *AuthService) VerifyTurnstile(ctx context.Context, token string, remoteIP string) error { +func (s *AuthService) VerifyCaptcha(ctx context.Context, proof CaptchaProof, remoteIP string) error { required := s.cfg != nil && s.cfg.Server.Mode == "release" && s.cfg.Turnstile.Required - - if required { - if s.settingService == nil { - logger.LegacyPrintf("service.auth", "%s", "[Auth] Turnstile required but settings service is not configured") - return ErrTurnstileNotConfigured - } - enabled := s.settingService.IsTurnstileEnabled(ctx) - secretConfigured := s.settingService.GetTurnstileSecretKey(ctx) != "" - if !enabled || !secretConfigured { - logger.LegacyPrintf("service.auth", "[Auth] Turnstile required but not configured (enabled=%v, secret_configured=%v)", enabled, secretConfigured) - return ErrTurnstileNotConfigured - } - } - - if s.turnstileService == nil { + if s.settingService == nil { if required { - logger.LegacyPrintf("service.auth", "%s", "[Auth] Turnstile required but service not configured") return ErrTurnstileNotConfigured } - return nil // 服务未配置则跳过验证 + return nil } - if !required && s.settingService != nil && s.settingService.IsTurnstileEnabled(ctx) && s.settingService.GetTurnstileSecretKey(ctx) == "" { - logger.LegacyPrintf("service.auth", "%s", "[Auth] Turnstile enabled but secret key not configured") + providerConfig, err := s.settingService.GetCaptchaProviderConfig(ctx) + if err != nil { + logger.LegacyPrintf("service.auth", "%s", "[Auth] Failed to read captcha provider settings") + return ErrServiceUnavailable + } + turnstileEnabled := providerConfig.TurnstileEnabled + tencentEnabled := providerConfig.Tencent.Enabled + if turnstileEnabled && tencentEnabled { + return ErrCaptchaProviderConflict + } + if tencentEnabled { + if s.tencentCaptchaService == nil { + return ErrTencentCaptchaNotConfigured + } + return s.tencentCaptchaService.VerifyTicketWithConfig(ctx, providerConfig.Tencent, proof.TencentTicket, proof.TencentRandstr, remoteIP) + } + if turnstileEnabled { + if s.turnstileService == nil || strings.TrimSpace(providerConfig.TurnstileSecretKey) == "" { + return ErrTurnstileNotConfigured + } + return s.turnstileService.VerifyTokenWithSecret(ctx, providerConfig.TurnstileSecretKey, proof.TurnstileToken, remoteIP) + } + if required { + return ErrTurnstileNotConfigured + } + return nil +} + +// VerifyTencentCaptchaIfEnabled 仅保护新增的腾讯验证码动作入口, +// 不扩大 Cloudflare Turnstile 的既有覆盖范围。 +func (s *AuthService) VerifyTencentCaptchaIfEnabled(ctx context.Context, proof CaptchaProof, remoteIP string) error { + if s == nil || s.settingService == nil { + return ErrServiceUnavailable } - return s.turnstileService.VerifyToken(ctx, token, remoteIP) + providerConfig, err := s.settingService.GetCaptchaProviderConfig(ctx) + if err != nil { + logger.LegacyPrintf("service.auth", "%s", "[Auth] Failed to read captcha provider settings") + return ErrServiceUnavailable + } + if !providerConfig.Tencent.Enabled { + return nil + } + if providerConfig.TurnstileEnabled { + return ErrCaptchaProviderConflict + } + if s.tencentCaptchaService == nil { + return ErrTencentCaptchaNotConfigured + } + return s.tencentCaptchaService.VerifyTicketWithConfig( + ctx, + providerConfig.Tencent, + proof.TencentTicket, + proof.TencentRandstr, + remoteIP, + ) +} + +// VerifyTurnstileForRegister 保留旧内部接口,生产 handler 使用 VerifyCaptchaForRegister。 +func (s *AuthService) VerifyTurnstileForRegister(ctx context.Context, token, remoteIP, verifyCode string) error { + return s.VerifyCaptchaForRegister(ctx, CaptchaProof{TurnstileToken: token}, remoteIP, verifyCode) +} + +// VerifyTurnstile 保留旧内部接口,生产 handler 使用 VerifyCaptcha。 +func (s *AuthService) VerifyTurnstile(ctx context.Context, token string, remoteIP string) error { + return s.VerifyCaptcha(ctx, CaptchaProof{TurnstileToken: token}, remoteIP) } // IsTurnstileEnabled 检查是否启用Turnstile验证 diff --git a/backend/internal/service/auth_service_captcha_test.go b/backend/internal/service/auth_service_captcha_test.go new file mode 100644 index 0000000000..6de121dc36 --- /dev/null +++ b/backend/internal/service/auth_service_captcha_test.go @@ -0,0 +1,188 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +func newAuthServiceForCaptchaRepoTest(repo *settingRepoStub, required bool, turnstileVerifier TurnstileVerifier, tencentVerifier TencentCaptchaVerifier) *AuthService { + cfg := &config.Config{ + Server: config.ServerConfig{Mode: "release"}, + Turnstile: config.TurnstileConfig{Required: required}, + } + settingService := NewSettingService(repo, cfg) + turnstileService := NewTurnstileService(settingService, turnstileVerifier) + tencentService := NewTencentCaptchaService(settingService, tencentVerifier) + svc := NewAuthService(nil, &userRepoStub{}, nil, nil, cfg, settingService, nil, turnstileService, nil, nil, nil, nil, nil) + svc.SetTencentCaptchaService(tencentService) + return svc +} + +func newAuthServiceForCaptchaTest(settings map[string]string, required bool, turnstileVerifier TurnstileVerifier, tencentVerifier TencentCaptchaVerifier) *AuthService { + cfg := &config.Config{ + Server: config.ServerConfig{Mode: "release"}, + Turnstile: config.TurnstileConfig{Required: required}, + } + settingService := NewSettingService(&settingRepoStub{values: settings}, cfg) + var turnstileService *TurnstileService + if turnstileVerifier != nil { + turnstileService = NewTurnstileService(settingService, turnstileVerifier) + } + svc := NewAuthService(nil, &userRepoStub{}, nil, nil, cfg, settingService, nil, turnstileService, nil, nil, nil, nil, nil) + if tencentVerifier != nil { + svc.SetTencentCaptchaService(NewTencentCaptchaService(settingService, tencentVerifier)) + } + return svc +} + +func tencentCaptchaSettings() map[string]string { + return map[string]string{ + SettingKeyTencentCaptchaEnabled: "true", + SettingKeyTencentCaptchaAppID: "123456789", + SettingKeyTencentCaptchaAppSecretKey: "app-secret", + SettingKeyTencentCaptchaCloudSecretID: "cloud-secret-id", + SettingKeyTencentCaptchaCloudSecretKey: "cloud-secret-key", + } +} + +func TestVerifyCaptchaUsesTencentWhenEnabled(t *testing.T) { + verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}} + svc := newAuthServiceForCaptchaTest(tencentCaptchaSettings(), false, nil, verifier) + + err := svc.VerifyCaptcha(context.Background(), CaptchaProof{ + TencentTicket: "ticket", + TencentRandstr: "@rand", + }, "203.0.113.10") + + require.NoError(t, err) + require.Equal(t, 1, verifier.calls) +} + +func TestVerifyCaptchaRejectsDirtyDoubleEnabledSettings(t *testing.T) { + settings := tencentCaptchaSettings() + settings[SettingKeyTurnstileEnabled] = "true" + settings[SettingKeyTurnstileSecretKey] = "turnstile-secret" + turnstileVerifier := &turnstileVerifierSpy{} + tencentVerifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}} + svc := newAuthServiceForCaptchaTest(settings, false, turnstileVerifier, tencentVerifier) + + err := svc.VerifyCaptcha(context.Background(), CaptchaProof{ + TurnstileToken: "turnstile-token", + TencentTicket: "ticket", + TencentRandstr: "@rand", + }, "203.0.113.10") + + require.ErrorIs(t, err, ErrCaptchaProviderConflict) + require.Zero(t, turnstileVerifier.called) + require.Zero(t, tencentVerifier.calls) +} + +func TestVerifyCaptchaRequiredModeAcceptsCompleteTencentProvider(t *testing.T) { + verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}} + svc := newAuthServiceForCaptchaTest(tencentCaptchaSettings(), true, nil, verifier) + + err := svc.VerifyCaptcha(context.Background(), CaptchaProof{ + TencentTicket: "ticket", + TencentRandstr: "@rand", + }, "203.0.113.10") + + require.NoError(t, err) +} + +func TestVerifyCaptchaForRegisterSkipsDuplicateTencentTicketAfterEmailCode(t *testing.T) { + settings := tencentCaptchaSettings() + settings[SettingKeyEmailVerifyEnabled] = "true" + verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}} + svc := newAuthServiceForCaptchaTest(settings, true, nil, verifier) + + err := svc.VerifyCaptchaForRegister(context.Background(), CaptchaProof{}, "203.0.113.10", "123456") + + require.NoError(t, err) + require.Zero(t, verifier.calls) +} + +func TestVerifyCaptchaFailsClosedWhenProviderSettingsCannotBeRead(t *testing.T) { + repo := &settingRepoStub{err: errors.New("settings unavailable")} + svc := newAuthServiceForCaptchaRepoTest(repo, false, &turnstileVerifierSpy{}, &tencentCaptchaVerifierStub{}) + + err := svc.VerifyCaptcha(context.Background(), CaptchaProof{}, "203.0.113.10") + + require.ErrorIs(t, err, ErrServiceUnavailable) +} + +func TestVerifyCaptchaReadsProviderConfigurationOnce(t *testing.T) { + repo := &settingRepoStub{values: tencentCaptchaSettings()} + verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}} + svc := newAuthServiceForCaptchaRepoTest(repo, false, &turnstileVerifierSpy{}, verifier) + + err := svc.VerifyCaptcha(context.Background(), CaptchaProof{ + TencentTicket: "ticket", + TencentRandstr: "@rand", + }, "203.0.113.10") + + require.NoError(t, err) + require.Equal(t, 1, repo.getMultipleCalls) + require.Zero(t, repo.getValueCalls) + require.Equal(t, 1, verifier.calls) +} + +func TestVerifyCaptchaRejectsEnabledTencentProviderWithIncompleteCredentials(t *testing.T) { + repo := &settingRepoStub{values: map[string]string{ + SettingKeyTencentCaptchaEnabled: "true", + SettingKeyTencentCaptchaAppID: "123456789", + }} + verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}} + svc := newAuthServiceForCaptchaRepoTest(repo, false, &turnstileVerifierSpy{}, verifier) + + err := svc.VerifyCaptcha(context.Background(), CaptchaProof{ + TencentTicket: "ticket", + TencentRandstr: "@rand", + }, "203.0.113.10") + + require.ErrorIs(t, err, ErrTencentCaptchaNotConfigured) + require.Equal(t, 1, repo.getMultipleCalls) + require.Zero(t, verifier.calls) +} + +func TestVerifyTencentCaptchaIfEnabledVerifiesTencentProof(t *testing.T) { + verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}} + svc := newAuthServiceForCaptchaTest(tencentCaptchaSettings(), false, nil, verifier) + + err := svc.VerifyTencentCaptchaIfEnabled(context.Background(), CaptchaProof{ + TencentTicket: "ticket", + TencentRandstr: "@rand", + }, "203.0.113.10") + + require.NoError(t, err) + require.Equal(t, 1, verifier.calls) + require.Equal(t, TencentCaptchaProof{Ticket: "ticket", Randstr: "@rand"}, verifier.proof) +} + +func TestVerifyTencentCaptchaIfEnabledDoesNotExpandTurnstileCoverage(t *testing.T) { + settings := map[string]string{ + SettingKeyTurnstileEnabled: "true", + SettingKeyTurnstileSecretKey: "turnstile-secret", + } + turnstileVerifier := &turnstileVerifierSpy{} + svc := newAuthServiceForCaptchaTest(settings, false, turnstileVerifier, nil) + + err := svc.VerifyTencentCaptchaIfEnabled(context.Background(), CaptchaProof{}, "203.0.113.10") + + require.NoError(t, err) + require.Zero(t, turnstileVerifier.called) +} + +func TestVerifyTencentCaptchaIfEnabledFailsClosedOnSettingReadError(t *testing.T) { + repo := &settingRepoStub{err: errors.New("settings unavailable")} + svc := newAuthServiceForCaptchaRepoTest(repo, false, &turnstileVerifierSpy{}, &tencentCaptchaVerifierStub{}) + + err := svc.VerifyTencentCaptchaIfEnabled(context.Background(), CaptchaProof{}, "203.0.113.10") + + require.ErrorIs(t, err, ErrServiceUnavailable) +} diff --git a/backend/internal/service/auth_service_register_test.go b/backend/internal/service/auth_service_register_test.go index eeb5f3b0a4..c31761d693 100644 --- a/backend/internal/service/auth_service_register_test.go +++ b/backend/internal/service/auth_service_register_test.go @@ -14,8 +14,10 @@ import ( ) type settingRepoStub struct { - values map[string]string - err error + values map[string]string + err error + getValueCalls int + getMultipleCalls int } func (s *settingRepoStub) Get(ctx context.Context, key string) (*Setting, error) { @@ -23,6 +25,7 @@ func (s *settingRepoStub) Get(ctx context.Context, key string) (*Setting, error) } func (s *settingRepoStub) GetValue(ctx context.Context, key string) (string, error) { + s.getValueCalls++ if s.err != nil { return "", s.err } @@ -37,6 +40,7 @@ func (s *settingRepoStub) Set(ctx context.Context, key, value string) error { } func (s *settingRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { + s.getMultipleCalls++ if s.err != nil { return nil, s.err } diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index aa3153d0e8..43ebf69340 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -164,6 +164,13 @@ const ( SettingKeyTurnstileSiteKey = "turnstile_site_key" // Turnstile Site Key SettingKeyTurnstileSecretKey = "turnstile_secret_key" // Turnstile Secret Key + // 腾讯天御验证码设置 + SettingKeyTencentCaptchaEnabled = "tencent_captcha_enabled" + SettingKeyTencentCaptchaAppID = "tencent_captcha_app_id" + SettingKeyTencentCaptchaAppSecretKey = "tencent_captcha_app_secret_key" + SettingKeyTencentCaptchaCloudSecretID = "tencent_captcha_cloud_secret_id" + SettingKeyTencentCaptchaCloudSecretKey = "tencent_captcha_cloud_secret_key" + // API Key IP 访问控制设置 SettingKeyAPIKeyACLTrustForwardedIP = "api_key_acl_trust_forwarded_ip" // API Key IP 白/黑名单是否信任转发 IP SettingKeyForwardedClientIPHeaders = "forwarded_client_ip_headers" // 自定义 CDN 客户端 IP 请求头(JSON 数组) diff --git a/backend/internal/service/setting_features.go b/backend/internal/service/setting_features.go index 50a3fbcfd8..8a7cc12c35 100644 --- a/backend/internal/service/setting_features.go +++ b/backend/internal/service/setting_features.go @@ -455,6 +455,61 @@ func (s *SettingService) GetTurnstileSecretKey(ctx context.Context) string { return value } +// TencentCaptchaConfig contains the credentials required by Tencent Cloud's +// ticket verification API. It must never be returned by a public handler. +type TencentCaptchaConfig struct { + Enabled bool + AppID string + AppSecretKey string + CloudSecretID string + CloudSecretKey string +} + +type CaptchaProviderConfig struct { + TurnstileEnabled bool + TurnstileSecretKey string + Tencent TencentCaptchaConfig +} + +func (s *SettingService) GetCaptchaProviderConfig(ctx context.Context) (CaptchaProviderConfig, error) { + values, err := s.settingRepo.GetMultiple(ctx, []string{ + SettingKeyTurnstileEnabled, + SettingKeyTurnstileSecretKey, + SettingKeyTencentCaptchaEnabled, + SettingKeyTencentCaptchaAppID, + SettingKeyTencentCaptchaAppSecretKey, + SettingKeyTencentCaptchaCloudSecretID, + SettingKeyTencentCaptchaCloudSecretKey, + }) + if err != nil { + return CaptchaProviderConfig{}, fmt.Errorf("read captcha provider settings: %w", err) + } + return CaptchaProviderConfig{ + TurnstileEnabled: values[SettingKeyTurnstileEnabled] == "true", + TurnstileSecretKey: values[SettingKeyTurnstileSecretKey], + Tencent: TencentCaptchaConfig{ + Enabled: values[SettingKeyTencentCaptchaEnabled] == "true", + AppID: values[SettingKeyTencentCaptchaAppID], + AppSecretKey: values[SettingKeyTencentCaptchaAppSecretKey], + CloudSecretID: values[SettingKeyTencentCaptchaCloudSecretID], + CloudSecretKey: values[SettingKeyTencentCaptchaCloudSecretKey], + }, + }, nil +} + +func (s *SettingService) IsTencentCaptchaEnabled(ctx context.Context) bool { + value, err := s.settingRepo.GetValue(ctx, SettingKeyTencentCaptchaEnabled) + return err == nil && value == "true" +} + +func (s *SettingService) GetTencentCaptchaConfig(ctx context.Context) TencentCaptchaConfig { + config, err := s.GetCaptchaProviderConfig(ctx) + if err != nil { + return TencentCaptchaConfig{} + } + return config.Tencent +} + // IsIdentityPatchEnabled 检查是否启用身份补丁(Claude -> Gemini systemInstruction 注入) func (s *SettingService) IsIdentityPatchEnabled(ctx context.Context) bool { value, err := s.settingRepo.GetValue(ctx, SettingKeyEnableIdentityPatch) diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index d5075038b1..440c8def2b 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -296,47 +296,52 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin } } result := &SystemSettings{ - RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", - EmailVerifyEnabled: emailVerifyEnabled, - RegistrationEmailSuffixWhitelist: ParseRegistrationEmailSuffixWhitelist(settings[SettingKeyRegistrationEmailSuffixWhitelist]), - PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 - PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true", - FrontendURL: settings[SettingKeyFrontendURL], - InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", - TotpEnabled: settings[SettingKeyTotpEnabled] == "true", - PasskeyEnabled: s.passkeySettingEnabled(settings), - SessionBindingEnabled: settings[SettingKeySessionBindingEnabled] == "true", // 默认关闭 - StepUpEnabled: settings[SettingKeyStepUpEnabled] == "true", // 默认关闭 - AuditLogRetentionDays: parseAuditLogRetentionDays(settings[SettingKeyAuditLogRetentionDays]), - LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true", - LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]), - LoginAgreementUpdatedAt: loginAgreementUpdatedAt, - LoginAgreementDocuments: loginAgreementDocuments, - SMTPHost: settings[SettingKeySMTPHost], - SMTPUsername: settings[SettingKeySMTPUsername], - SMTPFrom: settings[SettingKeySMTPFrom], - SMTPFromName: settings[SettingKeySMTPFromName], - SMTPUseTLS: settings[SettingKeySMTPUseTLS] == "true", - SMTPPasswordConfigured: settings[SettingKeySMTPPassword] != "", - TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", - TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], - TurnstileSecretKeyConfigured: settings[SettingKeyTurnstileSecretKey] != "", - APIKeyACLTrustForwardedIP: apiKeyACLTrustForwardedIP, - ForwardedClientIPHeaders: forwardedClientIPHeaders, - SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), - SiteLogo: settings[SettingKeySiteLogo], - SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), - APIBaseURL: settings[SettingKeyAPIBaseURL], - ContactInfo: settings[SettingKeyContactInfo], - DocURL: settings[SettingKeyDocURL], - HomeContent: settings[SettingKeyHomeContent], - CompactHomeEnabled: settings[SettingKeyCompactHomeEnabled] == "true", - HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true", - PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true", - PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), - CustomMenuItems: settings[SettingKeyCustomMenuItems], - CustomEndpoints: settings[SettingKeyCustomEndpoints], - BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", + RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", + EmailVerifyEnabled: emailVerifyEnabled, + RegistrationEmailSuffixWhitelist: ParseRegistrationEmailSuffixWhitelist(settings[SettingKeyRegistrationEmailSuffixWhitelist]), + PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 + PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true", + FrontendURL: settings[SettingKeyFrontendURL], + InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", + TotpEnabled: settings[SettingKeyTotpEnabled] == "true", + PasskeyEnabled: s.passkeySettingEnabled(settings), + SessionBindingEnabled: settings[SettingKeySessionBindingEnabled] == "true", // 默认关闭 + StepUpEnabled: settings[SettingKeyStepUpEnabled] == "true", // 默认关闭 + AuditLogRetentionDays: parseAuditLogRetentionDays(settings[SettingKeyAuditLogRetentionDays]), + LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true", + LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]), + LoginAgreementUpdatedAt: loginAgreementUpdatedAt, + LoginAgreementDocuments: loginAgreementDocuments, + SMTPHost: settings[SettingKeySMTPHost], + SMTPUsername: settings[SettingKeySMTPUsername], + SMTPFrom: settings[SettingKeySMTPFrom], + SMTPFromName: settings[SettingKeySMTPFromName], + SMTPUseTLS: settings[SettingKeySMTPUseTLS] == "true", + SMTPPasswordConfigured: settings[SettingKeySMTPPassword] != "", + TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", + TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], + TurnstileSecretKeyConfigured: settings[SettingKeyTurnstileSecretKey] != "", + TencentCaptchaEnabled: settings[SettingKeyTencentCaptchaEnabled] == "true", + TencentCaptchaAppID: settings[SettingKeyTencentCaptchaAppID], + TencentCaptchaAppSecretKeyConfigured: settings[SettingKeyTencentCaptchaAppSecretKey] != "", + TencentCaptchaCloudSecretIDConfigured: settings[SettingKeyTencentCaptchaCloudSecretID] != "", + TencentCaptchaCloudSecretKeyConfigured: settings[SettingKeyTencentCaptchaCloudSecretKey] != "", + APIKeyACLTrustForwardedIP: apiKeyACLTrustForwardedIP, + ForwardedClientIPHeaders: forwardedClientIPHeaders, + SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), + SiteLogo: settings[SettingKeySiteLogo], + SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), + APIBaseURL: settings[SettingKeyAPIBaseURL], + ContactInfo: settings[SettingKeyContactInfo], + DocURL: settings[SettingKeyDocURL], + HomeContent: settings[SettingKeyHomeContent], + CompactHomeEnabled: settings[SettingKeyCompactHomeEnabled] == "true", + HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true", + PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true", + PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), + CustomMenuItems: settings[SettingKeyCustomMenuItems], + CustomEndpoints: settings[SettingKeyCustomEndpoints], + BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", } result.TableDefaultPageSize, result.TablePageSizeOptions = parseTablePreferences( settings[SettingKeyTableDefaultPageSize], @@ -392,6 +397,9 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin // 敏感信息直接返回,方便测试连接时使用 result.SMTPPassword = settings[SettingKeySMTPPassword] result.TurnstileSecretKey = settings[SettingKeyTurnstileSecretKey] + result.TencentCaptchaAppSecretKey = settings[SettingKeyTencentCaptchaAppSecretKey] + result.TencentCaptchaCloudSecretID = settings[SettingKeyTencentCaptchaCloudSecretID] + result.TencentCaptchaCloudSecretKey = settings[SettingKeyTencentCaptchaCloudSecretKey] // LinuxDo Connect 设置: // - 兼容 config.yaml/env(避免老部署因为未迁移到数据库设置而被意外关闭) diff --git a/backend/internal/service/setting_public.go b/backend/internal/service/setting_public.go index bb2213a118..8bb44fc40e 100644 --- a/backend/internal/service/setting_public.go +++ b/backend/internal/service/setting_public.go @@ -171,6 +171,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyLoginAgreementDocuments, SettingKeyTurnstileEnabled, SettingKeyTurnstileSiteKey, + SettingKeyTencentCaptchaEnabled, + SettingKeyTencentCaptchaAppID, SettingKeyAPIKeyACLTrustForwardedIP, SettingKeySiteName, SettingKeySiteLogo, @@ -301,6 +303,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings LoginAgreementDocuments: loginAgreementDocuments, TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], + TencentCaptchaEnabled: settings[SettingKeyTencentCaptchaEnabled] == "true", + TencentCaptchaAppID: settings[SettingKeyTencentCaptchaAppID], SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), SiteLogo: settings[SettingKeySiteLogo], SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), @@ -490,6 +494,8 @@ type PublicSettingsInjectionPayload struct { LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"` TurnstileEnabled bool `json:"turnstile_enabled"` TurnstileSiteKey string `json:"turnstile_site_key"` + TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"` + TencentCaptchaAppID string `json:"tencent_captcha_app_id"` SiteName string `json:"site_name"` SiteLogo string `json:"site_logo"` SiteSubtitle string `json:"site_subtitle"` @@ -563,6 +569,8 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any LoginAgreementDocuments: settings.LoginAgreementDocuments, TurnstileEnabled: settings.TurnstileEnabled, TurnstileSiteKey: settings.TurnstileSiteKey, + TencentCaptchaEnabled: settings.TencentCaptchaEnabled, + TencentCaptchaAppID: settings.TencentCaptchaAppID, SiteName: settings.SiteName, SiteLogo: settings.SiteLogo, SiteSubtitle: settings.SiteSubtitle, diff --git a/backend/internal/service/setting_service_public_test.go b/backend/internal/service/setting_service_public_test.go index 54bdda77f7..1b63d412f6 100644 --- a/backend/internal/service/setting_service_public_test.go +++ b/backend/internal/service/setting_service_public_test.go @@ -12,6 +12,7 @@ import ( type settingPublicRepoStub struct { values map[string]string + err error } func (s *settingPublicRepoStub) Get(ctx context.Context, key string) (*Setting, error) { @@ -27,6 +28,9 @@ func (s *settingPublicRepoStub) Set(ctx context.Context, key, value string) erro } func (s *settingPublicRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { + if s.err != nil { + return nil, s.err + } out := make(map[string]string, len(keys)) for _, key := range keys { if value, ok := s.values[key]; ok { diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index e242c46d2c..c77639a509 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -209,6 +209,18 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting if settings.TurnstileSecretKey != "" { updates[SettingKeyTurnstileSecretKey] = settings.TurnstileSecretKey } + + updates[SettingKeyTencentCaptchaEnabled] = strconv.FormatBool(settings.TencentCaptchaEnabled) + updates[SettingKeyTencentCaptchaAppID] = settings.TencentCaptchaAppID + if settings.TencentCaptchaAppSecretKey != "" { + updates[SettingKeyTencentCaptchaAppSecretKey] = settings.TencentCaptchaAppSecretKey + } + if settings.TencentCaptchaCloudSecretID != "" { + updates[SettingKeyTencentCaptchaCloudSecretID] = settings.TencentCaptchaCloudSecretID + } + if settings.TencentCaptchaCloudSecretKey != "" { + updates[SettingKeyTencentCaptchaCloudSecretKey] = settings.TencentCaptchaCloudSecretKey + } updates[SettingKeyAPIKeyACLTrustForwardedIP] = strconv.FormatBool(settings.APIKeyACLTrustForwardedIP) forwardedClientIPHeadersJSON, err := json.Marshal(settings.ForwardedClientIPHeaders) if err != nil { diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index a8ddb4908a..770eff1ef5 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -38,12 +38,20 @@ type SystemSettings struct { SMTPFromName string SMTPUseTLS bool - TurnstileEnabled bool - TurnstileSiteKey string - TurnstileSecretKey string - TurnstileSecretKeyConfigured bool - APIKeyACLTrustForwardedIP bool - ForwardedClientIPHeaders []string + TurnstileEnabled bool + TurnstileSiteKey string + TurnstileSecretKey string + TurnstileSecretKeyConfigured bool + TencentCaptchaEnabled bool + TencentCaptchaAppID string + TencentCaptchaAppSecretKey string + TencentCaptchaAppSecretKeyConfigured bool + TencentCaptchaCloudSecretID string + TencentCaptchaCloudSecretIDConfigured bool + TencentCaptchaCloudSecretKey string + TencentCaptchaCloudSecretKeyConfigured bool + APIKeyACLTrustForwardedIP bool + ForwardedClientIPHeaders []string // LinuxDo Connect OAuth 登录 LinuxDoConnectEnabled bool @@ -299,6 +307,8 @@ type PublicSettings struct { LoginAgreementDocuments []LoginAgreementDocument TurnstileEnabled bool TurnstileSiteKey string + TencentCaptchaEnabled bool + TencentCaptchaAppID string SiteName string SiteLogo string SiteSubtitle string diff --git a/backend/internal/service/tencent_captcha_service.go b/backend/internal/service/tencent_captcha_service.go new file mode 100644 index 0000000000..ccfe3fca2b --- /dev/null +++ b/backend/internal/service/tencent_captcha_service.go @@ -0,0 +1,108 @@ +package service + +import ( + "context" + "fmt" + "strconv" + "strings" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" +) + +var ( + ErrTencentCaptchaVerificationFailed = infraerrors.BadRequest("TENCENT_CAPTCHA_VERIFICATION_FAILED", "tencent captcha verification failed") + ErrTencentCaptchaNotConfigured = infraerrors.ServiceUnavailable("TENCENT_CAPTCHA_NOT_CONFIGURED", "tencent captcha not configured") +) + +type TencentCaptchaProof struct { + Ticket string + Randstr string +} + +type TencentCaptchaCredentials struct { + AppID uint64 + AppSecretKey string + CloudSecretID string + CloudSecretKey string +} + +type TencentCaptchaVerifyResponse struct { + CaptchaCode int64 + CaptchaMsg string + RequestID string +} + +type TencentCaptchaVerifier interface { + VerifyTicket(context.Context, TencentCaptchaCredentials, TencentCaptchaProof, string) (*TencentCaptchaVerifyResponse, error) +} + +type TencentCaptchaService struct { + settingService *SettingService + verifier TencentCaptchaVerifier +} + +func NewTencentCaptchaService(settingService *SettingService, verifier TencentCaptchaVerifier) *TencentCaptchaService { + return &TencentCaptchaService{settingService: settingService, verifier: verifier} +} + +func (s *TencentCaptchaService) VerifyTicket(ctx context.Context, ticket, randstr, remoteIP string) error { + if s == nil || s.settingService == nil { + return ErrTencentCaptchaNotConfigured + } + providerConfig, err := s.settingService.GetCaptchaProviderConfig(ctx) + if err != nil { + logger.LegacyPrintf("service.tencent_captcha", "%s", "[TencentCaptcha] failed to read captcha provider settings") + return ErrServiceUnavailable + } + config := providerConfig.Tencent + if !config.Enabled { + return nil + } + return s.VerifyTicketWithConfig(ctx, config, ticket, randstr, remoteIP) +} + +func (s *TencentCaptchaService) VerifyTicketWithConfig(ctx context.Context, config TencentCaptchaConfig, ticket, randstr, remoteIP string) error { + credentials, ok := parseTencentCaptchaCredentials(config) + if !ok || s.verifier == nil { + return ErrTencentCaptchaNotConfigured + } + + proof := TencentCaptchaProof{ + Ticket: strings.TrimSpace(ticket), + Randstr: strings.TrimSpace(randstr), + } + if proof.Ticket == "" || proof.Randstr == "" || strings.HasPrefix(proof.Ticket, "trerror_") { + return ErrTencentCaptchaVerificationFailed + } + + result, err := s.verifier.VerifyTicket(ctx, credentials, proof, remoteIP) + if err != nil { + logger.LegacyPrintf("service.tencent_captcha", "%s", "[TencentCaptcha] verification request failed") + 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) + } + return ErrTencentCaptchaVerificationFailed + } + return nil +} + +func parseTencentCaptchaCredentials(config TencentCaptchaConfig) (TencentCaptchaCredentials, bool) { + appID, err := strconv.ParseUint(strings.TrimSpace(config.AppID), 10, 64) + if err != nil || appID == 0 { + return TencentCaptchaCredentials{}, false + } + credentials := TencentCaptchaCredentials{ + AppID: appID, + AppSecretKey: strings.TrimSpace(config.AppSecretKey), + CloudSecretID: strings.TrimSpace(config.CloudSecretID), + CloudSecretKey: strings.TrimSpace(config.CloudSecretKey), + } + if credentials.AppSecretKey == "" || credentials.CloudSecretID == "" || credentials.CloudSecretKey == "" { + return TencentCaptchaCredentials{}, false + } + return credentials, true +} diff --git a/backend/internal/service/tencent_captcha_service_test.go b/backend/internal/service/tencent_captcha_service_test.go new file mode 100644 index 0000000000..b927ba0b91 --- /dev/null +++ b/backend/internal/service/tencent_captcha_service_test.go @@ -0,0 +1,108 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +type tencentCaptchaVerifierStub struct { + response *TencentCaptchaVerifyResponse + err error + calls int + proof TencentCaptchaProof + remoteIP string +} + +func (s *tencentCaptchaVerifierStub) VerifyTicket(_ context.Context, _ TencentCaptchaCredentials, proof TencentCaptchaProof, remoteIP string) (*TencentCaptchaVerifyResponse, error) { + s.calls++ + s.proof = proof + s.remoteIP = remoteIP + return s.response, s.err +} + +func newTencentCaptchaTestService(verifier TencentCaptchaVerifier) *TencentCaptchaService { + settings := NewSettingService(&settingPublicRepoStub{values: map[string]string{ + SettingKeyTencentCaptchaEnabled: "true", + SettingKeyTencentCaptchaAppID: "123456789", + SettingKeyTencentCaptchaAppSecretKey: "app-secret", + SettingKeyTencentCaptchaCloudSecretID: "cloud-secret-id", + SettingKeyTencentCaptchaCloudSecretKey: "cloud-secret-key", + }}, &config.Config{}) + return NewTencentCaptchaService(settings, verifier) +} + +func TestTencentCaptchaServiceAcceptsCaptchaCodeOne(t *testing.T) { + verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}} + svc := newTencentCaptchaTestService(verifier) + + err := svc.VerifyTicket(context.Background(), "ticket", "@rand", "203.0.113.10") + + require.NoError(t, err) + require.Equal(t, 1, verifier.calls) + require.Equal(t, TencentCaptchaProof{Ticket: "ticket", Randstr: "@rand"}, verifier.proof) + require.Equal(t, "203.0.113.10", verifier.remoteIP) +} + +func TestTencentCaptchaServiceRejectsDisasterRecoveryTicketWithoutCallingVerifier(t *testing.T) { + verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}} + svc := newTencentCaptchaTestService(verifier) + + err := svc.VerifyTicket(context.Background(), "trerror_1001_123456789_1", "@rand", "203.0.113.10") + + require.ErrorIs(t, err, ErrTencentCaptchaVerificationFailed) + require.Zero(t, verifier.calls) +} + +func TestTencentCaptchaServiceRejectsEveryNonOneCode(t *testing.T) { + for _, code := range []int64{0, 7, 8, 9, 15, 16, 21, 100} { + t.Run(string(rune(code)), func(t *testing.T) { + verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: code}} + svc := newTencentCaptchaTestService(verifier) + + err := svc.VerifyTicket(context.Background(), "ticket", "@rand", "203.0.113.10") + + require.ErrorIs(t, err, ErrTencentCaptchaVerificationFailed) + }) + } +} + +func TestTencentCaptchaServiceFailsClosedOnVerifierError(t *testing.T) { + verifier := &tencentCaptchaVerifierStub{err: errors.New("sdk unavailable")} + svc := newTencentCaptchaTestService(verifier) + + err := svc.VerifyTicket(context.Background(), "ticket", "@rand", "203.0.113.10") + + require.Error(t, err) + require.ErrorIs(t, err, ErrTencentCaptchaVerificationFailed) +} + +func TestTencentCaptchaServiceRejectsIncompleteConfiguration(t *testing.T) { + settings := NewSettingService(&settingPublicRepoStub{values: map[string]string{ + SettingKeyTencentCaptchaEnabled: "true", + SettingKeyTencentCaptchaAppID: "123456789", + }}, &config.Config{}) + verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}} + svc := NewTencentCaptchaService(settings, verifier) + + err := svc.VerifyTicket(context.Background(), "ticket", "@rand", "203.0.113.10") + + require.ErrorIs(t, err, ErrTencentCaptchaNotConfigured) + require.Zero(t, verifier.calls) +} + +func TestTencentCaptchaServiceFailsClosedOnSettingsReadError(t *testing.T) { + settings := NewSettingService(&settingPublicRepoStub{err: errors.New("settings unavailable")}, &config.Config{}) + verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}} + svc := NewTencentCaptchaService(settings, verifier) + + err := svc.VerifyTicket(context.Background(), "ticket", "@rand", "203.0.113.10") + + require.ErrorIs(t, err, ErrServiceUnavailable) + require.Zero(t, verifier.calls) +} diff --git a/backend/internal/service/tencent_captcha_settings_test.go b/backend/internal/service/tencent_captcha_settings_test.go new file mode 100644 index 0000000000..8d719ce232 --- /dev/null +++ b/backend/internal/service/tencent_captcha_settings_test.go @@ -0,0 +1,76 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +func TestSettingService_ParseSettingsMasksTencentCaptchaCredentials(t *testing.T) { + svc := NewSettingService(&settingGetAllRepoStub{values: map[string]string{ + SettingKeyTencentCaptchaEnabled: "true", + SettingKeyTencentCaptchaAppID: "123456789", + SettingKeyTencentCaptchaAppSecretKey: "app-secret", + SettingKeyTencentCaptchaCloudSecretID: "cloud-secret-id", + SettingKeyTencentCaptchaCloudSecretKey: "cloud-secret-key", + }}, &config.Config{}) + + settings, err := svc.GetAllSettings(context.Background()) + + require.NoError(t, err) + require.True(t, settings.TencentCaptchaEnabled) + require.Equal(t, "123456789", settings.TencentCaptchaAppID) + require.True(t, settings.TencentCaptchaAppSecretKeyConfigured) + require.True(t, settings.TencentCaptchaCloudSecretIDConfigured) + require.True(t, settings.TencentCaptchaCloudSecretKeyConfigured) + require.Equal(t, "app-secret", settings.TencentCaptchaAppSecretKey) + require.Equal(t, "cloud-secret-id", settings.TencentCaptchaCloudSecretID) + require.Equal(t, "cloud-secret-key", settings.TencentCaptchaCloudSecretKey) +} + +func TestSettingService_GetPublicSettingsExposesOnlyTencentCaptchaAppID(t *testing.T) { + svc := NewSettingService(&settingPublicRepoStub{values: map[string]string{ + SettingKeyTencentCaptchaEnabled: "true", + SettingKeyTencentCaptchaAppID: "123456789", + SettingKeyTencentCaptchaAppSecretKey: "app-secret", + SettingKeyTencentCaptchaCloudSecretID: "cloud-secret-id", + SettingKeyTencentCaptchaCloudSecretKey: "cloud-secret-key", + }}, &config.Config{}) + + settings, err := svc.GetPublicSettings(context.Background()) + require.NoError(t, err) + require.True(t, settings.TencentCaptchaEnabled) + require.Equal(t, "123456789", settings.TencentCaptchaAppID) + + raw, err := json.Marshal(settings) + require.NoError(t, err) + require.NotContains(t, string(raw), "app-secret") + require.NotContains(t, string(raw), "cloud-secret-id") + require.NotContains(t, string(raw), "cloud-secret-key") +} + +func TestSettingService_GetTencentCaptchaConfig(t *testing.T) { + repo := &settingPublicRepoStub{values: map[string]string{ + SettingKeyTencentCaptchaEnabled: "true", + SettingKeyTencentCaptchaAppID: "123456789", + SettingKeyTencentCaptchaAppSecretKey: "app-secret", + SettingKeyTencentCaptchaCloudSecretID: "cloud-secret-id", + SettingKeyTencentCaptchaCloudSecretKey: "cloud-secret-key", + }} + svc := NewSettingService(repo, &config.Config{}) + + got := svc.GetTencentCaptchaConfig(context.Background()) + + require.Equal(t, TencentCaptchaConfig{ + Enabled: true, + AppID: "123456789", + AppSecretKey: "app-secret", + CloudSecretID: "cloud-secret-id", + CloudSecretKey: "cloud-secret-key", + }, got) +} diff --git a/backend/internal/service/turnstile_service.go b/backend/internal/service/turnstile_service.go index e454e2f1e1..94ce5d69b9 100644 --- a/backend/internal/service/turnstile_service.go +++ b/backend/internal/service/turnstile_service.go @@ -53,6 +53,10 @@ func (s *TurnstileService) VerifyToken(ctx context.Context, token string, remote // 获取 Secret Key secretKey := s.settingService.GetTurnstileSecretKey(ctx) + return s.VerifyTokenWithSecret(ctx, secretKey, token, remoteIP) +} + +func (s *TurnstileService) VerifyTokenWithSecret(ctx context.Context, secretKey, token, remoteIP string) error { if secretKey == "" { logger.LegacyPrintf("service.turnstile", "%s", "[Turnstile] Secret key not configured") return ErrTurnstileNotConfigured @@ -65,6 +69,9 @@ func (s *TurnstileService) VerifyToken(ctx context.Context, token string, remote } logger.LegacyPrintf("service.turnstile", "[Turnstile] Verifying token for IP: %s", remoteIP) + if s == nil || s.verifier == nil { + return ErrTurnstileNotConfigured + } result, err := s.verifier.VerifyToken(ctx, secretKey, token, remoteIP) if err != nil { logger.LegacyPrintf("service.turnstile", "[Turnstile] Request failed: %v", err) diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 2a07c73ea5..495bff7dc5 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -41,6 +41,43 @@ func ProvideEmailQueueService(emailService *EmailService) *EmailQueueService { return NewEmailQueueService(emailService, 3) } +// ProvideAuthService wires the optional captcha providers into AuthService while +// keeping NewAuthService's public constructor compatible with existing tests. +func ProvideAuthService( + entClient *dbent.Client, + userRepo UserRepository, + redeemRepo RedeemCodeRepository, + refreshTokenCache RefreshTokenCache, + cfg *config.Config, + settingService *SettingService, + emailService *EmailService, + turnstileService *TurnstileService, + tencentCaptchaService *TencentCaptchaService, + emailQueueService *EmailQueueService, + promoService *PromoService, + defaultSubAssigner DefaultSubscriptionAssigner, + affiliateService *AffiliateService, + userPlatformQuotaRepo UserPlatformQuotaRepository, +) *AuthService { + svc := NewAuthService( + entClient, + userRepo, + redeemRepo, + refreshTokenCache, + cfg, + settingService, + emailService, + turnstileService, + emailQueueService, + promoService, + defaultSubAssigner, + affiliateService, + userPlatformQuotaRepo, + ) + svc.SetTencentCaptchaService(tencentCaptchaService) + return svc +} + // ProvideOAuthRefreshAPI creates OAuthRefreshAPI with the default lock TTL. func ProvideOAuthRefreshAPI(accountRepo AccountRepository, tokenCache GeminiTokenCache) *OAuthRefreshAPI { return NewOAuthRefreshAPI(accountRepo, tokenCache) @@ -675,7 +712,7 @@ func ProvideAPIKeyService( // ProviderSet is the Wire provider set for all services var ProviderSet = wire.NewSet( // Core services - NewAuthService, + ProvideAuthService, NewPasskeyService, NewUserService, ProvideAPIKeyService, @@ -744,6 +781,7 @@ var ProviderSet = wire.NewSet( NewNotificationEmailService, ProvideEmailQueueService, NewTurnstileService, + NewTencentCaptchaService, NewSubscriptionService, wire.Bind(new(DefaultSubscriptionAssigner), new(*SubscriptionService)), ProvideConcurrencyService, diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 4e730b4fe0..fd10deda57 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://static.cloudflareinsights.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://fonts.googleapis.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.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'; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.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://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'" proxy_probe: # Allow skipping TLS verification for proxy probe (debug only) # 允许代理探测时跳过 TLS 证书验证(仅用于调试) diff --git a/frontend/src/api/__tests__/auth-captcha-oauth-start.spec.ts b/frontend/src/api/__tests__/auth-captcha-oauth-start.spec.ts new file mode 100644 index 0000000000..96913a3842 --- /dev/null +++ b/frontend/src/api/__tests__/auth-captcha-oauth-start.spec.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const post = vi.hoisted(() => vi.fn()) + +vi.mock('@/api/client', () => ({ + apiClient: { post } +})) + +import { + buildOAuthLoginStartURL, + startOAuthLogin, + type OAuthLoginStart +} from '@/api/auth' + +describe('OAuth captcha start API', () => { + beforeEach(() => { + post.mockReset() + }) + + it('posts Tencent captcha proof and preserves OAuth query parameters', async () => { + const request: OAuthLoginStart = { + provider: 'github', + params: { redirect: '/dashboard', aff_code: 'AFF123' } + } + const proof = { + tencent_captcha_ticket: 'ticket', + tencent_captcha_randstr: '@rand' + } + post.mockResolvedValue({ data: { authorize_url: 'https://github.com/login/oauth/authorize' } }) + + await expect(startOAuthLogin(request, proof)).resolves.toEqual({ + authorize_url: 'https://github.com/login/oauth/authorize' + }) + expect(post).toHaveBeenCalledWith('/auth/oauth/github/start', proof, { + params: request.params + }) + }) + + it('builds the legacy GET start URL when Tencent captcha is disabled', () => { + expect(buildOAuthLoginStartURL({ + provider: 'wechat', + params: { mode: 'open', redirect: '/billing?plan=pro' } + })).toBe('/api/v1/auth/oauth/wechat/start?mode=open&redirect=%2Fbilling%3Fplan%3Dpro') + }) +}) diff --git a/frontend/src/api/__tests__/passkey.spec.ts b/frontend/src/api/__tests__/passkey.spec.ts index fe32c1c7cd..52ab5c477f 100644 --- a/frontend/src/api/__tests__/passkey.spec.ts +++ b/frontend/src/api/__tests__/passkey.spec.ts @@ -97,6 +97,7 @@ describe('passkey api', () => { await passkeyAPI.login() + expect(post).toHaveBeenNthCalledWith(1, '/auth/passkey/login/begin') const request = credentialGet.mock.calls[0][0] as CredentialRequestOptions expect(Array.from(new Uint8Array(request.publicKey!.challenge))).toEqual([1, 2, 3]) expect(request.publicKey!.userVerification).toBe('required') @@ -119,6 +120,38 @@ describe('passkey api', () => { }) }) + it('sends Tencent captcha proof only with the passkey begin request', async () => { + post + .mockResolvedValueOnce({ + data: { + session_token: 'one-time-session', + options: { + publicKey: { + challenge: 'AQID', + rpId: 'sub2api.example.com', + userVerification: 'required' + } + } + } + }) + .mockResolvedValueOnce({ data: { access_token: 'access', token_type: 'Bearer', user: { id: 1 } } }) + credentialGet.mockResolvedValue(new FakePublicKeyCredential()) + + await passkeyAPI.login({ + tencent_captcha_ticket: 'ticket-value', + tencent_captcha_randstr: '@rand-value' + }) + + expect(post).toHaveBeenNthCalledWith(1, '/auth/passkey/login/begin', { + tencent_captcha_ticket: 'ticket-value', + tencent_captcha_randstr: '@rand-value' + }) + expect(post.mock.calls[1][1]).not.toEqual(expect.objectContaining({ + tencent_captcha_ticket: expect.anything(), + tencent_captcha_randstr: expect.anything() + })) + }) + it('sends the account password when beginning registration', async () => { post .mockResolvedValueOnce({ diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index b018b925b4..e47619bb37 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -459,6 +459,11 @@ export interface SystemSettings { turnstile_enabled: boolean; turnstile_site_key: string; turnstile_secret_key_configured: boolean; + tencent_captcha_enabled: boolean; + tencent_captcha_app_id: string; + tencent_captcha_app_secret_key_configured: boolean; + tencent_captcha_cloud_secret_id_configured: boolean; + tencent_captcha_cloud_secret_key_configured: boolean; api_key_acl_trust_forwarded_ip: boolean; forwarded_client_ip_headers: string[]; @@ -770,6 +775,11 @@ export interface UpdateSettingsRequest { turnstile_enabled?: boolean; turnstile_site_key?: string; turnstile_secret_key?: string; + tencent_captcha_enabled?: boolean; + tencent_captcha_app_id?: string; + tencent_captcha_app_secret_key?: string; + tencent_captcha_cloud_secret_id?: string; + tencent_captcha_cloud_secret_key?: string; api_key_acl_trust_forwarded_ip?: boolean; forwarded_client_ip_headers?: string[]; linuxdo_connect_enabled?: boolean; diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 8b0a74cb43..1ede775d62 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -14,6 +14,7 @@ import type { SendVerifyCodeRequest, SendVerifyCodeResponse, PublicSettings, + TencentCaptchaRequestProof, TotpLoginResponse, TotpLogin2FARequest } from '@/types' @@ -23,6 +24,43 @@ import type { */ export type LoginResponse = AuthResponse | TotpLoginResponse +export type OAuthLoginProvider = + | 'github' + | 'google' + | 'linuxdo' + | 'dingtalk' + | 'wechat' + | 'oidc' + +export interface OAuthLoginStart { + provider: OAuthLoginProvider + params: Record +} + +export interface OAuthLoginStartResponse { + authorize_url: string +} + +export function buildOAuthLoginStartURL(request: OAuthLoginStart): string { + const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1' + const normalized = apiBase.replace(/\/$/, '') + const query = new URLSearchParams(request.params).toString() + const path = `${normalized}/auth/oauth/${request.provider}/start` + return query ? `${path}?${query}` : path +} + +export async function startOAuthLogin( + request: OAuthLoginStart, + proof: TencentCaptchaRequestProof +): Promise { + const { data } = await apiClient.post( + `/auth/oauth/${request.provider}/start`, + proof, + { params: request.params } + ) + return data +} + /** * Type guard to check if login response requires 2FA */ @@ -493,6 +531,8 @@ export async function validateInvitationCode(code: string): Promise { +async function login(proof?: TencentCaptchaRequestProof): Promise { requirePasskeySupport() - const { data: begin } = await apiClient.post( - '/auth/passkey/login/begin' - ) + const { data: begin } = proof + ? await apiClient.post('/auth/passkey/login/begin', proof) + : await apiClient.post('/auth/passkey/login/begin') const credential = await navigator.credentials.get({ publicKey: requestOptionsFromJSON(begin.options.publicKey) }) diff --git a/frontend/src/components/CaptchaChallenge.vue b/frontend/src/components/CaptchaChallenge.vue new file mode 100644 index 0000000000..ba9301307a --- /dev/null +++ b/frontend/src/components/CaptchaChallenge.vue @@ -0,0 +1,56 @@ + + + diff --git a/frontend/src/components/TencentCaptchaGate.vue b/frontend/src/components/TencentCaptchaGate.vue new file mode 100644 index 0000000000..0665e86493 --- /dev/null +++ b/frontend/src/components/TencentCaptchaGate.vue @@ -0,0 +1,79 @@ + + + diff --git a/frontend/src/components/__tests__/TencentCaptchaGate.spec.ts b/frontend/src/components/__tests__/TencentCaptchaGate.spec.ts new file mode 100644 index 0000000000..c3aa9bd568 --- /dev/null +++ b/frontend/src/components/__tests__/TencentCaptchaGate.spec.ts @@ -0,0 +1,131 @@ +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' + +const locale = { value: 'zh' } + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ locale }) +})) + +type CaptchaResult = { + ret: number + ticket?: string | null + randstr?: string | null + errorCode?: number +} + +describe('TencentCaptchaGate', () => { + beforeEach(() => { + locale.value = 'zh' + delete window.TencentCaptcha + document.head.querySelectorAll('script[src*="TJCaptcha.js"]').forEach((node) => node.remove()) + resetTencentCaptchaLoaderForTest() + }) + + it('does not render a visible verification button', () => { + const wrapper = mount(TencentCaptchaGate, { props: { appId: '123456789' } }) + + expect(wrapper.find('button').exists()).toBe(false) + }) + + it('resolves proof after Tencent SDK success', async () => { + let callback: ((result: CaptchaResult) => void) | undefined + window.TencentCaptcha = class { + constructor(_appId: string, resultCallback: (result: CaptchaResult) => void) { + callback = resultCallback + } + show = vi.fn() + destroy = vi.fn() + } + const wrapper = mount(TencentCaptchaGate, { props: { appId: '123456789' } }) + + const verification = wrapper.vm.verify() + await flushPromises() + callback?.({ ret: 0, ticket: 'ticket-value', randstr: 'rand-value' }) + + await expect(verification).resolves.toEqual({ ticket: 'ticket-value', randstr: 'rand-value' }) + }) + + it('resolves null when the user closes the popup', async () => { + let callback: ((result: CaptchaResult) => void) | undefined + window.TencentCaptcha = class { + constructor(_appId: string, resultCallback: (result: CaptchaResult) => void) { + callback = resultCallback + } + show = vi.fn() + destroy = vi.fn() + } + const wrapper = mount(TencentCaptchaGate, { props: { appId: '123456789' } }) + + const verification = wrapper.vm.verify() + await flushPromises() + callback?.({ ret: 2, ticket: null }) + + await expect(verification).resolves.toBeNull() + }) + + it('rejects SDK load failures and disaster-recovery tickets', async () => { + const failedLoad = mount(TencentCaptchaGate, { props: { appId: '123456789' } }) + const loadVerification = failedLoad.vm.verify() + const script = document.head.querySelector('script[src*="TJCaptcha.js"]') + expect(script).not.toBeNull() + script?.dispatchEvent(new Event('error')) + await expect(loadVerification).rejects.toThrow('Failed to load Tencent Captcha SDK') + + let callback: ((result: CaptchaResult) => void) | undefined + window.TencentCaptcha = class { + constructor(_appId: string, resultCallback: (result: CaptchaResult) => void) { + callback = resultCallback + } + show = vi.fn() + destroy = vi.fn() + } + const failedResult = mount(TencentCaptchaGate, { props: { appId: '123456789' } }) + const resultVerification = failedResult.vm.verify() + await flushPromises() + callback?.({ ret: 0, ticket: 'trerror_1001_123456789', randstr: '@fallback', errorCode: 1001 }) + + await expect(resultVerification).rejects.toThrow('Tencent Captcha verification failed') + }) + + it('reuses one pending promise for concurrent verify calls', async () => { + const show = vi.fn() + let callback: ((result: CaptchaResult) => void) | undefined + window.TencentCaptcha = class { + constructor(_appId: string, resultCallback: (result: CaptchaResult) => void) { + callback = resultCallback + } + show = show + destroy = vi.fn() + } + const wrapper = mount(TencentCaptchaGate, { props: { appId: '123456789' } }) + + const first = wrapper.vm.verify() + const second = wrapper.vm.verify() + await flushPromises() + callback?.({ ret: 0, ticket: 'ticket-value', randstr: 'rand-value' }) + + await expect(first).resolves.toEqual({ ticket: 'ticket-value', randstr: 'rand-value' }) + await expect(second).resolves.toEqual({ ticket: 'ticket-value', randstr: 'rand-value' }) + expect(show).toHaveBeenCalledOnce() + }) + + it('settles a pending verification when reset', async () => { + const destroy = vi.fn() + window.TencentCaptcha = class { + constructor(_appId: string, _callback: (result: CaptchaResult) => void) {} + show = vi.fn() + destroy = destroy + } + const wrapper = mount(TencentCaptchaGate, { props: { appId: '123456789' } }) + + const verification = wrapper.vm.verify() + await flushPromises() + wrapper.vm.reset() + + await expect(verification).resolves.toBeNull() + expect(destroy).toHaveBeenCalledOnce() + }) +}) diff --git a/frontend/src/components/auth/DingTalkOAuthSection.vue b/frontend/src/components/auth/DingTalkOAuthSection.vue index 55b919e659..dcb2fbc5c6 100644 --- a/frontend/src/components/auth/DingTalkOAuthSection.vue +++ b/frontend/src/components/auth/DingTalkOAuthSection.vue @@ -37,6 +37,7 @@ diff --git a/frontend/src/components/auth/EmailOAuthButtons.vue b/frontend/src/components/auth/EmailOAuthButtons.vue index b5d874a502..de45be1449 100644 --- a/frontend/src/components/auth/EmailOAuthButtons.vue +++ b/frontend/src/components/auth/EmailOAuthButtons.vue @@ -31,6 +31,7 @@ import { useRoute } from 'vue-router' import { useI18n } from 'vue-i18n' import GitHubMark from './GitHubMark.vue' import GoogleMark from './GoogleMark.vue' +import type { OAuthLoginStart } from '@/api/auth' import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate' type EmailOAuthProvider = 'github' | 'google' @@ -45,6 +46,9 @@ const props = withDefaults(defineProps<{ }>(), { showDivider: true }) +const emit = defineEmits<{ + start: [request: OAuthLoginStart] +}>() const route = useRoute() const { t } = useI18n() @@ -75,13 +79,10 @@ function startLogin(provider: EmailOAuthProvider): void { const affiliateCode = resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code) storeOAuthAffiliateCode(affiliateCode) window.sessionStorage.setItem(EMAIL_OAUTH_PENDING_PROVIDER_KEY, provider) - const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1' - const normalized = apiBase.replace(/\/$/, '') - const params = new URLSearchParams({ redirect: redirectTo }) + const params: Record = { redirect: redirectTo } if (affiliateCode) { - params.set('aff_code', affiliateCode) + params.aff_code = affiliateCode } - const startURL = `${normalized}/auth/oauth/${provider}/start?${params.toString()}` - window.location.href = startURL + emit('start', { provider, params }) } diff --git a/frontend/src/components/auth/LinuxDoOAuthSection.vue b/frontend/src/components/auth/LinuxDoOAuthSection.vue index 6b2451231c..a8ae927eee 100644 --- a/frontend/src/components/auth/LinuxDoOAuthSection.vue +++ b/frontend/src/components/auth/LinuxDoOAuthSection.vue @@ -42,6 +42,7 @@ diff --git a/frontend/src/components/auth/OidcOAuthSection.vue b/frontend/src/components/auth/OidcOAuthSection.vue index 4297f7a192..816b4da052 100644 --- a/frontend/src/components/auth/OidcOAuthSection.vue +++ b/frontend/src/components/auth/OidcOAuthSection.vue @@ -23,6 +23,7 @@ import { computed } from 'vue' import { useRoute } from 'vue-router' import { useI18n } from 'vue-i18n' +import type { OAuthLoginStart } from '@/api/auth' import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate' const props = withDefaults(defineProps<{ @@ -34,6 +35,9 @@ const props = withDefaults(defineProps<{ providerName: 'OIDC', showDivider: true }) +const emit = defineEmits<{ + start: [request: OAuthLoginStart] +}>() const route = useRoute() const { t } = useI18n() @@ -48,9 +52,6 @@ const providerInitial = computed(() => normalizedProviderName.value.charAt(0).to function startLogin(): void { const redirectTo = (route.query.redirect as string) || '/dashboard' storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code)) - const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1' - const normalized = apiBase.replace(/\/$/, '') - const startURL = `${normalized}/auth/oauth/oidc/start?redirect=${encodeURIComponent(redirectTo)}` - window.location.href = startURL + emit('start', { provider: 'oidc', params: { redirect: redirectTo } }) } diff --git a/frontend/src/components/auth/PendingOAuthCreateAccountForm.vue b/frontend/src/components/auth/PendingOAuthCreateAccountForm.vue index 112a25cec0..0403843eb4 100644 --- a/frontend/src/components/auth/PendingOAuthCreateAccountForm.vue +++ b/frontend/src/components/auth/PendingOAuthCreateAccountForm.vue @@ -16,10 +16,14 @@ :placeholder="t('auth.passwordPlaceholder')" :disabled="isSubmitting" /> -
+
diff --git a/frontend/src/components/auth/__tests__/EmailOAuthButtons.spec.ts b/frontend/src/components/auth/__tests__/EmailOAuthButtons.spec.ts index d85178085f..845d763744 100644 --- a/frontend/src/components/auth/__tests__/EmailOAuthButtons.spec.ts +++ b/frontend/src/components/auth/__tests__/EmailOAuthButtons.spec.ts @@ -6,10 +6,6 @@ const routeState = vi.hoisted(() => ({ query: {} as Record, })) -const locationState = vi.hoisted(() => ({ - current: { href: 'http://localhost/register?aff=AFF123' } as { href: string }, -})) - vi.mock('vue-router', () => ({ useRoute: () => routeState, })) @@ -28,16 +24,11 @@ vi.mock('vue-i18n', () => ({ describe('EmailOAuthButtons', () => { beforeEach(() => { routeState.query = { redirect: '/billing?plan=pro', aff: 'AFF123' } - locationState.current = { href: 'http://localhost/register?aff=AFF123' } - Object.defineProperty(window, 'location', { - configurable: true, - value: locationState.current, - }) window.localStorage.clear() window.sessionStorage.clear() }) - it('passes the affiliate code to the email oauth start URL', async () => { + it('emits the GitHub OAuth request with redirect and affiliate parameters', async () => { const wrapper = mount(EmailOAuthButtons, { props: { githubEnabled: true, @@ -53,13 +44,40 @@ describe('EmailOAuthButtons', () => { await wrapper.get('button').trigger('click') - expect(locationState.current.href).toBe( - '/api/v1/auth/oauth/github/start?redirect=%2Fbilling%3Fplan%3Dpro&aff_code=AFF123' - ) + expect(wrapper.emitted('start')).toEqual([[ + { + provider: 'github', + params: { redirect: '/billing?plan=pro', aff_code: 'AFF123' } + } + ]]) expect(window.sessionStorage.getItem('oauth_aff_code')).toBe('AFF123') expect(window.sessionStorage.getItem('email_oauth_pending_provider')).toBe('github') }) + it('emits the Google provider without navigating directly', async () => { + const originalHref = window.location.href + const wrapper = mount(EmailOAuthButtons, { + props: { + githubEnabled: false, + googleEnabled: true, + }, + global: { + stubs: { + GitHubMark: true, + GoogleMark: true, + }, + }, + }) + + await wrapper.get('button').trigger('click') + + expect(wrapper.emitted('start')?.[0]?.[0]).toEqual({ + provider: 'google', + params: { redirect: '/billing?plan=pro', aff_code: 'AFF123' } + }) + expect(window.location.href).toBe(originalHref) + }) + it('uses a full-width descriptive button when only GitHub is enabled', () => { const wrapper = mount(EmailOAuthButtons, { props: { diff --git a/frontend/src/components/auth/__tests__/OAuthLoginSections.spec.ts b/frontend/src/components/auth/__tests__/OAuthLoginSections.spec.ts new file mode 100644 index 0000000000..5111ac6f66 --- /dev/null +++ b/frontend/src/components/auth/__tests__/OAuthLoginSections.spec.ts @@ -0,0 +1,44 @@ +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue' +import DingTalkOAuthSection from '@/components/auth/DingTalkOAuthSection.vue' +import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue' + +const routeState = vi.hoisted(() => ({ + query: {} as Record +})) + +vi.mock('vue-router', () => ({ + useRoute: () => routeState +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key + }) +})) + +describe('OAuth login sections', () => { + beforeEach(() => { + routeState.query = { redirect: '/billing?plan=pro', aff: 'AFF123' } + window.sessionStorage.clear() + }) + + it.each([ + ['linuxdo', LinuxDoOAuthSection], + ['dingtalk', DingTalkOAuthSection], + ['oidc', OidcOAuthSection] + ] as const)('emits a %s start request from the original button', async (provider, component) => { + const originalHref = window.location.href + const wrapper = mount(component, { props: { affCode: 'AFF456' } }) + + await wrapper.get('button').trigger('click') + + expect(wrapper.emitted('start')?.[0]?.[0]).toEqual({ + provider, + params: { redirect: '/billing?plan=pro' } + }) + expect(window.sessionStorage.getItem('oauth_aff_code')).toBe('AFF456') + expect(window.location.href).toBe(originalHref) + }) +}) diff --git a/frontend/src/components/auth/__tests__/PendingOAuthCreateAccountForm.spec.ts b/frontend/src/components/auth/__tests__/PendingOAuthCreateAccountForm.spec.ts index d41c1dd7f0..23111299c1 100644 --- a/frontend/src/components/auth/__tests__/PendingOAuthCreateAccountForm.spec.ts +++ b/frontend/src/components/auth/__tests__/PendingOAuthCreateAccountForm.spec.ts @@ -1,3 +1,4 @@ +import { defineComponent, h } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' @@ -7,6 +8,8 @@ const sendVerifyCode = vi.fn() const sendPendingOAuthVerifyCode = vi.fn() const getPublicSettings = vi.fn() const showError = vi.fn() +const turnstileReset = vi.fn() +const verifyTencent = vi.fn() vi.mock('vue-i18n', async () => { const actual = await vi.importActual('vue-i18n') @@ -40,12 +43,69 @@ describe('PendingOAuthCreateAccountForm', () => { sendPendingOAuthVerifyCode.mockReset() getPublicSettings.mockReset() showError.mockReset() + turnstileReset.mockReset() + verifyTencent.mockReset() getPublicSettings.mockResolvedValue({ turnstile_enabled: false, turnstile_site_key: '' }) }) + it('acquires separate proofs for pending OAuth send-code and create-account', async () => { + getPublicSettings.mockResolvedValue({ + email_verify_enabled: true, + turnstile_enabled: false, + turnstile_site_key: '', + tencent_captcha_enabled: true, + tencent_captcha_app_id: 'tencent-app-id' + }) + sendPendingOAuthVerifyCode.mockResolvedValue({ countdown: 0 }) + verifyTencent + .mockResolvedValueOnce({ ticket: 'ticket-1', randstr: '@rand-1' }) + .mockResolvedValueOnce({ ticket: 'ticket-2', randstr: '@rand-2' }) + const CaptchaChallengeStub = defineComponent({ + setup(_, { expose }) { + expose({ verifyTencent, reset: turnstileReset }) + return () => h('div') + } + }) + + const wrapper = mount(PendingOAuthCreateAccountForm, { + props: { + testIdPrefix: 'oidc', + initialEmail: 'user@example.com', + isSubmitting: false + }, + global: { + stubs: { TurnstileWidget: CaptchaChallengeStub } + } + }) + + await flushPromises() + await wrapper.get('[data-testid="oidc-create-account-password"]').setValue('secret-123') + await wrapper.get('[data-testid="oidc-create-account-verify-code"]').setValue('246810') + await wrapper.get('[data-testid="oidc-create-account-send-code"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="oidc-create-account-submit"]').trigger('click') + await flushPromises() + + expect(verifyTencent).toHaveBeenCalledTimes(2) + expect(sendPendingOAuthVerifyCode).toHaveBeenCalledWith({ + email: 'user@example.com', + tencent_captcha_ticket: 'ticket-1', + tencent_captcha_randstr: '@rand-1' + }) + expect(wrapper.emitted('submit')).toEqual([ + [ + expect.objectContaining({ + tencentCaptchaTicket: 'ticket-2', + tencentCaptchaRandstr: '@rand-2' + }) + ] + ]) + expect(turnstileReset).toHaveBeenCalledTimes(2) + }) + it('emits trimmed email, password, and verify code on submit', async () => { const wrapper = mount(PendingOAuthCreateAccountForm, { props: { @@ -195,6 +255,38 @@ describe('PendingOAuthCreateAccountForm', () => { expect(wrapper.text()).not.toContain('send failed') }) + it('consumes the captcha proof when sending a verify code fails', async () => { + getPublicSettings.mockResolvedValue({ + turnstile_enabled: true, + turnstile_site_key: 'site-key' + }) + sendPendingOAuthVerifyCode.mockRejectedValue(new Error('send failed')) + + const wrapper = mount(PendingOAuthCreateAccountForm, { + props: { + testIdPrefix: 'oidc', + initialEmail: 'user@example.com', + isSubmitting: false + }, + global: { + stubs: { + TurnstileWidget: { + template: '', + methods: { reset: turnstileReset } + } + } + } + }) + + await flushPromises() + await wrapper.get('[data-testid="turnstile-verify"]').trigger('click') + await wrapper.get('[data-testid="oidc-create-account-send-code"]').trigger('click') + await flushPromises() + + expect(turnstileReset).toHaveBeenCalledOnce() + expect(wrapper.get('[data-testid="oidc-create-account-send-code"]').attributes('disabled')).toBeDefined() + }) + it('requires a turnstile token before sending a verify code when turnstile is enabled', async () => { getPublicSettings.mockResolvedValue({ turnstile_enabled: true, @@ -215,7 +307,8 @@ describe('PendingOAuthCreateAccountForm', () => { global: { stubs: { TurnstileWidget: { - template: '' + template: '', + methods: { reset: vi.fn() } } } } diff --git a/frontend/src/components/auth/__tests__/WechatOAuthSection.spec.ts b/frontend/src/components/auth/__tests__/WechatOAuthSection.spec.ts index 5c2673424a..67ccb47aaa 100644 --- a/frontend/src/components/auth/__tests__/WechatOAuthSection.spec.ts +++ b/frontend/src/components/auth/__tests__/WechatOAuthSection.spec.ts @@ -9,10 +9,6 @@ const routeState = vi.hoisted(() => ({ query: {} as Record, })) -const locationState = vi.hoisted(() => ({ - current: { href: 'http://localhost/login' } as { href: string }, -})) - let pinia: ReturnType vi.mock('vue-router', () => ({ @@ -105,11 +101,6 @@ describe('WechatOAuthSection', () => { pinia = createPinia() setActivePinia(pinia) routeState.query = { redirect: '/billing?plan=pro' } - locationState.current = { href: 'http://localhost/login' } - Object.defineProperty(window, 'location', { - configurable: true, - value: locationState.current, - }) Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value: 'Mozilla/5.0', @@ -135,9 +126,10 @@ describe('WechatOAuthSection', () => { await wrapper.get('button').trigger('click') - expect(locationState.current.href).toContain( - '/api/v1/auth/oauth/wechat/start?mode=open&redirect=%2Fbilling%3Fplan%3Dpro' - ) + expect(wrapper.emitted('start')?.[0]?.[0]).toEqual({ + provider: 'wechat', + params: { mode: 'open', redirect: '/billing?plan=pro' } + }) }) it('uses mp mode inside the WeChat browser when mp mode is configured', async () => { @@ -157,9 +149,10 @@ describe('WechatOAuthSection', () => { await wrapper.get('button').trigger('click') - expect(locationState.current.href).toContain( - '/api/v1/auth/oauth/wechat/start?mode=mp&redirect=%2Fbilling%3Fplan%3Dpro' - ) + expect(wrapper.emitted('start')?.[0]?.[0]).toEqual({ + provider: 'wechat', + params: { mode: 'mp', redirect: '/billing?plan=pro' } + }) }) it('disables the button outside the WeChat browser when only mp mode is configured', async () => { @@ -178,7 +171,7 @@ describe('WechatOAuthSection', () => { await wrapper.get('button').trigger('click') - expect(locationState.current.href).toBe('http://localhost/login') + expect(wrapper.emitted('start')).toBeUndefined() }) it('disables the button inside the WeChat browser when only open mode is configured', async () => { @@ -201,7 +194,7 @@ describe('WechatOAuthSection', () => { await wrapper.get('button').trigger('click') - expect(locationState.current.href).toBe('http://localhost/login') + expect(wrapper.emitted('start')).toBeUndefined() }) it('uses the legacy overall enabled flag when per-mode settings are not present', async () => { @@ -216,9 +209,10 @@ describe('WechatOAuthSection', () => { await wrapper.get('button').trigger('click') - expect(locationState.current.href).toContain( - '/api/v1/auth/oauth/wechat/start?mode=open&redirect=%2Fbilling%3Fplan%3Dpro' - ) + expect(wrapper.emitted('start')?.[0]?.[0]).toEqual({ + provider: 'wechat', + params: { mode: 'open', redirect: '/billing?plan=pro' } + }) }) it('shows the localized not-configured hint when WeChat OAuth is unavailable', async () => { diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 0f3a9e8dd2..df43d9170a 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -183,6 +183,29 @@ export default { secretKeyHint: 'Server-side verification key (keep this secret)', secretKeyConfiguredHint: 'Secret key configured. Leave empty to keep the current value.' }, + tencentCaptcha: { + title: 'Tencent Captcha', + description: 'Slider captcha protection for login, registration, and third-party account creation', + enable: 'Enable Tencent Captcha', + enableHint: 'Use Tencent slider captcha in every existing Turnstile flow', + keepExisting: 'Leave empty to keep current value', + configured: 'Configured. Leave empty to keep it.', + required: 'Required before enabling.', + mutualExclusion: 'Tencent Captcha and Cloudflare Turnstile are mutually exclusive. Enabling one disables the other.', + appCredentialsTitle: 'Captcha application credentials', + appCredentialsHint: 'Get CaptchaAppId and AppSecretKey from Verification Management in the Captcha console.', + cloudCredentialsTitle: 'Cloud API credentials', + cloudCredentialsHint: 'SecretId and SecretKey authorize server-side DescribeCaptchaResult requests.', + appId: 'CaptchaAppId', + appSecretKey: 'AppSecretKey', + cloudSecretId: 'Tencent Cloud SecretId', + cloudSecretKey: 'Tencent Cloud SecretKey', + camPermissionHint: 'Create a CAM sub-user with QcloudCaptchaFullAccess instead of using permanent root-account credentials.', + aidEncryptedHint: 'aidEncrypted is not supported yet. Keep CaptchaAppId mandatory verification disabled in the Captcha console.', + openCaptchaConsole: 'Open Captcha console', + createCloudKeys: 'Create SecretId / SecretKey', + openWebDocs: 'View Web integration guide' + }, apiKeyAcl: { title: 'API Key IP Access Control', description: diff --git a/frontend/src/i18n/locales/en/common.ts b/frontend/src/i18n/locales/en/common.ts index 16c797e612..988500c226 100644 --- a/frontend/src/i18n/locales/en/common.ts +++ b/frontend/src/i18n/locales/en/common.ts @@ -243,6 +243,8 @@ export default { reloginRequired: 'Session expired. Please log in again.', turnstileExpired: 'Verification expired, please try again', turnstileFailed: 'Verification failed, please try again', + captchaVerified: 'Verification completed', + captchaLoading: 'Loading verification…', completeVerification: 'Please complete the verification', verifyYourEmail: 'Verify Your Email', sessionExpired: 'Session expired', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 5137d02436..734bb99d11 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -183,6 +183,29 @@ export default { secretKeyHint: '服务端验证密钥(请保密)', secretKeyConfiguredHint: '密钥已配置,留空以保留当前值。' }, + tencentCaptcha: { + title: '腾讯天御验证码', + description: '为登录、注册及第三方登录创建账号流程提供滑动验证码保护', + enable: '启用腾讯天御验证码', + enableHint: '启用后将在所有原 Turnstile 场景使用腾讯滑动验证码', + keepExisting: '留空以保留当前值', + configured: '已配置,留空不会覆盖。', + required: '启用前必须填写此项。', + mutualExclusion: '腾讯天御验证码与 Cloudflare Turnstile 互斥,开启其中一个会自动关闭另一个。', + appCredentialsTitle: '验证码应用密钥', + appCredentialsHint: 'CaptchaAppId 与 AppSecretKey 来自验证码控制台的验证管理页面。', + cloudCredentialsTitle: '云 API 调用密钥', + cloudCredentialsHint: 'SecretId 与 SecretKey 用于服务端调用 DescribeCaptchaResult 接口。', + appId: 'CaptchaAppId', + appSecretKey: 'AppSecretKey', + cloudSecretId: '腾讯云 SecretId', + cloudSecretKey: '腾讯云 SecretKey', + camPermissionHint: '推荐创建 CAM 子用户并授予 QcloudCaptchaFullAccess,避免使用主账号永久密钥。', + aidEncryptedHint: '当前版本暂不支持 aidEncrypted,请先不要在验证码控制台开启 CaptchaAppId 强制校验。', + openCaptchaConsole: '打开验证码控制台', + createCloudKeys: '创建 SecretId / SecretKey', + openWebDocs: '查看 Web 接入文档' + }, apiKeyAcl: { title: 'API Key IP 访问控制', description: '控制 API Key 白/黑名单、操作审计日志与会话 IP/UA 绑定使用哪个客户端 IP 判断', diff --git a/frontend/src/i18n/locales/zh/common.ts b/frontend/src/i18n/locales/zh/common.ts index 0deb1b9573..ce611fcca1 100644 --- a/frontend/src/i18n/locales/zh/common.ts +++ b/frontend/src/i18n/locales/zh/common.ts @@ -242,6 +242,8 @@ export default { reloginRequired: '会话已过期,请重新登录。', turnstileExpired: '验证已过期,请重试', turnstileFailed: '验证失败,请重试', + captchaVerified: '验证已完成', + captchaLoading: '正在加载验证码…', completeVerification: '请完成验证', verifyYourEmail: '验证您的邮箱', sessionExpired: '会话已过期', diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index c0f3477d3b..928997b1bc 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -6,7 +6,13 @@ import { defineStore } from 'pinia' import { ref, computed, readonly } from 'vue' import { authAPI, isTotp2FARequired, passkeyAPI, type LoginResponse } from '@/api' -import type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types' +import type { + User, + LoginRequest, + RegisterRequest, + AuthResponse, + TencentCaptchaRequestProof +} from '@/types' const AUTH_TOKEN_KEY = 'auth_token' const AUTH_USER_KEY = 'auth_user' @@ -275,9 +281,9 @@ export const useAuthStore = defineStore('auth', () => { } } - async function loginWithPasskey(): Promise { + async function loginWithPasskey(proof?: TencentCaptchaRequestProof): Promise { try { - const response = await passkeyAPI.login() + const response = await passkeyAPI.login(proof) setAuthFromResponse(response) return user.value! } catch (error) { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 398e0c8b8b..792227741c 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -115,6 +115,13 @@ export interface LoginRequest { email: string password: string turnstile_token?: string + tencent_captcha_ticket?: string + tencent_captcha_randstr?: string +} + +export interface TencentCaptchaRequestProof { + tencent_captcha_ticket: string + tencent_captcha_randstr: string } export interface RegisterRequest { @@ -122,6 +129,8 @@ export interface RegisterRequest { password: string verify_code?: string turnstile_token?: string + tencent_captcha_ticket?: string + tencent_captcha_randstr?: string promo_code?: string invitation_code?: string aff_code?: string @@ -156,6 +165,8 @@ export interface AffiliateTransferResponse { export interface SendVerifyCodeRequest { email: string turnstile_token?: string + tencent_captcha_ticket?: string + tencent_captcha_randstr?: string pending_auth_token?: string pending_oauth_token?: string } @@ -201,6 +212,8 @@ export interface PublicSettings { login_agreement_revision?: string login_agreement_documents?: LoginAgreementDocument[] turnstile_enabled: boolean + tencent_captcha_enabled?: boolean + tencent_captcha_app_id?: string passkey_enabled?: boolean turnstile_site_key: string site_name: string diff --git a/frontend/src/utils/tencentCaptcha.ts b/frontend/src/utils/tencentCaptcha.ts new file mode 100644 index 0000000000..1e79b0f6a6 --- /dev/null +++ b/frontend/src/utils/tencentCaptcha.ts @@ -0,0 +1,62 @@ +export interface TencentCaptchaProof { + ticket: string + randstr: string +} + +export interface TencentCaptchaResult { + ret: number + ticket?: string | null + randstr?: string | null + errorCode?: number + errorMessage?: string +} + +interface TencentCaptchaInstance { + show(): void + destroy(): void +} + +type TencentCaptchaConstructor = new ( + appId: string, + callback: (result: TencentCaptchaResult) => void, + options?: Record +) => TencentCaptchaInstance + +declare global { + interface Window { + TencentCaptcha?: TencentCaptchaConstructor + } +} + +const SCRIPT_SRC = 'https://turing.captcha.qcloud.com/TJCaptcha.js' +let scriptPromise: Promise | null = null + +export function loadTencentCaptcha(): Promise { + if (window.TencentCaptcha) return Promise.resolve(window.TencentCaptcha) + if (scriptPromise) return scriptPromise + + scriptPromise = new Promise((resolve, reject) => { + const script = document.createElement('script') + script.src = SCRIPT_SRC + script.async = true + script.onload = () => { + if (window.TencentCaptcha) { + resolve(window.TencentCaptcha) + return + } + scriptPromise = null + reject(new Error('Tencent Captcha SDK is unavailable')) + } + script.onerror = () => { + scriptPromise = null + reject(new Error('Failed to load Tencent Captcha SDK')) + } + document.head.appendChild(script) + }) + + return scriptPromise +} + +export function resetTencentCaptchaLoaderForTest(): void { + scriptPromise = null +} diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index fb7e81acd3..e6d9a9072d 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1990,7 +1990,11 @@ {{ t("admin.settings.turnstile.enableTurnstileHint") }}

- +
@@ -2050,6 +2054,151 @@ + +
+
+

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

+

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

+
+
+
+
+ +

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

+
+ +
+ +
+
+
+

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

+

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

+
+
+ + +
+
+ + +

+ {{ form.tencent_captcha_app_secret_key_configured ? t("admin.settings.tencentCaptcha.configured") : t("admin.settings.tencentCaptcha.required") }} +

+
+
+

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

+

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

+
+
+ + +

+ {{ form.tencent_captcha_cloud_secret_id_configured ? t("admin.settings.tencentCaptcha.configured") : t("admin.settings.tencentCaptcha.required") }} +

+
+
+ + +

+ {{ form.tencent_captcha_cloud_secret_key_configured ? t("admin.settings.tencentCaptcha.configured") : t("admin.settings.tencentCaptcha.required") }} +

+
+
+

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

+

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

+

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

+ +
+
+
+
& { smtp_password: string; turnstile_secret_key: string; + tencent_captcha_app_secret_key: string; + tencent_captcha_cloud_secret_id: string; + tencent_captcha_cloud_secret_key: string; linuxdo_connect_client_secret: string; dingtalk_connect_client_secret: string; wechat_connect_app_secret: string; @@ -8908,6 +9060,14 @@ const form = reactive({ turnstile_site_key: "", turnstile_secret_key: "", turnstile_secret_key_configured: false, + tencent_captcha_enabled: false, + tencent_captcha_app_id: "", + tencent_captcha_app_secret_key: "", + tencent_captcha_app_secret_key_configured: false, + tencent_captcha_cloud_secret_id: "", + tencent_captcha_cloud_secret_id_configured: false, + tencent_captcha_cloud_secret_key: "", + tencent_captcha_cloud_secret_key_configured: false, api_key_acl_trust_forwarded_ip: true, forwarded_client_ip_headers: [], // LinuxDo Connect OAuth 登录 @@ -9067,6 +9227,14 @@ const form = reactive({ allow_user_view_error_requests: false, }); +function onTurnstileToggle(enabled: boolean): void { + if (enabled) form.tencent_captcha_enabled = false; +} + +function onTencentCaptchaToggle(enabled: boolean): void { + if (enabled) form.turnstile_enabled = false; +} + type OpenAIAdvancedSchedulerOverrideKey = | "openai_advanced_scheduler_lb_top_k" | "openai_advanced_scheduler_weight_priority" @@ -10024,6 +10192,9 @@ async function loadSettings() { form.smtp_password = ""; smtpPasswordManuallyEdited.value = false; form.turnstile_secret_key = ""; + form.tencent_captcha_app_secret_key = ""; + form.tencent_captcha_cloud_secret_id = ""; + form.tencent_captcha_cloud_secret_key = ""; form.linuxdo_connect_client_secret = ""; form.dingtalk_connect_client_secret = ""; form.github_oauth_client_secret = ""; @@ -10393,6 +10564,14 @@ async function saveSettings() { turnstile_enabled: form.turnstile_enabled, turnstile_site_key: form.turnstile_site_key, turnstile_secret_key: form.turnstile_secret_key || undefined, + tencent_captcha_enabled: form.tencent_captcha_enabled, + tencent_captcha_app_id: form.tencent_captcha_app_id, + tencent_captcha_app_secret_key: + form.tencent_captcha_app_secret_key || undefined, + tencent_captcha_cloud_secret_id: + form.tencent_captcha_cloud_secret_id || undefined, + tencent_captcha_cloud_secret_key: + form.tencent_captcha_cloud_secret_key || undefined, api_key_acl_trust_forwarded_ip: form.api_key_acl_trust_forwarded_ip, forwarded_client_ip_headers: form.forwarded_client_ip_headers, linuxdo_connect_enabled: form.linuxdo_connect_enabled, diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts index e4e306a6d1..07e33a0ff3 100644 --- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts +++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts @@ -396,6 +396,11 @@ const baseSettingsResponse = { turnstile_enabled: false, turnstile_site_key: "", turnstile_secret_key_configured: false, + tencent_captcha_enabled: false, + tencent_captcha_app_id: "", + tencent_captcha_app_secret_key_configured: false, + tencent_captcha_cloud_secret_id_configured: false, + tencent_captcha_cloud_secret_key_configured: false, api_key_acl_trust_forwarded_ip: true, forwarded_client_ip_headers: [], linuxdo_connect_enabled: false, @@ -767,6 +772,50 @@ describe("admin SettingsView payment visible method controls", () => { ); }); + it("腾讯天御验证码与 Turnstile 开关互斥并保存四项配置", async () => { + const wrapper = mountView(); + await flushPromises(); + await openSecurityTab(wrapper); + + const turnstileToggle = wrapper.get('[data-testid="turnstile-enabled-toggle"]'); + const tencentToggle = wrapper.get('[data-testid="tencent-captcha-enabled-toggle"]'); + await turnstileToggle.setValue(true); + expect((turnstileToggle.element as HTMLInputElement).checked).toBe(true); + + await tencentToggle.setValue(true); + expect((turnstileToggle.element as HTMLInputElement).checked).toBe(false); + expect((tencentToggle.element as HTMLInputElement).checked).toBe(true); + + const card = wrapper + .findAll(".card") + .find((node) => node.text().includes("admin.settings.tencentCaptcha.title")); + expect(card).toBeDefined(); + expect(card!.get('a[href="https://console.cloud.tencent.com/captcha"]').exists()).toBe(true); + expect(card!.get('a[href="https://console.cloud.tencent.com/cam/capi"]').exists()).toBe(true); + expect( + card!.get('a[href="https://cloud.tencent.com/document/product/1110/36841"]').exists(), + ).toBe(true); + const inputs = card!.findAll("input").filter((input) => input.attributes("type") !== "checkbox"); + await inputs[0]!.setValue("123456789"); + await inputs[1]!.setValue("app-secret-value"); + await inputs[2]!.setValue("cloud-secret-id-value"); + await inputs[3]!.setValue("cloud-secret-key-value"); + + await wrapper.find("form").trigger("submit.prevent"); + await flushPromises(); + + expect(updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ + turnstile_enabled: false, + tencent_captcha_enabled: true, + tencent_captcha_app_id: "123456789", + 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", + }), + ); + }); + it("disables passkey sign-in when the RP configuration is unavailable", async () => { getSettings.mockResolvedValueOnce({ ...baseSettingsResponse, diff --git a/frontend/src/views/auth/DingTalkCallbackView.vue b/frontend/src/views/auth/DingTalkCallbackView.vue index b90d4977a9..48c090680c 100644 --- a/frontend/src/views/auth/DingTalkCallbackView.vue +++ b/frontend/src/views/auth/DingTalkCallbackView.vue @@ -683,6 +683,13 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) { email: payload.email, password: payload.password, verify_code: payload.verifyCode || undefined, + ...(payload.turnstileToken ? { turnstile_token: payload.turnstileToken } : {}), + ...(payload.tencentCaptchaTicket + ? { + tencent_captcha_ticket: payload.tencentCaptchaTicket, + tencent_captcha_randstr: payload.tencentCaptchaRandstr + } + : {}), invitation_code: payload.invitationCode || undefined, ...oauthAffiliatePayload(loadOAuthAffiliateCode()), ...serializeAdoptionDecision(currentAdoptionDecision()) diff --git a/frontend/src/views/auth/DingTalkEmailCompletionView.vue b/frontend/src/views/auth/DingTalkEmailCompletionView.vue index 11e631cdfe..5d3fa15b31 100644 --- a/frontend/src/views/auth/DingTalkEmailCompletionView.vue +++ b/frontend/src/views/auth/DingTalkEmailCompletionView.vue @@ -82,6 +82,13 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) { email: payload.email, password: payload.password, verify_code: payload.verifyCode || undefined, + ...(payload.turnstileToken ? { turnstile_token: payload.turnstileToken } : {}), + ...(payload.tencentCaptchaTicket + ? { + tencent_captcha_ticket: payload.tencentCaptchaTicket, + tencent_captcha_randstr: payload.tencentCaptchaRandstr + } + : {}), invitation_code: payload.invitationCode || undefined } ) diff --git a/frontend/src/views/auth/EmailVerifyView.vue b/frontend/src/views/auth/EmailVerifyView.vue index 7363e20a28..f515340fbd 100644 --- a/frontend/src/views/auth/EmailVerifyView.vue +++ b/frontend/src/views/auth/EmailVerifyView.vue @@ -67,18 +67,40 @@
-
+
+
+ +
+ -
-
+
('') // Public settings const turnstileEnabled = ref(false) const turnstileSiteKey = ref('') +const tencentCaptchaEnabled = ref(false) +const tencentCaptchaAppId = ref('') // Turnstile const turnstileRef = ref | null>(null) const turnstileToken = ref('') +const tencentCaptchaRandstr = ref('') +const captchaEnabled = computed( + () => + (turnstileEnabled.value && Boolean(turnstileSiteKey.value)) || + (tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value)) +) const formData = reactive({ email: '' @@ -177,6 +188,8 @@ onMounted(async () => { const settings = await getPublicSettings() turnstileEnabled.value = settings.turnstile_enabled turnstileSiteKey.value = settings.turnstile_site_key || '' + tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true + tencentCaptchaAppId.value = settings.tencent_captcha_app_id || '' } catch (error) { console.error('Failed to load public settings:', error) } @@ -184,21 +197,42 @@ onMounted(async () => { // ==================== Turnstile Handlers ==================== -function onTurnstileVerify(token: string): void { +function onTurnstileVerify(token: string, randstr = ''): void { turnstileToken.value = token + tencentCaptchaRandstr.value = randstr errors.turnstile = '' } function onTurnstileExpire(): void { turnstileToken.value = '' + tencentCaptchaRandstr.value = '' errors.turnstile = t('auth.turnstileExpired') } function onTurnstileError(): void { turnstileToken.value = '' + tencentCaptchaRandstr.value = '' errors.turnstile = t('auth.turnstileFailed') } +function resetCaptchaProof(): void { + turnstileRef.value?.reset() + turnstileToken.value = '' + tencentCaptchaRandstr.value = '' + errors.turnstile = '' +} + +async function acquireTencentProof(): Promise { + if (!tencentCaptchaEnabled.value) return true + + const proof = await turnstileRef.value?.verifyTencent() + if (!proof) return false + + turnstileToken.value = proof.ticket + tencentCaptchaRandstr.value = proof.randstr + return true +} + // ==================== Validation ==================== function validateForm(): boolean { @@ -234,23 +268,23 @@ async function handleSubmit(): Promise { return } + if (!(await acquireTencentProof())) { + return + } + isLoading.value = true try { await forgotPassword({ email: formData.email, - turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined + turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined, + tencent_captcha_ticket: tencentCaptchaEnabled.value ? turnstileToken.value : undefined, + tencent_captcha_randstr: tencentCaptchaEnabled.value ? tencentCaptchaRandstr.value : undefined }) isSubmitted.value = true appStore.showSuccess(t('auth.resetEmailSent')) } catch (error: unknown) { - // Reset Turnstile on error - if (turnstileRef.value) { - turnstileRef.value.reset() - turnstileToken.value = '' - } - const err = error as { message?: string; response?: { data?: { detail?: string } } } if (err.response?.data?.detail) { @@ -263,6 +297,9 @@ async function handleSubmit(): Promise { appStore.showError(errorMessage.value) } finally { + if (captchaEnabled.value) { + resetCaptchaProof() + } isLoading.value = false } } diff --git a/frontend/src/views/auth/LinuxDoCallbackView.vue b/frontend/src/views/auth/LinuxDoCallbackView.vue index fef5b90d06..e789a519d4 100644 --- a/frontend/src/views/auth/LinuxDoCallbackView.vue +++ b/frontend/src/views/auth/LinuxDoCallbackView.vue @@ -681,6 +681,13 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) { email: payload.email, password: payload.password, verify_code: payload.verifyCode || undefined, + ...(payload.turnstileToken ? { turnstile_token: payload.turnstileToken } : {}), + ...(payload.tencentCaptchaTicket + ? { + tencent_captcha_ticket: payload.tencentCaptchaTicket, + tencent_captcha_randstr: payload.tencentCaptchaRandstr + } + : {}), invitation_code: payload.invitationCode || undefined, ...oauthAffiliatePayload(loadOAuthAffiliateCode()), ...serializeAdoptionDecision(currentAdoptionDecision()) diff --git a/frontend/src/views/auth/LoginView.vue b/frontend/src/views/auth/LoginView.vue index 75168dfcd5..de4915b029 100644 --- a/frontend/src/views/auth/LoginView.vue +++ b/frontend/src/views/auth/LoginView.vue @@ -79,10 +79,13 @@
-
+
@@ -221,10 +229,21 @@ import EmailOAuthButtons from '@/components/auth/EmailOAuthButtons.vue' import LoginAgreementPrompt from '@/components/auth/LoginAgreementPrompt.vue' import TotpLoginModal from '@/components/auth/TotpLoginModal.vue' import Icon from '@/components/icons/Icon.vue' -import TurnstileWidget from '@/components/TurnstileWidget.vue' +import TurnstileWidget from '@/components/CaptchaChallenge.vue' import { useAuthStore, useAppStore } from '@/stores' -import { getPublicSettings, isTotp2FARequired, isWeChatWebOAuthEnabled } from '@/api/auth' -import type { LoginAgreementDocument, TotpLoginResponse } from '@/types' +import { + buildOAuthLoginStartURL, + getPublicSettings, + isTotp2FARequired, + isWeChatWebOAuthEnabled, + startOAuthLogin, + type OAuthLoginStart +} from '@/api/auth' +import type { + LoginAgreementDocument, + TencentCaptchaRequestProof, + TotpLoginResponse +} from '@/types' import { extractI18nErrorMessage } from '@/utils/apiError' import { clearAllAffiliateReferralCodes } from '@/utils/oauthAffiliate' @@ -248,6 +267,8 @@ const publicSettingsLoaded = ref(false) // Public settings const turnstileEnabled = ref(false) const turnstileSiteKey = ref('') +const tencentCaptchaEnabled = ref(false) +const tencentCaptchaAppId = ref('') const linuxdoOAuthEnabled = ref(false) const dingtalkOAuthEnabled = ref(false) const wechatOAuthEnabled = ref(false) @@ -269,6 +290,12 @@ const showAgreementModal = ref(false) // Turnstile const turnstileRef = ref | null>(null) const turnstileToken = ref('') +const tencentCaptchaRandstr = ref('') +const captchaEnabled = computed( + () => + (turnstileEnabled.value && Boolean(turnstileSiteKey.value)) || + (tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value)) +) // 2FA state const show2FAModal = ref(false) @@ -335,6 +362,8 @@ onMounted(async () => { const settings = await getPublicSettings() turnstileEnabled.value = settings.turnstile_enabled turnstileSiteKey.value = settings.turnstile_site_key || '' + tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true + tencentCaptchaAppId.value = settings.tencent_captcha_app_id || '' linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled dingtalkOAuthEnabled.value = settings.dingtalk_oauth_enabled ?? false wechatOAuthEnabled.value = isWeChatWebOAuthEnabled(settings) @@ -420,21 +449,42 @@ function rejectLoginAgreement(): void { // ==================== Turnstile Handlers ==================== -function onTurnstileVerify(token: string): void { +function onTurnstileVerify(token: string, randstr = ''): void { turnstileToken.value = token + tencentCaptchaRandstr.value = randstr errors.turnstile = '' } function onTurnstileExpire(): void { turnstileToken.value = '' + tencentCaptchaRandstr.value = '' errors.turnstile = t('auth.turnstileExpired') } function onTurnstileError(): void { turnstileToken.value = '' + tencentCaptchaRandstr.value = '' errors.turnstile = t('auth.turnstileFailed') } +function resetCaptchaProof(): void { + turnstileRef.value?.reset() + turnstileToken.value = '' + tencentCaptchaRandstr.value = '' + errors.turnstile = '' +} + +async function acquireTencentProof(): Promise { + if (!tencentCaptchaEnabled.value) return true + + const proof = await turnstileRef.value?.verifyTencent() + if (!proof) return false + + turnstileToken.value = proof.ticket + tencentCaptchaRandstr.value = proof.randstr + return true +} + // ==================== Validation ==================== function validateForm(): boolean { @@ -491,6 +541,10 @@ async function handleLogin(): Promise { return } + if (!(await acquireTencentProof())) { + return + } + isLoading.value = true try { @@ -498,7 +552,11 @@ async function handleLogin(): Promise { const response = await authStore.login({ email: formData.email, password: formData.password, - turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined + turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined, + tencent_captcha_ticket: tencentCaptchaEnabled.value ? turnstileToken.value : undefined, + tencent_captcha_randstr: tencentCaptchaEnabled.value + ? tencentCaptchaRandstr.value + : undefined }) // Check if 2FA is required @@ -519,17 +577,14 @@ async function handleLogin(): Promise { const redirectTo = (router.currentRoute.value.query.redirect as string) || '/dashboard' await router.push(redirectTo) } catch (error: unknown) { - // Reset Turnstile on error - if (turnstileRef.value) { - turnstileRef.value.reset() - turnstileToken.value = '' - } - errorMessage.value = extractI18nErrorMessage(error, t, 'auth.errors', t('auth.loginFailed')) // Also show error toast appStore.showError(errorMessage.value) } finally { + if (captchaEnabled.value) { + resetCaptchaProof() + } isLoading.value = false } } @@ -545,7 +600,17 @@ async function handlePasskeyLogin(): Promise { passkeyLoading.value = true try { - await authStore.loginWithPasskey() + let proof: TencentCaptchaRequestProof | undefined + if (tencentCaptchaEnabled.value) { + const result = await turnstileRef.value?.verifyTencent() + if (!result) return + proof = { + tencent_captcha_ticket: result.ticket, + tencent_captcha_randstr: result.randstr + } + } + + await authStore.loginWithPasskey(proof) clearAllAffiliateReferralCodes() appStore.showSuccess(t('auth.loginSuccess')) const redirectTo = (router.currentRoute.value.query.redirect as string) || '/dashboard' @@ -557,10 +622,45 @@ async function handlePasskeyLogin(): Promise { errorMessage.value = extractI18nErrorMessage(error, t, 'auth.errors', fallback) appStore.showError(errorMessage.value) } finally { + if (tencentCaptchaEnabled.value) { + resetCaptchaProof() + } passkeyLoading.value = false } } +async function handleOAuthStart(request: OAuthLoginStart): Promise { + if (authActionDisabled.value) return + + if (!tencentCaptchaEnabled.value) { + window.location.href = buildOAuthLoginStartURL(request) + return + } + + isLoading.value = true + try { + const proof = await turnstileRef.value?.verifyTencent() + if (!proof) return + + const result = await startOAuthLogin(request, { + tencent_captcha_ticket: proof.ticket, + tencent_captcha_randstr: proof.randstr + }) + window.location.href = result.authorize_url + } catch (error: unknown) { + errorMessage.value = extractI18nErrorMessage( + error, + t, + 'auth.errors', + t('auth.turnstileFailed') + ) + appStore.showError(errorMessage.value) + } finally { + resetCaptchaProof() + isLoading.value = false + } +} + // ==================== 2FA Handlers ==================== async function handle2FAVerify(code: string): Promise { diff --git a/frontend/src/views/auth/OidcCallbackView.vue b/frontend/src/views/auth/OidcCallbackView.vue index e17f05e91d..d24ae620dc 100644 --- a/frontend/src/views/auth/OidcCallbackView.vue +++ b/frontend/src/views/auth/OidcCallbackView.vue @@ -705,6 +705,13 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) { email: payload.email, password: payload.password, verify_code: payload.verifyCode || undefined, + ...(payload.turnstileToken ? { turnstile_token: payload.turnstileToken } : {}), + ...(payload.tencentCaptchaTicket + ? { + tencent_captcha_ticket: payload.tencentCaptchaTicket, + tencent_captcha_randstr: payload.tencentCaptchaRandstr + } + : {}), invitation_code: payload.invitationCode || undefined, ...oauthAffiliatePayload(loadOAuthAffiliateCode()), ...serializeAdoptionDecision(currentAdoptionDecision()) diff --git a/frontend/src/views/auth/RegisterView.vue b/frontend/src/views/auth/RegisterView.vue index 4b89779142..5c4ce61ef1 100644 --- a/frontend/src/views/auth/RegisterView.vue +++ b/frontend/src/views/auth/RegisterView.vue @@ -204,10 +204,13 @@
-
+
@@ -329,15 +336,19 @@ import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue' import EmailOAuthButtons from '@/components/auth/EmailOAuthButtons.vue' import LoginAgreementPrompt from '@/components/auth/LoginAgreementPrompt.vue' import Icon from '@/components/icons/Icon.vue' -import TurnstileWidget from '@/components/TurnstileWidget.vue' +import TurnstileWidget from '@/components/CaptchaChallenge.vue' import { useAuthStore, useAppStore } from '@/stores' import { + buildOAuthLoginStartURL, getPublicSettings, isWeChatWebOAuthEnabled, + startOAuthLogin, + type OAuthLoginStart, validatePromoCode, validateInvitationCode } from '@/api/auth' import { buildAuthErrorMessage } from '@/utils/authError' +import { extractI18nErrorMessage } from '@/utils/apiError' import { formatRegistrationEmailSuffixWhitelistForMessage, isRegistrationEmailSuffixAllowed, @@ -375,6 +386,8 @@ const invitationCodeEnabled = ref(false) const affiliateEnabled = ref(false) const turnstileEnabled = ref(false) const turnstileSiteKey = ref('') +const tencentCaptchaEnabled = ref(false) +const tencentCaptchaAppId = ref('') const siteName = ref('Sub2API') const linuxdoOAuthEnabled = ref(false) const wechatOAuthEnabled = ref(false) @@ -394,6 +407,12 @@ const showAgreementModal = ref(false) // Turnstile const turnstileRef = ref | null>(null) const turnstileToken = ref('') +const tencentCaptchaRandstr = ref('') +const captchaEnabled = computed( + () => + (turnstileEnabled.value && Boolean(turnstileSiteKey.value)) || + (tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value)) +) // Promo code validation const promoValidating = ref(false) @@ -484,6 +503,8 @@ onMounted(async () => { affiliateEnabled.value = settings.affiliate_enabled turnstileEnabled.value = settings.turnstile_enabled turnstileSiteKey.value = settings.turnstile_site_key || '' + tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true + tencentCaptchaAppId.value = settings.tencent_captcha_app_id || '' siteName.value = settings.site_name || 'Sub2API' linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled wechatOAuthEnabled.value = isWeChatWebOAuthEnabled(settings) @@ -732,21 +753,74 @@ function getInvitationErrorMessage(errorCode?: string): string { // ==================== Turnstile Handlers ==================== -function onTurnstileVerify(token: string): void { +function onTurnstileVerify(token: string, randstr = ''): void { turnstileToken.value = token + tencentCaptchaRandstr.value = randstr errors.turnstile = '' } function onTurnstileExpire(): void { turnstileToken.value = '' + tencentCaptchaRandstr.value = '' errors.turnstile = t('auth.turnstileExpired') } function onTurnstileError(): void { turnstileToken.value = '' + tencentCaptchaRandstr.value = '' errors.turnstile = t('auth.turnstileFailed') } +function resetCaptchaProof(): void { + turnstileRef.value?.reset() + turnstileToken.value = '' + tencentCaptchaRandstr.value = '' + errors.turnstile = '' +} + +async function acquireTencentProof(): Promise { + if (!tencentCaptchaEnabled.value) return true + + const proof = await turnstileRef.value?.verifyTencent() + if (!proof) return false + + turnstileToken.value = proof.ticket + tencentCaptchaRandstr.value = proof.randstr + return true +} + +async function handleOAuthStart(request: OAuthLoginStart): Promise { + if (registrationActionDisabled.value) return + + if (!tencentCaptchaEnabled.value) { + window.location.href = buildOAuthLoginStartURL(request) + return + } + + isLoading.value = true + try { + const proof = await turnstileRef.value?.verifyTencent() + if (!proof) return + + const result = await startOAuthLogin(request, { + tencent_captcha_ticket: proof.ticket, + tencent_captcha_randstr: proof.randstr + }) + window.location.href = result.authorize_url + } catch (error: unknown) { + errorMessage.value = extractI18nErrorMessage( + error, + t, + 'auth.errors', + t('auth.turnstileFailed') + ) + appStore.showError(errorMessage.value) + } finally { + resetCaptchaProof() + isLoading.value = false + } +} + // ==================== Validation ==================== function validateEmail(email: string): boolean { @@ -876,6 +950,10 @@ async function handleRegister(): Promise { } } + if (!(await acquireTencentProof())) { + return + } + isLoading.value = true try { @@ -892,7 +970,9 @@ async function handleRegister(): Promise { JSON.stringify({ email: formData.email, password: formData.password, - turnstile_token: turnstileToken.value, + turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined, + tencent_captcha_ticket: tencentCaptchaEnabled.value ? turnstileToken.value : undefined, + tencent_captcha_randstr: tencentCaptchaEnabled.value ? tencentCaptchaRandstr.value : undefined, promo_code: formData.promo_code || undefined, invitation_code: formData.invitation_code || undefined, ...(affCode ? { aff_code: affCode } : {}) @@ -909,6 +989,8 @@ async function handleRegister(): Promise { email: formData.email, password: formData.password, turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined, + tencent_captcha_ticket: tencentCaptchaEnabled.value ? turnstileToken.value : undefined, + tencent_captcha_randstr: tencentCaptchaEnabled.value ? tencentCaptchaRandstr.value : undefined, promo_code: formData.promo_code || undefined, invitation_code: formData.invitation_code || undefined, ...(affCode ? { aff_code: affCode } : {}) @@ -921,12 +1003,6 @@ async function handleRegister(): Promise { // Redirect to dashboard await router.push('/dashboard') } catch (error: unknown) { - // Reset Turnstile on error - if (turnstileRef.value) { - turnstileRef.value.reset() - turnstileToken.value = '' - } - // Handle registration error errorMessage.value = buildAuthErrorMessage(error, { fallback: t('auth.registrationFailed') @@ -935,6 +1011,9 @@ async function handleRegister(): Promise { // Also show error toast appStore.showError(errorMessage.value) } finally { + if (captchaEnabled.value) { + resetCaptchaProof() + } isLoading.value = false } } diff --git a/frontend/src/views/auth/WechatCallbackView.vue b/frontend/src/views/auth/WechatCallbackView.vue index afa5c5ab1e..e67b5dde44 100644 --- a/frontend/src/views/auth/WechatCallbackView.vue +++ b/frontend/src/views/auth/WechatCallbackView.vue @@ -562,7 +562,7 @@ function resolveWeChatStartURL(intent: 'bind_current_user' | 'adopt_existing_use intent, }) - return `${normalized}/auth/oauth/wechat/start?${params.toString()}` + return `${normalized}/auth/oauth/wechat/bind/start?${params.toString()}` } function buildExistingAccountResumePath(): string | null { @@ -915,6 +915,13 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) { email: payload.email, password: payload.password, verify_code: payload.verifyCode || undefined, + ...(payload.turnstileToken ? { turnstile_token: payload.turnstileToken } : {}), + ...(payload.tencentCaptchaTicket + ? { + tencent_captcha_ticket: payload.tencentCaptchaTicket, + tencent_captcha_randstr: payload.tencentCaptchaRandstr + } + : {}), invitation_code: payload.invitationCode || undefined, ...oauthAffiliatePayload(loadOAuthAffiliateCode()), ...serializeAdoptionDecision(currentAdoptionDecision()) diff --git a/frontend/src/views/auth/__tests__/EmailVerifyView.spec.ts b/frontend/src/views/auth/__tests__/EmailVerifyView.spec.ts index 0f2d87e4a0..a4ebb27bc4 100644 --- a/frontend/src/views/auth/__tests__/EmailVerifyView.spec.ts +++ b/frontend/src/views/auth/__tests__/EmailVerifyView.spec.ts @@ -1,3 +1,4 @@ +import { defineComponent, h } from 'vue' import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import EmailVerifyView from '@/views/auth/EmailVerifyView.vue' @@ -16,6 +17,8 @@ const { persistOAuthTokenContextMock, apiClientPostMock, authStoreState, + createTurnstileResetMock, + verifyTencentMock, } = vi.hoisted(() => ({ pushMock: vi.fn(), showSuccessMock: vi.fn(), @@ -29,6 +32,8 @@ const { sendPendingOAuthVerifyCodeMock: vi.fn(), persistOAuthTokenContextMock: vi.fn(), apiClientPostMock: vi.fn(), + createTurnstileResetMock: vi.fn(), + verifyTencentMock: vi.fn(), authStoreState: { pendingAuthSession: null as null | { token: string @@ -110,6 +115,8 @@ describe('EmailVerifyView', () => { sendPendingOAuthVerifyCodeMock.mockReset() persistOAuthTokenContextMock.mockReset() apiClientPostMock.mockReset() + createTurnstileResetMock.mockReset() + verifyTencentMock.mockReset() authStoreState.pendingAuthSession = null sessionStorage.clear() localStorage.clear() @@ -125,6 +132,67 @@ describe('EmailVerifyView', () => { setTokenMock.mockResolvedValue({}) }) + it('acquires a fresh Tencent proof for each resend action', async () => { + getPublicSettingsMock.mockResolvedValue({ + turnstile_enabled: false, + turnstile_site_key: '', + tencent_captcha_enabled: true, + tencent_captcha_app_id: 'tencent-app-id', + site_name: 'Sub2API', + registration_email_suffix_whitelist: [], + }) + sendVerifyCodeMock.mockResolvedValue({ countdown: 0 }) + verifyTencentMock + .mockResolvedValueOnce({ ticket: 'ticket-1', randstr: '@rand-1' }) + .mockResolvedValueOnce({ ticket: 'ticket-2', randstr: '@rand-2' }) + sessionStorage.setItem( + 'register_data', + JSON.stringify({ + email: 'fresh@example.com', + password: 'secret-123', + tencent_captcha_ticket: 'initial-ticket', + tencent_captcha_randstr: '@initial-rand', + }) + ) + + const CaptchaChallengeStub = defineComponent({ + setup(_, { expose }) { + expose({ verifyTencent: verifyTencentMock, reset: createTurnstileResetMock }) + return () => h('div') + }, + }) + const wrapper = mount(EmailVerifyView, { + global: { + stubs: { + AuthLayout: { template: '
' }, + Icon: true, + TurnstileWidget: CaptchaChallengeStub, + transition: false, + }, + }, + }) + + await flushPromises() + const resendButton = () => wrapper.findAll('button').find((button) => + button.text().includes('auth.clickToResend') + )! + + await resendButton().trigger('click') + await flushPromises() + await resendButton().trigger('click') + await flushPromises() + + expect(verifyTencentMock).toHaveBeenCalledTimes(2) + expect(sendVerifyCodeMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ + tencent_captcha_ticket: 'ticket-1', + tencent_captcha_randstr: '@rand-1', + })) + expect(sendVerifyCodeMock).toHaveBeenNthCalledWith(3, expect.objectContaining({ + tencent_captcha_ticket: 'ticket-2', + tencent_captcha_randstr: '@rand-2', + })) + }) + it('uses the pending oauth verify-code endpoint when register data carries a pending auth session', async () => { authStoreState.pendingAuthSession = { token: 'pending-token-1', @@ -160,6 +228,44 @@ describe('EmailVerifyView', () => { expect(sendVerifyCodeMock).not.toHaveBeenCalled() }) + it('requires a fresh captcha proof after the initial send-code request fails', async () => { + getPublicSettingsMock.mockResolvedValue({ + turnstile_enabled: true, + turnstile_site_key: 'site-key', + site_name: 'Sub2API', + registration_email_suffix_whitelist: [], + }) + sendVerifyCodeMock.mockRejectedValue(new Error('send failed')) + sessionStorage.setItem( + 'register_data', + JSON.stringify({ + email: 'fresh@example.com', + password: 'secret-123', + turnstile_token: 'initial-proof', + }) + ) + + const wrapper = mount(EmailVerifyView, { + global: { + stubs: { + AuthLayout: { template: '
' }, + Icon: true, + TurnstileWidget: { + template: '', + }, + transition: false, + }, + }, + }) + + await flushPromises() + + expect(sendVerifyCodeMock).toHaveBeenCalledWith(expect.objectContaining({ + turnstile_token: 'initial-proof', + })) + expect(wrapper.find('[data-testid="resend-captcha"]').exists()).toBe(true) + }) + it('skips the registration email suffix whitelist for pending oauth verification', async () => { authStoreState.pendingAuthSession = { token: 'pending-token-2', @@ -350,6 +456,135 @@ describe('EmailVerifyView', () => { expect(registerMock).not.toHaveBeenCalled() }) + it('requires and submits a fresh turnstile token for pending oauth account creation', async () => { + authStoreState.pendingAuthSession = { + token: 'pending-token-3', + token_field: 'pending_auth_token', + provider: 'oidc', + redirect: '/profile', + } + getPublicSettingsMock.mockResolvedValue({ + turnstile_enabled: true, + turnstile_site_key: 'site-key', + site_name: 'Sub2API', + registration_email_suffix_whitelist: ['allowed.com'], + }) + sessionStorage.setItem( + 'register_data', + JSON.stringify({ + email: 'fresh@example.com', + password: 'secret-123', + turnstile_token: 'send-code-token', + }) + ) + apiClientPostMock.mockResolvedValue({ + data: { + access_token: 'oauth-access-token', + refresh_token: 'oauth-refresh-token', + expires_in: 3600, + token_type: 'Bearer', + }, + }) + + const wrapper = mount(EmailVerifyView, { + global: { + stubs: { + AuthLayout: { template: '
' }, + Icon: true, + TurnstileWidget: { + template: '', + methods: { + reset: createTurnstileResetMock, + }, + }, + transition: false, + }, + }, + }) + + await flushPromises() + + expect(sendPendingOAuthVerifyCodeMock).toHaveBeenCalledWith({ + email: 'fresh@example.com', + pending_auth_token: 'pending-token-3', + turnstile_token: 'send-code-token', + }) + + await wrapper.get('#code').setValue('123456') + expect(wrapper.get('button[type="submit"]').attributes('disabled')).toBeDefined() + + await wrapper.get('[data-testid="create-turnstile"]').trigger('click') + expect(wrapper.get('button[type="submit"]').attributes('disabled')).toBeUndefined() + + await wrapper.get('form').trigger('submit.prevent') + await flushPromises() + + expect(apiClientPostMock).toHaveBeenCalledWith('/auth/oauth/pending/create-account', { + email: 'fresh@example.com', + password: 'secret-123', + verify_code: '123456', + turnstile_token: 'create-token', + }) + expect(setTokenMock).toHaveBeenCalledWith('oauth-access-token') + }) + + it('resets the pending oauth create-account turnstile after submit failure', async () => { + authStoreState.pendingAuthSession = { + token: 'pending-token-4', + token_field: 'pending_auth_token', + provider: 'oidc', + redirect: '/profile', + } + getPublicSettingsMock.mockResolvedValue({ + turnstile_enabled: true, + turnstile_site_key: 'site-key', + site_name: 'Sub2API', + registration_email_suffix_whitelist: ['allowed.com'], + }) + sessionStorage.setItem( + 'register_data', + JSON.stringify({ + email: 'fresh@example.com', + password: 'secret-123', + turnstile_token: 'send-code-token', + }) + ) + apiClientPostMock.mockRejectedValue(new Error('invalid verify code')) + + const wrapper = mount(EmailVerifyView, { + global: { + stubs: { + AuthLayout: { template: '
' }, + Icon: true, + TurnstileWidget: { + template: '', + methods: { + reset: createTurnstileResetMock, + }, + }, + transition: false, + }, + }, + }) + + await flushPromises() + await wrapper.get('#code').setValue('123456') + await wrapper.get('[data-testid="create-turnstile"]').trigger('click') + expect(wrapper.get('button[type="submit"]').attributes('disabled')).toBeUndefined() + + await wrapper.get('form').trigger('submit.prevent') + await flushPromises() + + expect(apiClientPostMock).toHaveBeenCalledWith('/auth/oauth/pending/create-account', { + email: 'fresh@example.com', + password: 'secret-123', + verify_code: '123456', + turnstile_token: 'create-token', + }) + expect(createTurnstileResetMock).toHaveBeenCalled() + expect(wrapper.get('button[type="submit"]').attributes('disabled')).toBeDefined() + }) + it('returns to the oauth callback flow when pending account creation becomes bind-login', async () => { authStoreState.pendingAuthSession = { token: '', @@ -447,10 +682,70 @@ describe('EmailVerifyView', () => { password: 'secret-456', verify_code: '654321', turnstile_token: undefined, + tencent_captcha_ticket: undefined, + tencent_captcha_randstr: undefined, promo_code: 'PROMO', invitation_code: 'INVITE', }) expect(apiClientPostMock).not.toHaveBeenCalled() expect(pushMock).toHaveBeenCalledWith('/dashboard') }) + + it('does not require another Tencent proof for final email registration', async () => { + getPublicSettingsMock.mockResolvedValue({ + turnstile_enabled: false, + turnstile_site_key: '', + tencent_captcha_enabled: true, + tencent_captcha_app_id: 'tencent-app-id', + site_name: 'Sub2API', + registration_email_suffix_whitelist: [], + }) + sessionStorage.setItem( + 'register_data', + JSON.stringify({ + email: 'normal@example.com', + password: 'secret-456', + tencent_captcha_ticket: 'send-code-ticket', + tencent_captcha_randstr: '@send-code-rand', + }) + ) + registerMock.mockResolvedValue({}) + + const wrapper = mount(EmailVerifyView, { + global: { + stubs: { + AuthLayout: { template: '
' }, + Icon: true, + TurnstileWidget: { + template: '', + methods: { + reset: createTurnstileResetMock, + }, + }, + transition: false, + }, + }, + }) + + await flushPromises() + expect(sendVerifyCodeMock).toHaveBeenCalledWith(expect.objectContaining({ + tencent_captcha_ticket: 'send-code-ticket', + tencent_captcha_randstr: '@send-code-rand', + })) + expect(JSON.parse(sessionStorage.getItem('register_data') || '{}')).toEqual({ + email: 'normal@example.com', + password: 'secret-456', + }) + + await wrapper.get('#code').setValue('654321') + await wrapper.get('form').trigger('submit.prevent') + await flushPromises() + + expect(registerMock).toHaveBeenCalledWith(expect.objectContaining({ + email: 'normal@example.com', + verify_code: '654321', + tencent_captcha_ticket: undefined, + tencent_captcha_randstr: undefined, + })) + }) }) diff --git a/frontend/src/views/auth/__tests__/TencentCaptchaActionGate.spec.ts b/frontend/src/views/auth/__tests__/TencentCaptchaActionGate.spec.ts new file mode 100644 index 0000000000..a9056eab6c --- /dev/null +++ b/frontend/src/views/auth/__tests__/TencentCaptchaActionGate.spec.ts @@ -0,0 +1,229 @@ +import { defineComponent, h } from 'vue' +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import LoginView from '@/views/auth/LoginView.vue' + +const loginMock = vi.fn() +const loginWithPasskeyMock = vi.fn() +const getPublicSettingsMock = vi.fn() +const startOAuthLoginMock = vi.fn() +const verifyTencentMock = vi.fn() +const captchaResetMock = vi.fn() +const locationState = { href: 'http://localhost/login' } + +vi.mock('vue-router', () => ({ + useRouter: () => ({ + currentRoute: { value: { query: {} } }, + push: vi.fn() + }) +})) + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ + t: (key: string) => key + }) + } +}) + +vi.mock('@/stores', () => ({ + useAuthStore: () => ({ + login: (...args: unknown[]) => loginMock(...args), + loginWithPasskey: (...args: unknown[]) => loginWithPasskeyMock(...args) + }), + useAppStore: () => ({ + showError: vi.fn(), + showSuccess: vi.fn(), + showWarning: vi.fn() + }) +})) + +vi.mock('@/api/auth', async () => { + const actual = await vi.importActual('@/api/auth') + return { + ...actual, + getPublicSettings: (...args: unknown[]) => getPublicSettingsMock(...args), + startOAuthLogin: (...args: unknown[]) => startOAuthLoginMock(...args), + isTotp2FARequired: () => false, + isWeChatWebOAuthEnabled: () => false + } +}) + +const CaptchaChallengeStub = defineComponent({ + setup(_, { expose }) { + expose({ + verifyTencent: verifyTencentMock, + reset: captchaResetMock + }) + return () => h('div') + } +}) + +const OAuthButtonStub = defineComponent({ + emits: ['start'], + setup(_, { emit }) { + return () => h('button', { + type: 'button', + 'data-testid': 'oauth-start', + onClick: () => emit('start', { + provider: 'github', + params: { redirect: '/dashboard' } + }) + }) + } +}) + +function mountLogin() { + return mount(LoginView, { + global: { + stubs: { + AuthLayout: { template: '
' }, + RouterLink: true, + TurnstileWidget: CaptchaChallengeStub, + Icon: true, + LoginAgreementPrompt: true, + TotpLoginModal: true, + EmailOAuthButtons: OAuthButtonStub, + LinuxDoOAuthSection: true, + DingTalkOAuthSection: true, + OidcOAuthSection: true, + WechatOAuthSection: true + } + } + }) +} + +describe('Tencent captcha action gate', () => { + beforeEach(() => { + loginMock.mockReset() + loginWithPasskeyMock.mockReset() + getPublicSettingsMock.mockReset() + startOAuthLoginMock.mockReset() + verifyTencentMock.mockReset() + captchaResetMock.mockReset() + getPublicSettingsMock.mockResolvedValue({ + turnstile_enabled: false, + turnstile_site_key: '', + tencent_captcha_enabled: true, + tencent_captcha_app_id: 'tencent-app-id', + backend_mode_enabled: false, + password_reset_enabled: false, + passkey_enabled: true, + github_oauth_enabled: true, + google_oauth_enabled: false + }) + loginMock.mockResolvedValue({}) + loginWithPasskeyMock.mockResolvedValue({}) + startOAuthLoginMock.mockResolvedValue({ authorize_url: 'https://github.example/authorize' }) + verifyTencentMock.mockResolvedValue({ ticket: 'ticket-1', randstr: '@rand-1' }) + Object.defineProperty(window, 'PublicKeyCredential', { + configurable: true, + value: class PublicKeyCredential {} + }) + locationState.href = 'http://localhost/login' + Object.defineProperty(window, 'location', { + configurable: true, + value: locationState + }) + }) + + it('clicking login opens Tencent captcha before calling login', async () => { + const wrapper = mountLogin() + await flushPromises() + await wrapper.get('#email').setValue('user@example.com') + await wrapper.get('#password').setValue('secret-123') + + await wrapper.get('form').trigger('submit') + await flushPromises() + + expect(verifyTencentMock).toHaveBeenCalledOnce() + expect(loginMock).toHaveBeenCalledWith(expect.objectContaining({ + tencent_captcha_ticket: 'ticket-1', + tencent_captcha_randstr: '@rand-1' + })) + }) + + it('does not call login when Tencent captcha is closed', async () => { + verifyTencentMock.mockResolvedValue(null) + const wrapper = mountLogin() + await flushPromises() + await wrapper.get('#email').setValue('user@example.com') + await wrapper.get('#password').setValue('secret-123') + + await wrapper.get('form').trigger('submit') + await flushPromises() + + expect(verifyTencentMock).toHaveBeenCalledOnce() + expect(loginMock).not.toHaveBeenCalled() + }) + + it('does not open Tencent captcha when login form validation fails', async () => { + const wrapper = mountLogin() + await flushPromises() + + await wrapper.get('form').trigger('submit') + await flushPromises() + + expect(verifyTencentMock).not.toHaveBeenCalled() + expect(loginMock).not.toHaveBeenCalled() + }) + + it('starts OAuth through the Tencent gate before navigating', async () => { + const wrapper = mountLogin() + await flushPromises() + + await wrapper.get('[data-testid="oauth-start"]').trigger('click') + await flushPromises() + + expect(verifyTencentMock).toHaveBeenCalledOnce() + expect(startOAuthLoginMock).toHaveBeenCalledWith( + { provider: 'github', params: { redirect: '/dashboard' } }, + { + tencent_captcha_ticket: 'ticket-1', + tencent_captcha_randstr: '@rand-1' + } + ) + expect(locationState.href).toBe('https://github.example/authorize') + expect(captchaResetMock).toHaveBeenCalledOnce() + }) + + it('does not start OAuth when Tencent captcha is closed', async () => { + verifyTencentMock.mockResolvedValue(null) + const wrapper = mountLogin() + await flushPromises() + + await wrapper.get('[data-testid="oauth-start"]').trigger('click') + await flushPromises() + + expect(startOAuthLoginMock).not.toHaveBeenCalled() + expect(locationState.href).toBe('http://localhost/login') + }) + + it('passes a fresh Tencent proof to Passkey login', async () => { + const wrapper = mountLogin() + await flushPromises() + + await wrapper.get('button.btn-secondary.w-full').trigger('click') + await flushPromises() + + expect(verifyTencentMock).toHaveBeenCalledOnce() + expect(loginWithPasskeyMock).toHaveBeenCalledWith({ + tencent_captcha_ticket: 'ticket-1', + tencent_captcha_randstr: '@rand-1' + }) + expect(captchaResetMock).toHaveBeenCalledOnce() + }) + + it('does not invoke Passkey when Tencent captcha is closed', async () => { + verifyTencentMock.mockResolvedValue(null) + const wrapper = mountLogin() + await flushPromises() + + await wrapper.get('button.btn-secondary.w-full').trigger('click') + await flushPromises() + + expect(loginWithPasskeyMock).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts b/frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts index 77897514fe..80d95236c8 100644 --- a/frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts +++ b/frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts @@ -617,6 +617,7 @@ describe('WechatCallbackView', () => { await wrapper.get('[data-testid="existing-account-submit"]').trigger('click') expect(prepareOAuthBindAccessTokenCookieMock).toHaveBeenCalledTimes(1) + expect(locationState.current.href).toContain('/api/v1/auth/oauth/wechat/bind/start?') expect(locationState.current.href).toContain('intent=bind_current_user') expect(locationState.current.href).toContain('redirect=%2Fusage') expect(locationState.current.href).toContain('mode=open') @@ -1052,7 +1053,7 @@ describe('WechatCallbackView', () => { expect(exchangePendingOAuthCompletionMock).not.toHaveBeenCalled() expect(prepareOAuthBindAccessTokenCookieMock).toHaveBeenCalledTimes(1) - expect(locationState.current.href).toContain('/api/v1/auth/oauth/wechat/start?') + expect(locationState.current.href).toContain('/api/v1/auth/oauth/wechat/bind/start?') expect(locationState.current.href).toContain('mode=mp') expect(locationState.current.href).toContain('intent=bind_current_user') expect(locationState.current.href).toContain('redirect=%2Fprofile')