新增腾讯天御验证码认证门禁

This commit is contained in:
lyen1688
2026-08-04 15:09:29 +08:00
parent 825ca7b1fc
commit e592c5f9e0
86 changed files with 3683 additions and 262 deletions
+3 -1
View File
@@ -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)
+2
View File
@@ -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
+14
View File
@@ -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=
+1 -1
View File
@@ -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 ─────────────────────────────────────────────
+4 -1
View File
@@ -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) {
+39 -22
View File
@@ -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(),
+4 -1
View File
@@ -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,
+12 -5
View File
@@ -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)
}
+1
View File
@@ -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)
+18
View File
@@ -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")
+84 -27
View File
@@ -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)
+49 -41
View File
@@ -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 {
+16 -6
View File
@@ -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)
+39 -1
View File
@@ -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,