mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 13:48:43 +08:00
新增腾讯天御验证码认证门禁
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ─────────────────────────────────────────────
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 回调:创建/登录用户,然后重定向到前端。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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、创建/登录用户并重定向到前端。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -149,6 +149,7 @@ var ProviderSet = wire.NewSet(
|
||||
|
||||
// HTTP service ports (DI Strategy A: return interface directly)
|
||||
NewTurnstileVerifier,
|
||||
NewTencentCaptchaVerifier,
|
||||
ProvidePricingRemoteClient,
|
||||
ProvideGitHubReleaseClient,
|
||||
NewProxyExitInfoProber,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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-") {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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验证
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 数组)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(避免老部署因为未迁移到数据库设置而被意外关闭)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 证书验证(仅用于调试)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, string>
|
||||
}
|
||||
|
||||
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<OAuthLoginStartResponse> {
|
||||
const { data } = await apiClient.post<OAuthLoginStartResponse>(
|
||||
`/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<ValidateInvi
|
||||
export interface ForgotPasswordRequest {
|
||||
email: string
|
||||
turnstile_token?: string
|
||||
tencent_captcha_ticket?: string
|
||||
tencent_captcha_randstr?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { apiClient } from './client'
|
||||
import type { AuthResponse } from '@/types'
|
||||
import type { AuthResponse, TencentCaptchaRequestProof } from '@/types'
|
||||
|
||||
export interface PasskeyCredentialSummary {
|
||||
id: number
|
||||
@@ -104,11 +104,11 @@ function serializeAssertionCredential(credential: PublicKeyCredential): Record<s
|
||||
}
|
||||
}
|
||||
|
||||
async function login(): Promise<AuthResponse> {
|
||||
async function login(proof?: TencentCaptchaRequestProof): Promise<AuthResponse> {
|
||||
requirePasskeySupport()
|
||||
const { data: begin } = await apiClient.post<CeremonyOptionsResponse>(
|
||||
'/auth/passkey/login/begin'
|
||||
)
|
||||
const { data: begin } = proof
|
||||
? await apiClient.post<CeremonyOptionsResponse>('/auth/passkey/login/begin', proof)
|
||||
: await apiClient.post<CeremonyOptionsResponse>('/auth/passkey/login/begin')
|
||||
const credential = await navigator.credentials.get({
|
||||
publicKey: requestOptionsFromJSON(begin.options.publicKey)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<TurnstileWidget
|
||||
v-if="turnstileEnabled && turnstileSiteKey"
|
||||
ref="turnstileRef"
|
||||
:site-key="turnstileSiteKey"
|
||||
@verify="(token) => emit('verify', token, '')"
|
||||
@expire="emit('expire')"
|
||||
@error="emit('error')"
|
||||
/>
|
||||
<TencentCaptchaGate
|
||||
v-else-if="tencentEnabled && tencentAppId"
|
||||
ref="tencentRef"
|
||||
:app-id="tencentAppId"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
import TencentCaptchaGate from '@/components/TencentCaptchaGate.vue'
|
||||
import type { TencentCaptchaProof } from '@/utils/tencentCaptcha'
|
||||
|
||||
const props = defineProps<{
|
||||
siteKey?: string
|
||||
turnstileEnabled: boolean
|
||||
turnstileSiteKey: string
|
||||
tencentEnabled: boolean
|
||||
tencentAppId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
verify: [tokenOrTicket: string, randstr: string]
|
||||
expire: []
|
||||
error: []
|
||||
}>()
|
||||
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||
const tencentRef = ref<InstanceType<typeof TencentCaptchaGate> | null>(null)
|
||||
|
||||
function reset(): void {
|
||||
turnstileRef.value?.reset()
|
||||
tencentRef.value?.reset()
|
||||
}
|
||||
|
||||
async function verifyTencent(): Promise<TencentCaptchaProof | null> {
|
||||
if (!props.tencentEnabled || !props.tencentAppId) return null
|
||||
try {
|
||||
return (await tencentRef.value?.verify()) ?? null
|
||||
} catch {
|
||||
emit('error')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ reset, verifyTencent })
|
||||
</script>
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<span v-if="false" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
loadTencentCaptcha,
|
||||
type TencentCaptchaProof,
|
||||
type TencentCaptchaResult
|
||||
} from '@/utils/tencentCaptcha'
|
||||
|
||||
const { locale } = useI18n()
|
||||
const props = defineProps<{ appId: string }>()
|
||||
|
||||
let instance: { show(): void; destroy(): void } | null = null
|
||||
let pending: Promise<TencentCaptchaProof | null> | null = null
|
||||
let cancelPending: (() => void) | null = null
|
||||
|
||||
function createVerificationPromise(): Promise<TencentCaptchaProof | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
|
||||
const finish = (callback: () => void): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (cancelPending === cancel) cancelPending = null
|
||||
instance?.destroy()
|
||||
instance = null
|
||||
callback()
|
||||
}
|
||||
const cancel = (): void => finish(() => resolve(null))
|
||||
|
||||
cancelPending = cancel
|
||||
void loadTencentCaptcha()
|
||||
.then((TencentCaptcha) => {
|
||||
if (cancelPending !== cancel) return
|
||||
|
||||
const userLanguage = locale.value.toLowerCase().startsWith('zh') ? 'zh-cn' : 'en'
|
||||
instance = new TencentCaptcha(props.appId, (result: TencentCaptchaResult) => {
|
||||
if (result.ret === 2) {
|
||||
finish(() => resolve(null))
|
||||
return
|
||||
}
|
||||
|
||||
const ticket = result.ticket?.trim() || ''
|
||||
const randstr = result.randstr?.trim() || ''
|
||||
if (!ticket || !randstr || ticket.startsWith('trerror_') || result.errorCode !== undefined) {
|
||||
finish(() => reject(new Error('Tencent Captcha verification failed')))
|
||||
return
|
||||
}
|
||||
|
||||
finish(() => resolve({ ticket, randstr }))
|
||||
}, { userLanguage })
|
||||
instance.show()
|
||||
})
|
||||
.catch((error: unknown) => finish(() => reject(error)))
|
||||
})
|
||||
}
|
||||
|
||||
function verify(): Promise<TencentCaptchaProof | null> {
|
||||
if (pending) return pending
|
||||
pending = createVerificationPromise().finally(() => {
|
||||
pending = null
|
||||
})
|
||||
return pending
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
instance?.destroy()
|
||||
instance = null
|
||||
cancelPending?.()
|
||||
cancelPending = null
|
||||
}
|
||||
|
||||
onBeforeUnmount(reset)
|
||||
defineExpose({ verify, reset })
|
||||
</script>
|
||||
@@ -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<HTMLScriptElement>('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()
|
||||
})
|
||||
})
|
||||
@@ -37,6 +37,7 @@
|
||||
<script setup lang="ts">
|
||||
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<{
|
||||
@@ -46,6 +47,9 @@ const props = withDefaults(defineProps<{
|
||||
}>(), {
|
||||
showDivider: true
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
start: [request: OAuthLoginStart]
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
@@ -53,9 +57,6 @@ const { t } = useI18n()
|
||||
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/dingtalk/start?redirect=${encodeURIComponent(redirectTo)}`
|
||||
window.location.href = startURL
|
||||
emit('start', { provider: 'dingtalk', params: { redirect: redirectTo } })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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<string, string> = { 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 })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
<script setup lang="ts">
|
||||
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<{
|
||||
@@ -51,6 +52,9 @@ const props = withDefaults(defineProps<{
|
||||
}>(), {
|
||||
showDivider: true
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
start: [request: OAuthLoginStart]
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
@@ -58,9 +62,6 @@ const { t } = useI18n()
|
||||
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/linuxdo/start?redirect=${encodeURIComponent(redirectTo)}`
|
||||
window.location.href = startURL
|
||||
emit('start', { provider: 'linuxdo', params: { redirect: redirectTo } })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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 } })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -16,10 +16,14 @@
|
||||
:placeholder="t('auth.passwordPlaceholder')"
|
||||
:disabled="isSubmitting"
|
||||
/>
|
||||
<div v-if="emailVerifyEnabled && turnstileEnabled && turnstileSiteKey" class="space-y-2">
|
||||
<div v-if="captchaEnabled" class="space-y-2">
|
||||
<TurnstileWidget
|
||||
ref="turnstileRef"
|
||||
:site-key="turnstileSiteKey"
|
||||
:turnstile-enabled="turnstileEnabled"
|
||||
:turnstile-site-key="turnstileSiteKey"
|
||||
:tencent-enabled="tencentCaptchaEnabled"
|
||||
:tencent-app-id="tencentCaptchaAppId"
|
||||
@verify="onTurnstileVerify"
|
||||
@expire="onTurnstileExpire"
|
||||
@error="onTurnstileError"
|
||||
@@ -88,9 +92,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
import TurnstileWidget from '@/components/CaptchaChallenge.vue'
|
||||
import { getPublicSettings, sendPendingOAuthVerifyCode } from '@/api/auth'
|
||||
import { useAppStore } from '@/stores'
|
||||
|
||||
@@ -98,6 +102,9 @@ export type PendingOAuthCreateAccountPayload = {
|
||||
email: string
|
||||
password: string
|
||||
verifyCode: string
|
||||
turnstileToken?: string
|
||||
tencentCaptchaTicket?: string
|
||||
tencentCaptchaRandstr?: string
|
||||
invitationCode?: string
|
||||
}
|
||||
|
||||
@@ -128,8 +135,16 @@ const invitationCodeEnabled = ref(false)
|
||||
const emailVerifyEnabled = ref(true)
|
||||
const turnstileEnabled = ref(false)
|
||||
const turnstileSiteKey = ref('')
|
||||
const tencentCaptchaEnabled = ref(false)
|
||||
const tencentCaptchaAppId = ref('')
|
||||
const turnstileToken = ref('')
|
||||
const tencentCaptchaRandstr = ref('')
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||
const captchaEnabled = computed(
|
||||
() =>
|
||||
(turnstileEnabled.value && Boolean(turnstileSiteKey.value)) ||
|
||||
(tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value))
|
||||
)
|
||||
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
@@ -152,6 +167,9 @@ watch(
|
||||
value => {
|
||||
if (value) {
|
||||
appStore.showError(value)
|
||||
if (captchaEnabled.value) {
|
||||
resetTurnstile()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -189,24 +207,39 @@ function getRequestErrorMessage(error: unknown, fallback: string): string {
|
||||
|
||||
function resetTurnstile() {
|
||||
turnstileToken.value = ''
|
||||
tencentCaptchaRandstr.value = ''
|
||||
turnstileRef.value?.reset()
|
||||
}
|
||||
|
||||
function onTurnstileVerify(token: string) {
|
||||
function onTurnstileVerify(token: string, randstr = '') {
|
||||
turnstileToken.value = token
|
||||
tencentCaptchaRandstr.value = randstr
|
||||
sendCodeError.value = ''
|
||||
}
|
||||
|
||||
function onTurnstileExpire() {
|
||||
turnstileToken.value = ''
|
||||
tencentCaptchaRandstr.value = ''
|
||||
sendCodeError.value = t('auth.turnstileExpired')
|
||||
}
|
||||
|
||||
function onTurnstileError() {
|
||||
turnstileToken.value = ''
|
||||
tencentCaptchaRandstr.value = ''
|
||||
sendCodeError.value = t('auth.turnstileFailed')
|
||||
}
|
||||
|
||||
async function acquireTencentProof(): Promise<boolean> {
|
||||
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 handleSendCode() {
|
||||
const trimmedEmail = email.value.trim()
|
||||
if (!trimmedEmail) {
|
||||
@@ -218,6 +251,10 @@ async function handleSendCode() {
|
||||
return
|
||||
}
|
||||
|
||||
if (!(await acquireTencentProof())) {
|
||||
return
|
||||
}
|
||||
|
||||
isSendingCode.value = true
|
||||
sendCodeError.value = ''
|
||||
sendCodeSuccess.value = false
|
||||
@@ -225,32 +262,49 @@ async function handleSendCode() {
|
||||
try {
|
||||
const response = await sendPendingOAuthVerifyCode({
|
||||
email: trimmedEmail,
|
||||
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
|
||||
})
|
||||
sendCodeSuccess.value = true
|
||||
startCountdown(response.countdown)
|
||||
if (turnstileEnabled.value) {
|
||||
resetTurnstile()
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
sendCodeError.value = getRequestErrorMessage(error, t('auth.sendCodeFailed'))
|
||||
} finally {
|
||||
if (captchaEnabled.value) {
|
||||
resetTurnstile()
|
||||
}
|
||||
isSendingCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
async function handleSubmit() {
|
||||
const trimmedEmail = email.value.trim()
|
||||
if (!trimmedEmail || password.value.length < 6) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!(await acquireTencentProof())) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('submit', {
|
||||
email: trimmedEmail,
|
||||
password: password.value,
|
||||
verifyCode: emailVerifyEnabled.value ? verifyCode.value.trim() : '',
|
||||
...(turnstileEnabled.value && turnstileToken.value ? { turnstileToken: turnstileToken.value } : {}),
|
||||
...(tencentCaptchaEnabled.value && turnstileToken.value
|
||||
? {
|
||||
tencentCaptchaTicket: turnstileToken.value,
|
||||
tencentCaptchaRandstr: tencentCaptchaRandstr.value
|
||||
}
|
||||
: {}),
|
||||
invitationCode: invitationCode.value.trim() || undefined
|
||||
})
|
||||
|
||||
if (tencentCaptchaEnabled.value) {
|
||||
resetTurnstile()
|
||||
}
|
||||
}
|
||||
|
||||
function emitSwitchToBind() {
|
||||
@@ -264,11 +318,15 @@ onMounted(async () => {
|
||||
emailVerifyEnabled.value = settings.email_verify_enabled !== false
|
||||
turnstileEnabled.value = settings.turnstile_enabled === true
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true
|
||||
tencentCaptchaAppId.value = settings.tencent_captcha_app_id || ''
|
||||
} catch {
|
||||
invitationCodeEnabled.value = false
|
||||
emailVerifyEnabled.value = true
|
||||
turnstileEnabled.value = false
|
||||
turnstileSiteKey.value = ''
|
||||
tencentCaptchaEnabled.value = false
|
||||
tencentCaptchaAppId.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { resolveWeChatOAuthStart } from '@/api/auth'
|
||||
import { resolveWeChatOAuthStart, type OAuthLoginStart } from '@/api/auth'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
|
||||
|
||||
@@ -42,6 +42,9 @@ const props = withDefaults(defineProps<{
|
||||
}>(), {
|
||||
showDivider: true,
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
start: [request: OAuthLoginStart]
|
||||
}>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
const route = useRoute()
|
||||
@@ -87,10 +90,10 @@ 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 mode = resolvedStart.value.mode
|
||||
const startURL = `${normalized}/auth/oauth/wechat/start?mode=${mode}&redirect=${encodeURIComponent(redirectTo)}`
|
||||
window.location.href = startURL
|
||||
emit('start', {
|
||||
provider: 'wechat',
|
||||
params: { mode, redirect: redirectTo }
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -6,10 +6,6 @@ const routeState = vi.hoisted(() => ({
|
||||
query: {} as Record<string, unknown>,
|
||||
}))
|
||||
|
||||
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: {
|
||||
|
||||
@@ -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<string, unknown>
|
||||
}))
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<typeof import('vue-i18n')>('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: '<button data-testid="turnstile-verify" @click="$emit(\'verify\', \'proof-token\')">verify</button>',
|
||||
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: '<button data-testid="turnstile-verify" @click="$emit(\'verify\', \'turnstile-token\')">verify</button>'
|
||||
template: '<button data-testid="turnstile-verify" @click="$emit(\'verify\', \'turnstile-token\')">verify</button>',
|
||||
methods: { reset: vi.fn() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,6 @@ const routeState = vi.hoisted(() => ({
|
||||
query: {} as Record<string, unknown>,
|
||||
}))
|
||||
|
||||
const locationState = vi.hoisted(() => ({
|
||||
current: { href: 'http://localhost/login' } as { href: string },
|
||||
}))
|
||||
|
||||
let pinia: ReturnType<typeof createPinia>
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 判断',
|
||||
|
||||
@@ -242,6 +242,8 @@ export default {
|
||||
reloginRequired: '会话已过期,请重新登录。',
|
||||
turnstileExpired: '验证已过期,请重试',
|
||||
turnstileFailed: '验证失败,请重试',
|
||||
captchaVerified: '验证已完成',
|
||||
captchaLoading: '正在加载验证码…',
|
||||
completeVerification: '请完成验证',
|
||||
verifyYourEmail: '验证您的邮箱',
|
||||
sessionExpired: '会话已过期',
|
||||
|
||||
@@ -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<User> {
|
||||
async function loginWithPasskey(proof?: TencentCaptchaRequestProof): Promise<User> {
|
||||
try {
|
||||
const response = await passkeyAPI.login()
|
||||
const response = await passkeyAPI.login(proof)
|
||||
setAuthFromResponse(response)
|
||||
return user.value!
|
||||
} catch (error) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown>
|
||||
) => TencentCaptchaInstance
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
TencentCaptcha?: TencentCaptchaConstructor
|
||||
}
|
||||
}
|
||||
|
||||
const SCRIPT_SRC = 'https://turing.captcha.qcloud.com/TJCaptcha.js'
|
||||
let scriptPromise: Promise<TencentCaptchaConstructor> | null = null
|
||||
|
||||
export function loadTencentCaptcha(): Promise<TencentCaptchaConstructor> {
|
||||
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
|
||||
}
|
||||
@@ -1990,7 +1990,11 @@
|
||||
{{ t("admin.settings.turnstile.enableTurnstileHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.turnstile_enabled" />
|
||||
<Toggle
|
||||
v-model="form.turnstile_enabled"
|
||||
data-testid="turnstile-enabled-toggle"
|
||||
@update:model-value="onTurnstileToggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Turnstile Keys - Only show when enabled -->
|
||||
@@ -2050,6 +2054,151 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 腾讯天御验证码设置 -->
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t("admin.settings.tencentCaptcha.title") }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.tencentCaptcha.description") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-5 p-6">
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">
|
||||
{{ t("admin.settings.tencentCaptcha.enable") }}
|
||||
</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.tencentCaptcha.enableHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
v-model="form.tencent_captcha_enabled"
|
||||
data-testid="tencent-captcha-enabled-toggle"
|
||||
@update:model-value="onTencentCaptchaToggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="form.tencent_captcha_enabled"
|
||||
class="border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||
>
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div class="md:col-span-2">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t("admin.settings.tencentCaptcha.appCredentialsTitle") }}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.tencentCaptcha.appCredentialsHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t("admin.settings.tencentCaptcha.appId") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.tencent_captcha_app_id"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
class="input font-mono text-sm"
|
||||
placeholder="123456789"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t("admin.settings.tencentCaptcha.appSecretKey") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.tencent_captcha_app_secret_key"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.tencentCaptcha.keepExisting')"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ form.tencent_captcha_app_secret_key_configured ? t("admin.settings.tencentCaptcha.configured") : t("admin.settings.tencentCaptcha.required") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-t border-gray-100 pt-5 md:col-span-2 dark:border-dark-700">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t("admin.settings.tencentCaptcha.cloudCredentialsTitle") }}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.tencentCaptcha.cloudCredentialsHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t("admin.settings.tencentCaptcha.cloudSecretId") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.tencent_captcha_cloud_secret_id"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.tencentCaptcha.keepExisting')"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ form.tencent_captcha_cloud_secret_id_configured ? t("admin.settings.tencentCaptcha.configured") : t("admin.settings.tencentCaptcha.required") }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t("admin.settings.tencentCaptcha.cloudSecretKey") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.tencent_captcha_cloud_secret_key"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.tencentCaptcha.keepExisting')"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ form.tencent_captcha_cloud_secret_key_configured ? t("admin.settings.tencentCaptcha.configured") : t("admin.settings.tencentCaptcha.required") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-5 text-xs text-amber-600 dark:text-amber-400">
|
||||
{{ t("admin.settings.tencentCaptcha.mutualExclusion") }}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.tencentCaptcha.camPermissionHint") }}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.tencentCaptcha.aidEncryptedHint") }}
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap gap-x-4 gap-y-2 text-sm">
|
||||
<a
|
||||
href="https://console.cloud.tencent.com/captcha"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
{{ t("admin.settings.tencentCaptcha.openCaptchaConsole") }}
|
||||
</a>
|
||||
<a
|
||||
href="https://console.cloud.tencent.com/cam/capi"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
{{ t("admin.settings.tencentCaptcha.createCloudKeys") }}
|
||||
</a>
|
||||
<a
|
||||
href="https://cloud.tencent.com/document/product/1110/36841"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
{{ t("admin.settings.tencentCaptcha.openWebDocs") }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LinuxDo Connect OAuth 登录 -->
|
||||
<div class="card">
|
||||
<div
|
||||
@@ -8779,6 +8928,9 @@ type SettingsForm = Omit<
|
||||
> & {
|
||||
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<SettingsForm>({
|
||||
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<SettingsForm>({
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
|
||||
@@ -67,18 +67,40 @@
|
||||
</div>
|
||||
|
||||
<!-- Turnstile Widget for Resend -->
|
||||
<div v-if="turnstileEnabled && turnstileSiteKey && showResendTurnstile">
|
||||
<div v-if="tencentCaptchaEnabled || (turnstileEnabled && showResendTurnstile)">
|
||||
<TurnstileWidget
|
||||
ref="turnstileRef"
|
||||
:site-key="turnstileSiteKey"
|
||||
:turnstile-enabled="turnstileEnabled"
|
||||
:turnstile-site-key="turnstileSiteKey"
|
||||
:tencent-enabled="tencentCaptchaEnabled"
|
||||
:tencent-app-id="tencentCaptchaAppId"
|
||||
@verify="onTurnstileVerify"
|
||||
@expire="onTurnstileExpire"
|
||||
@error="onTurnstileError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="pendingOAuthCreateCaptchaEnabled" class="space-y-2">
|
||||
<TurnstileWidget
|
||||
ref="createAccountTurnstileRef"
|
||||
:site-key="turnstileSiteKey"
|
||||
:turnstile-enabled="turnstileEnabled"
|
||||
:turnstile-site-key="turnstileSiteKey"
|
||||
:tencent-enabled="tencentCaptchaEnabled"
|
||||
:tencent-app-id="tencentCaptchaAppId"
|
||||
@verify="onCreateAccountTurnstileVerify"
|
||||
@expire="onCreateAccountTurnstileExpire"
|
||||
@error="onCreateAccountTurnstileError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button type="submit" :disabled="isLoading || !verifyCode" class="btn btn-primary w-full">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isLoading || !verifyCode || (pendingOAuthCreateTurnstileRequired && !createAccountTurnstileToken)"
|
||||
class="btn btn-primary w-full"
|
||||
>
|
||||
<svg
|
||||
v-if="isLoading"
|
||||
class="-ml-1 mr-2 h-4 w-4 animate-spin text-white"
|
||||
@@ -123,7 +145,7 @@
|
||||
class="text-sm text-primary-600 transition-colors hover:text-primary-500 disabled:cursor-not-allowed disabled:opacity-50 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
<span v-if="isSendingCode">{{ t('auth.sendingCode') }}</span>
|
||||
<span v-else-if="turnstileEnabled && !showResendTurnstile">
|
||||
<span v-else-if="captchaEnabled && !showResendTurnstile">
|
||||
{{ t('auth.clickToResend') }}
|
||||
</span>
|
||||
<span v-else>{{ t('auth.resendCode') }}</span>
|
||||
@@ -151,7 +173,7 @@ import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
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 {
|
||||
persistOAuthTokenContext,
|
||||
@@ -213,6 +235,7 @@ type PendingOAuthCreateAccountResponse = {
|
||||
const email = ref<string>('')
|
||||
const password = ref<string>('')
|
||||
const initialTurnstileToken = ref<string>('')
|
||||
const initialTencentCaptchaRandstr = ref<string>('')
|
||||
const promoCode = ref<string>('')
|
||||
const invitationCode = ref<string>('')
|
||||
const affCode = ref<string>('')
|
||||
@@ -229,13 +252,24 @@ const hasRegisterData = ref<boolean>(false)
|
||||
// Public settings
|
||||
const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const tencentCaptchaEnabled = ref<boolean>(false)
|
||||
const tencentCaptchaAppId = ref<string>('')
|
||||
const siteName = ref<string>('Sub2API')
|
||||
const registrationEmailSuffixWhitelist = ref<string[]>([])
|
||||
|
||||
// Turnstile for resend
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||
const createAccountTurnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||
const resendTurnstileToken = ref<string>('')
|
||||
const resendTencentCaptchaRandstr = ref<string>('')
|
||||
const createAccountTurnstileToken = ref<string>('')
|
||||
const createAccountTencentCaptchaRandstr = ref<string>('')
|
||||
const showResendTurnstile = ref<boolean>(false)
|
||||
const captchaEnabled = computed(
|
||||
() =>
|
||||
(turnstileEnabled.value && Boolean(turnstileSiteKey.value)) ||
|
||||
(tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value))
|
||||
)
|
||||
|
||||
const errors = ref({
|
||||
code: '',
|
||||
@@ -245,6 +279,12 @@ const errors = ref({
|
||||
const validationToastMessage = computed(
|
||||
() => errors.value.code || errors.value.turnstile || ''
|
||||
)
|
||||
const pendingOAuthCreateTurnstileRequired = computed(
|
||||
() => isPendingOAuthFlow() && turnstileEnabled.value
|
||||
)
|
||||
const pendingOAuthCreateCaptchaEnabled = computed(
|
||||
() => isPendingOAuthFlow() && captchaEnabled.value
|
||||
)
|
||||
|
||||
watch(validationToastMessage, (value, previousValue) => {
|
||||
if (value && value !== previousValue) {
|
||||
@@ -264,7 +304,9 @@ onMounted(async () => {
|
||||
const registerData = JSON.parse(registerDataStr)
|
||||
email.value = registerData.email || ''
|
||||
password.value = registerData.password || ''
|
||||
initialTurnstileToken.value = registerData.turnstile_token || ''
|
||||
initialTurnstileToken.value =
|
||||
registerData.tencent_captcha_ticket || registerData.turnstile_token || ''
|
||||
initialTencentCaptchaRandstr.value = registerData.tencent_captcha_randstr || ''
|
||||
promoCode.value = registerData.promo_code || ''
|
||||
invitationCode.value = registerData.invitation_code || ''
|
||||
affCode.value = registerData.aff_code || loadAffiliateReferralCode()
|
||||
@@ -294,6 +336,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 || ''
|
||||
siteName.value = settings.site_name || 'Sub2API'
|
||||
registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
|
||||
settings.registration_email_suffix_whitelist || []
|
||||
@@ -338,21 +382,70 @@ function startCountdown(seconds: number): void {
|
||||
|
||||
// ==================== Turnstile Handlers ====================
|
||||
|
||||
function onTurnstileVerify(token: string): void {
|
||||
function onTurnstileVerify(token: string, randstr = ''): void {
|
||||
resendTurnstileToken.value = token
|
||||
resendTencentCaptchaRandstr.value = randstr
|
||||
errors.value.turnstile = ''
|
||||
}
|
||||
|
||||
function onTurnstileExpire(): void {
|
||||
resendTurnstileToken.value = ''
|
||||
resendTencentCaptchaRandstr.value = ''
|
||||
errors.value.turnstile = t('auth.turnstileExpired')
|
||||
}
|
||||
|
||||
function onTurnstileError(): void {
|
||||
resendTurnstileToken.value = ''
|
||||
resendTencentCaptchaRandstr.value = ''
|
||||
errors.value.turnstile = t('auth.turnstileFailed')
|
||||
}
|
||||
|
||||
function onCreateAccountTurnstileVerify(token: string, randstr = ''): void {
|
||||
createAccountTurnstileToken.value = token
|
||||
createAccountTencentCaptchaRandstr.value = randstr
|
||||
errors.value.turnstile = ''
|
||||
}
|
||||
|
||||
function onCreateAccountTurnstileExpire(): void {
|
||||
createAccountTurnstileToken.value = ''
|
||||
createAccountTencentCaptchaRandstr.value = ''
|
||||
errors.value.turnstile = t('auth.turnstileExpired')
|
||||
}
|
||||
|
||||
function onCreateAccountTurnstileError(): void {
|
||||
createAccountTurnstileToken.value = ''
|
||||
createAccountTencentCaptchaRandstr.value = ''
|
||||
errors.value.turnstile = t('auth.turnstileFailed')
|
||||
}
|
||||
|
||||
function resetCreateAccountTurnstile(): void {
|
||||
createAccountTurnstileToken.value = ''
|
||||
createAccountTencentCaptchaRandstr.value = ''
|
||||
createAccountTurnstileRef.value?.reset()
|
||||
}
|
||||
|
||||
async function acquireResendTencentProof(): Promise<boolean> {
|
||||
if (!tencentCaptchaEnabled.value) return true
|
||||
|
||||
const proof = await turnstileRef.value?.verifyTencent()
|
||||
if (!proof) return false
|
||||
|
||||
resendTurnstileToken.value = proof.ticket
|
||||
resendTencentCaptchaRandstr.value = proof.randstr
|
||||
return true
|
||||
}
|
||||
|
||||
async function acquireCreateAccountTencentProof(): Promise<boolean> {
|
||||
if (!isPendingOAuthFlow() || !tencentCaptchaEnabled.value) return true
|
||||
|
||||
const proof = await createAccountTurnstileRef.value?.verifyTencent()
|
||||
if (!proof) return false
|
||||
|
||||
createAccountTurnstileToken.value = proof.ticket
|
||||
createAccountTencentCaptchaRandstr.value = proof.randstr
|
||||
return true
|
||||
}
|
||||
|
||||
function isPendingOAuthFlow(): boolean {
|
||||
return Boolean(pendingProvider.value.trim())
|
||||
}
|
||||
@@ -398,6 +491,8 @@ function persistPendingOAuthSession(provider: string, redirect?: string): void {
|
||||
async function sendCode(): Promise<void> {
|
||||
isSendingCode.value = true
|
||||
errorMessage.value = ''
|
||||
let requestSucceeded = false
|
||||
let captchaProofUsed = false
|
||||
|
||||
try {
|
||||
if (!shouldBypassRegistrationEmailPolicy() && !isRegistrationEmailSuffixAllowed(email.value, registrationEmailSuffixWhitelist.value)) {
|
||||
@@ -410,11 +505,23 @@ async function sendCode(): Promise<void> {
|
||||
email: email.value,
|
||||
[pendingAuthTokenField.value]: pendingAuthToken.value || undefined,
|
||||
// 优先使用重发时新获取的 token(因为初始 token 可能已被使用)
|
||||
turnstile_token: resendTurnstileToken.value || initialTurnstileToken.value || undefined
|
||||
turnstile_token: turnstileEnabled.value
|
||||
? resendTurnstileToken.value || initialTurnstileToken.value || undefined
|
||||
: undefined,
|
||||
tencent_captcha_ticket: tencentCaptchaEnabled.value
|
||||
? resendTurnstileToken.value || initialTurnstileToken.value || undefined
|
||||
: undefined,
|
||||
tencent_captcha_randstr: tencentCaptchaEnabled.value
|
||||
? resendTencentCaptchaRandstr.value || initialTencentCaptchaRandstr.value || undefined
|
||||
: undefined
|
||||
} as Parameters<typeof sendVerifyCode>[0]
|
||||
captchaProofUsed = Boolean(
|
||||
requestPayload.turnstile_token || requestPayload.tencent_captcha_ticket
|
||||
)
|
||||
const response = isPendingOAuthFlow()
|
||||
? await sendPendingOAuthVerifyCode(requestPayload)
|
||||
: await sendVerifyCode(requestPayload)
|
||||
requestSucceeded = true
|
||||
|
||||
const pendingSendCodeSession = isPendingOAuthFlow()
|
||||
? getPendingOAuthSendCodeSessionResponse(response as PendingOAuthSendVerifyCodeResponse)
|
||||
@@ -434,10 +541,7 @@ async function sendCode(): Promise<void> {
|
||||
codeSent.value = true
|
||||
startCountdown(response.countdown)
|
||||
|
||||
// Reset turnstile state(token 已使用,清除以避免重复使用)
|
||||
initialTurnstileToken.value = ''
|
||||
showResendTurnstile.value = false
|
||||
resendTurnstileToken.value = ''
|
||||
} catch (error: unknown) {
|
||||
errorMessage.value = buildAuthErrorMessage(error, {
|
||||
fallback: t('auth.sendCodeFailed')
|
||||
@@ -445,25 +549,54 @@ async function sendCode(): Promise<void> {
|
||||
|
||||
appStore.showError(errorMessage.value)
|
||||
} finally {
|
||||
if (captchaProofUsed) {
|
||||
clearStoredCaptchaProof()
|
||||
initialTurnstileToken.value = ''
|
||||
initialTencentCaptchaRandstr.value = ''
|
||||
resendTurnstileToken.value = ''
|
||||
resendTencentCaptchaRandstr.value = ''
|
||||
turnstileRef.value?.reset()
|
||||
if (!requestSucceeded && turnstileEnabled.value) {
|
||||
showResendTurnstile.value = true
|
||||
}
|
||||
}
|
||||
isSendingCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearStoredCaptchaProof(): void {
|
||||
const registerDataStr = sessionStorage.getItem('register_data')
|
||||
if (!registerDataStr) return
|
||||
|
||||
try {
|
||||
const registerData = JSON.parse(registerDataStr) as Record<string, unknown>
|
||||
delete registerData.turnstile_token
|
||||
delete registerData.tencent_captcha_ticket
|
||||
delete registerData.tencent_captcha_randstr
|
||||
sessionStorage.setItem('register_data', JSON.stringify(registerData))
|
||||
} catch {
|
||||
// Invalid registration state is handled by the existing onMounted parser.
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Handlers ====================
|
||||
|
||||
async function handleResendCode(): Promise<void> {
|
||||
// If turnstile is enabled and we haven't shown it yet, show it
|
||||
// Turnstile stays staged; Tencent is acquired from this action.
|
||||
if (turnstileEnabled.value && !showResendTurnstile.value) {
|
||||
showResendTurnstile.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// If turnstile is enabled but no token yet, wait
|
||||
if (turnstileEnabled.value && !resendTurnstileToken.value) {
|
||||
errors.value.turnstile = t('auth.completeVerification')
|
||||
return
|
||||
}
|
||||
|
||||
if (!(await acquireResendTencentProof())) {
|
||||
return
|
||||
}
|
||||
|
||||
await sendCode()
|
||||
}
|
||||
|
||||
@@ -490,20 +623,33 @@ async function handleVerify(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
if (!shouldBypassRegistrationEmailPolicy() && !isRegistrationEmailSuffixAllowed(email.value, registrationEmailSuffixWhitelist.value)) {
|
||||
errorMessage.value = buildEmailSuffixNotAllowedMessage()
|
||||
appStore.showError(errorMessage.value)
|
||||
return
|
||||
}
|
||||
|
||||
if (!(await acquireCreateAccountTencentProof())) {
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
if (!shouldBypassRegistrationEmailPolicy() && !isRegistrationEmailSuffixAllowed(email.value, registrationEmailSuffixWhitelist.value)) {
|
||||
errorMessage.value = buildEmailSuffixNotAllowedMessage()
|
||||
appStore.showError(errorMessage.value)
|
||||
return
|
||||
}
|
||||
|
||||
if (isPendingOAuthFlow()) {
|
||||
const payload: Record<string, unknown> = {
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
verify_code: verifyCode.value.trim(),
|
||||
...(turnstileEnabled.value && createAccountTurnstileToken.value
|
||||
? { turnstile_token: createAccountTurnstileToken.value }
|
||||
: {}),
|
||||
...(tencentCaptchaEnabled.value && createAccountTurnstileToken.value
|
||||
? {
|
||||
tencent_captcha_ticket: createAccountTurnstileToken.value,
|
||||
tencent_captcha_randstr: createAccountTencentCaptchaRandstr.value
|
||||
}
|
||||
: {}),
|
||||
...oauthAffiliatePayload(affCode.value || loadAffiliateReferralCode()),
|
||||
}
|
||||
if (invitationCode.value) {
|
||||
@@ -539,7 +685,9 @@ async function handleVerify(): Promise<void> {
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
verify_code: verifyCode.value.trim(),
|
||||
turnstile_token: initialTurnstileToken.value || undefined,
|
||||
turnstile_token: turnstileEnabled.value ? initialTurnstileToken.value || undefined : undefined,
|
||||
tencent_captcha_ticket: tencentCaptchaEnabled.value ? initialTurnstileToken.value || undefined : undefined,
|
||||
tencent_captcha_randstr: tencentCaptchaEnabled.value ? initialTencentCaptchaRandstr.value || undefined : undefined,
|
||||
promo_code: promoCode.value || undefined,
|
||||
invitation_code: invitationCode.value || undefined,
|
||||
...(affCode.value ? { aff_code: affCode.value } : {})
|
||||
@@ -562,6 +710,11 @@ async function handleVerify(): Promise<void> {
|
||||
|
||||
appStore.showError(errorMessage.value)
|
||||
} finally {
|
||||
initialTurnstileToken.value = ''
|
||||
initialTencentCaptchaRandstr.value = ''
|
||||
if (pendingOAuthCreateCaptchaEnabled.value) {
|
||||
resetCreateAccountTurnstile()
|
||||
}
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,10 +67,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Turnstile Widget -->
|
||||
<div v-if="turnstileEnabled && turnstileSiteKey">
|
||||
<div v-if="captchaEnabled">
|
||||
<TurnstileWidget
|
||||
ref="turnstileRef"
|
||||
:site-key="turnstileSiteKey"
|
||||
:turnstile-enabled="turnstileEnabled"
|
||||
:turnstile-site-key="turnstileSiteKey"
|
||||
:tencent-enabled="tencentCaptchaEnabled"
|
||||
:tencent-app-id="tencentCaptchaAppId"
|
||||
@verify="onTurnstileVerify"
|
||||
@expire="onTurnstileExpire"
|
||||
@error="onTurnstileError"
|
||||
@@ -129,7 +132,7 @@ import { computed, ref, reactive, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
import TurnstileWidget from '@/components/CaptchaChallenge.vue'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { getPublicSettings, forgotPassword } from '@/api/auth'
|
||||
|
||||
@@ -148,10 +151,18 @@ const errorMessage = ref<string>('')
|
||||
// Public settings
|
||||
const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const tencentCaptchaEnabled = ref<boolean>(false)
|
||||
const tencentCaptchaAppId = ref<string>('')
|
||||
|
||||
// Turnstile
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||
const turnstileToken = ref<string>('')
|
||||
const tencentCaptchaRandstr = ref<string>('')
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
|
||||
appStore.showError(errorMessage.value)
|
||||
} finally {
|
||||
if (captchaEnabled.value) {
|
||||
resetCaptchaProof()
|
||||
}
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -79,10 +79,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Turnstile Widget -->
|
||||
<div v-if="turnstileEnabled && turnstileSiteKey">
|
||||
<div v-if="captchaEnabled">
|
||||
<TurnstileWidget
|
||||
ref="turnstileRef"
|
||||
:site-key="turnstileSiteKey"
|
||||
:turnstile-enabled="turnstileEnabled"
|
||||
:turnstile-site-key="turnstileSiteKey"
|
||||
:tencent-enabled="tencentCaptchaEnabled"
|
||||
:tencent-app-id="tencentCaptchaAppId"
|
||||
@verify="onTurnstileVerify"
|
||||
@expire="onTurnstileExpire"
|
||||
@error="onTurnstileError"
|
||||
@@ -156,28 +159,33 @@
|
||||
:github-enabled="githubOAuthEnabled"
|
||||
:google-enabled="googleOAuthEnabled"
|
||||
:show-divider="false"
|
||||
@start="handleOAuthStart"
|
||||
/>
|
||||
|
||||
<LinuxDoOAuthSection
|
||||
v-if="linuxdoOAuthEnabled"
|
||||
:disabled="authActionDisabled"
|
||||
:show-divider="false"
|
||||
@start="handleOAuthStart"
|
||||
/>
|
||||
<DingTalkOAuthSection
|
||||
v-if="dingtalkOAuthEnabled"
|
||||
:disabled="authActionDisabled"
|
||||
:show-divider="false"
|
||||
@start="handleOAuthStart"
|
||||
/>
|
||||
<WechatOAuthSection
|
||||
v-if="wechatOAuthEnabled"
|
||||
:disabled="authActionDisabled"
|
||||
:show-divider="false"
|
||||
@start="handleOAuthStart"
|
||||
/>
|
||||
<OidcOAuthSection
|
||||
v-if="oidcOAuthEnabled"
|
||||
:disabled="authActionDisabled"
|
||||
:provider-name="oidcOAuthProviderName"
|
||||
:show-divider="false"
|
||||
@start="handleOAuthStart"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
@@ -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<boolean>(false)
|
||||
// Public settings
|
||||
const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const tencentCaptchaEnabled = ref<boolean>(false)
|
||||
const tencentCaptchaAppId = ref<string>('')
|
||||
const linuxdoOAuthEnabled = ref<boolean>(false)
|
||||
const dingtalkOAuthEnabled = ref<boolean>(false)
|
||||
const wechatOAuthEnabled = ref<boolean>(false)
|
||||
@@ -269,6 +290,12 @@ const showAgreementModal = ref<boolean>(false)
|
||||
// Turnstile
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||
const turnstileToken = ref<string>('')
|
||||
const tencentCaptchaRandstr = ref<string>('')
|
||||
const captchaEnabled = computed(
|
||||
() =>
|
||||
(turnstileEnabled.value && Boolean(turnstileSiteKey.value)) ||
|
||||
(tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value))
|
||||
)
|
||||
|
||||
// 2FA state
|
||||
const show2FAModal = ref<boolean>(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<boolean> {
|
||||
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<void> {
|
||||
return
|
||||
}
|
||||
|
||||
if (!(await acquireTencentProof())) {
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
@@ -498,7 +552,11 @@ async function handleLogin(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -204,10 +204,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Turnstile Widget -->
|
||||
<div v-if="turnstileEnabled && turnstileSiteKey" data-testid="registration-turnstile">
|
||||
<div v-if="captchaEnabled" data-testid="registration-turnstile">
|
||||
<TurnstileWidget
|
||||
ref="turnstileRef"
|
||||
:site-key="turnstileSiteKey"
|
||||
:turnstile-enabled="turnstileEnabled"
|
||||
:turnstile-site-key="turnstileSiteKey"
|
||||
:tencent-enabled="tencentCaptchaEnabled"
|
||||
:tencent-app-id="tencentCaptchaAppId"
|
||||
@verify="onTurnstileVerify"
|
||||
@expire="onTurnstileExpire"
|
||||
@error="onTurnstileError"
|
||||
@@ -279,6 +282,7 @@
|
||||
:github-enabled="githubOAuthEnabled"
|
||||
:google-enabled="googleOAuthEnabled"
|
||||
:show-divider="false"
|
||||
@start="handleOAuthStart"
|
||||
/>
|
||||
|
||||
<LinuxDoOAuthSection
|
||||
@@ -286,12 +290,14 @@
|
||||
:disabled="registrationActionDisabled"
|
||||
:aff-code="formData.aff_code"
|
||||
:show-divider="false"
|
||||
@start="handleOAuthStart"
|
||||
/>
|
||||
<WechatOAuthSection
|
||||
v-if="wechatOAuthEnabled"
|
||||
:disabled="registrationActionDisabled"
|
||||
:aff-code="formData.aff_code"
|
||||
:show-divider="false"
|
||||
@start="handleOAuthStart"
|
||||
/>
|
||||
<OidcOAuthSection
|
||||
v-if="oidcOAuthEnabled"
|
||||
@@ -299,6 +305,7 @@
|
||||
:provider-name="oidcOAuthProviderName"
|
||||
:aff-code="formData.aff_code"
|
||||
:show-divider="false"
|
||||
@start="handleOAuthStart"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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<boolean>(false)
|
||||
const affiliateEnabled = ref<boolean>(false)
|
||||
const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const tencentCaptchaEnabled = ref<boolean>(false)
|
||||
const tencentCaptchaAppId = ref<string>('')
|
||||
const siteName = ref<string>('Sub2API')
|
||||
const linuxdoOAuthEnabled = ref<boolean>(false)
|
||||
const wechatOAuthEnabled = ref<boolean>(false)
|
||||
@@ -394,6 +407,12 @@ const showAgreementModal = ref<boolean>(false)
|
||||
// Turnstile
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||
const turnstileToken = ref<string>('')
|
||||
const tencentCaptchaRandstr = ref<string>('')
|
||||
const captchaEnabled = computed(
|
||||
() =>
|
||||
(turnstileEnabled.value && Boolean(turnstileSiteKey.value)) ||
|
||||
(tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value))
|
||||
)
|
||||
|
||||
// Promo code validation
|
||||
const promoValidating = ref<boolean>(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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (!(await acquireTencentProof())) {
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
@@ -892,7 +970,9 @@ async function handleRegister(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
// Also show error toast
|
||||
appStore.showError(errorMessage.value)
|
||||
} finally {
|
||||
if (captchaEnabled.value) {
|
||||
resetCaptchaProof()
|
||||
}
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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: '<div><slot /><slot name="footer" /></div>' },
|
||||
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: '<div><slot /><slot name="footer" /></div>' },
|
||||
Icon: true,
|
||||
TurnstileWidget: {
|
||||
template: '<button data-testid="resend-captcha" @click="$emit(\'verify\', \'fresh-proof\')">verify</button>',
|
||||
},
|
||||
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: '<div><slot /><slot name="footer" /></div>' },
|
||||
Icon: true,
|
||||
TurnstileWidget: {
|
||||
template: '<button data-testid="create-turnstile" @click="$emit(\'verify\', \'create-token\')">verify</button>',
|
||||
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: '<div><slot /><slot name="footer" /></div>' },
|
||||
Icon: true,
|
||||
TurnstileWidget: {
|
||||
template: '<button data-testid="create-turnstile" @click="$emit(\'verify\', \'create-token\')">verify</button>',
|
||||
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: '<div><slot /><slot name="footer" /></div>' },
|
||||
Icon: true,
|
||||
TurnstileWidget: {
|
||||
template: '<span />',
|
||||
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,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<typeof import('vue-i18n')>('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<typeof import('@/api/auth')>('@/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: '<div><slot /><slot name="footer" /></div>' },
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user