diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index a21cecb729..2369e09049 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -59,6 +59,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { turnstileService := service.NewTurnstileService(settingService, turnstileVerifier) tencentCaptchaVerifier := repository.NewTencentCaptchaVerifier() tencentCaptchaService := service.NewTencentCaptchaService(settingService, tencentCaptchaVerifier) + aliyunCaptchaVerifier := repository.NewAliyunCaptchaVerifier() + aliyunCaptchaService := service.NewAliyunCaptchaService(settingService, aliyunCaptchaVerifier) emailQueueService := service.ProvideEmailQueueService(emailService) promoCodeRepository := repository.NewPromoCodeRepository(client) billingCache := repository.NewBillingCache(redisClient) @@ -80,7 +82,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.ProvideAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, tencentCaptchaService, emailQueueService, promoService, subscriptionService, affiliateService, serviceUserPlatformQuotaRepository) + authService := service.ProvideAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, tencentCaptchaService, aliyunCaptchaService, 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) @@ -224,7 +226,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { registry := payment.ProvideRegistry() defaultLoadBalancer := payment.ProvideDefaultLoadBalancer(client, encryptionKey) paymentService := service.ProvidePaymentService(client, registry, defaultLoadBalancer, redeemService, subscriptionService, paymentConfigService, userRepository, groupRepository, affiliateService, notificationEmailService) - settingHandler := handler.ProvideAdminSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService, userAttributeService, notificationEmailService, totpService, userService) + settingHandler := handler.ProvideAdminSettingHandler(settingService, emailService, turnstileService, aliyunCaptchaService, opsService, paymentConfigService, paymentService, userAttributeService, notificationEmailService, totpService, userService) opsHandler := admin.NewOpsHandler(opsService) updateCache := repository.NewUpdateCache(redisClient) gitHubReleaseClient := repository.ProvideGitHubReleaseClient(configConfig) diff --git a/backend/go.mod b/backend/go.mod index ebe7adf6c2..d6f1c10ed6 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -5,6 +5,9 @@ go 1.26.5 require ( entgo.io/ent v0.14.5 github.com/DATA-DOG/go-sqlmock v1.5.2 + github.com/alibabacloud-go/captcha-20230305 v1.1.3 + github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13 + github.com/alibabacloud-go/tea v1.3.13 github.com/alicebob/miniredis/v2 v2.38.0 github.com/alitto/pond/v2 v2.6.2 github.com/andybalholm/brotli v1.2.0 @@ -35,6 +38,8 @@ require ( github.com/spf13/viper v1.18.2 github.com/stretchr/testify v1.11.1 github.com/stripe/stripe-go/v85 v85.0.0 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/captcha v1.3.52 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.52 github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 github.com/testcontainers/testcontainers-go/modules/redis v0.40.0 github.com/tidwall/gjson v1.18.0 @@ -60,6 +65,10 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/agext/levenshtein v1.2.3 // indirect + github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect + github.com/alibabacloud-go/debug v1.0.1 // indirect + github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect + github.com/aliyun/credentials-go v1.4.5 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect @@ -81,6 +90,7 @@ require ( github.com/bytedance/sonic v1.9.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/clbanning/mxj/v2 v2.7.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect @@ -161,12 +171,11 @@ 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 github.com/tinylib/msgp v1.6.4 // indirect + github.com/tjfoc/gmsm v1.4.1 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 24d3133355..e9a4c1829e 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,5 +1,6 @@ ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9 h1:E0wvcUXTkgyN4wy4LGtNzMNGMytJN8afmIWXJVMi4cc= ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9/go.mod h1:Oe1xWPuu5q9LzyrWfbZmEZxFYeu4BHTyzfjeW2aZp/w= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= entgo.io/ent v0.14.5 h1:Rj2WOYJtCkWyFo6a+5wB3EfBRP0rnx1fMk6gGA0UUe4= @@ -8,6 +9,7 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8af github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= @@ -16,10 +18,56 @@ github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7l github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/agiledragon/gomonkey v2.0.2+incompatible h1:eXKi9/piiC3cjJD1658mEE2o3NjkJ5vDLgYjCQu0Xlw= github.com/agiledragon/gomonkey v2.0.2+incompatible/go.mod h1:2NGfXu1a80LLr2cmWXGBDaHEjb1idR6+FVlX5T3D9hw= +github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 h1:eIf+iGJxdU4U9ypaUfbtOWCsZSbTb8AUHvyPrxu6mAA= +github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g= +github.com/alibabacloud-go/captcha-20230305 v1.1.3 h1:0Aobw12m3x28aeDMPjwjXsfF8MuLvRjlQ4Hhoy5hFOY= +github.com/alibabacloud-go/captcha-20230305 v1.1.3/go.mod h1:ydzBIN2OiM7eeQPpAFyBrv1H5TY1MtUP2rQig44C4UQ= +github.com/alibabacloud-go/darabonba-array v0.1.0 h1:vR8s7b1fWAQIjEjWnuF0JiKsCvclSRTfDzZHTYqfufY= +github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI= +github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC2NG0Ax+GpOM5gtupki31XE= +github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8= +github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc= +github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc= +github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13 h1:Q00FU3H94Ts0ZIHDmY+fYGgB7dV9D/YX6FGsgorQPgw= +github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE= +github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg= +github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ= +github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo= +github.com/alibabacloud-go/darabonba-string v1.0.2/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA= +github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY= +github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= +github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg= +github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= +github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q= +github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE= +github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY= +github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws= +github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg= +github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= +github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= +github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk= +github.com/alibabacloud-go/tea v1.3.13 h1:WhGy6LIXaMbBM6VBYcsDCz6K/TPsT1Ri2hPmmZffZ94= +github.com/alibabacloud-go/tea v1.3.13/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg= +github.com/alibabacloud-go/tea-utils v1.3.1 h1:iWQeRzRheqCMuiF3+XkfybB3kTgUXkXX+JMrqfLeB2I= +github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE= +github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4= +github.com/alibabacloud-go/tea-utils/v2 v2.0.7 h1:WDx5qW3Xa5ZgJ1c8NfqJkF6w+AU5wB8835UdhPr6Ax0= +github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I= github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw= github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/alitto/pond/v2 v2.6.2 h1:Sphe40g0ILeM1pA2c2K+Th0DGU+pt0A/Kprr+WB24Pw= github.com/alitto/pond/v2 v2.6.2/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE= +github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw= +github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0= +github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM= +github.com/aliyun/credentials-go v1.4.5 h1:O76WYKgdy1oQYYiJkERjlA2dxGuvLRrzuO2ScrtGWSk= +github.com/aliyun/credentials-go v1.4.5/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= @@ -77,11 +125,16 @@ github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= +github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -120,6 +173,9 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -163,27 +219,43 @@ github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk= github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= 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= github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= @@ -207,8 +279,10 @@ github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= @@ -217,6 +291,8 @@ github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZX github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -234,8 +310,6 @@ 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= @@ -263,14 +337,15 @@ github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 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/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 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= @@ -292,6 +367,7 @@ github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= @@ -304,8 +380,6 @@ 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 +404,9 @@ github.com/smartwalle/ngx v1.1.0 h1:q8nANgWSPRGeI/u+ixBoA4mf68DrUq6vZ+n9L5UKv9I= github.com/smartwalle/ngx v1.1.0/go.mod h1:mx/nz2Pk5j+RBs7t6u6k22MPiBG/8CtOMpCnALIG8Y0= github.com/smartwalle/nsign v1.0.9 h1:8poAgG7zBd8HkZy9RQDwasC6XZvJpDGQWSjzL2FZL6E= github.com/smartwalle/nsign v1.0.9/go.mod h1:eY6I4CJlyNdVMP+t6z1H6Jpd4m5/V+8xi44ufSTxXgc= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= @@ -338,18 +415,18 @@ 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= github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -386,6 +463,9 @@ github.com/tiktoken-go/tokenizer v0.8.0 h1:drHWno2Zx3eAm/hk/LmvBKXPpSImB7BRyh/ru github.com/tiktoken-go/tokenizer v0.8.0/go.mod h1:pTmPz4r14MV3JkUGAmAcdLdYhSxN68MCjrP+EoxBdx0= github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w= +github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= +github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= @@ -400,6 +480,9 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= @@ -443,59 +526,189 @@ go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ= google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU= google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4/go.mod h1:NnuHhy+bxcg30o7FnVAZbXsPHUDQ9qKWAQKCD7VxFtk= google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 h1:i8QOKZfYg6AbGVZzUAY3LrNWCKF8O6zFisU9Wl9RER4= google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 2cac240e4a..6977a0e308 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -32,7 +32,7 @@ const ( // DefaultCSPPolicy is the default Content-Security-Policy with nonce support // __CSP_NONCE__ will be replaced with actual nonce at request time by the SecurityHeaders middleware -const DefaultCSPPolicy = "default-src 'self'; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://static.cloudflareinsights.com https://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'" +const DefaultCSPPolicy = "default-src 'self'; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" // UMQ(用户消息队列)模式常量 const ( diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index ee2fac857d..ecf8feaee3 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -54,6 +54,7 @@ type SettingHandler struct { settingService *service.SettingService emailService *service.EmailService turnstileService *service.TurnstileService + aliyunCaptchaService *service.AliyunCaptchaService opsService *service.OpsService paymentConfigService *service.PaymentConfigService paymentService *service.PaymentService @@ -82,6 +83,12 @@ func (h *SettingHandler) SetNotificationEmailService(notificationEmailService *s h.notificationEmailService = notificationEmailService } +// SetAliyunCaptchaService attaches the Aliyun captcha credential validator without +// changing the constructor signature used by existing unit tests. +func (h *SettingHandler) SetAliyunCaptchaService(aliyunCaptchaService *service.AliyunCaptchaService) { + h.aliyunCaptchaService = aliyunCaptchaService +} + // SetStepUpDeps attaches the services backing the step-up switch preconditions // (enable requires the acting admin to have TOTP enabled; disable is itself a // step-up gated operation), without changing the constructor signature used by @@ -161,6 +168,12 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { TencentCaptchaAppSecretKeyConfigured: settings.TencentCaptchaAppSecretKeyConfigured, TencentCaptchaCloudSecretIDConfigured: settings.TencentCaptchaCloudSecretIDConfigured, TencentCaptchaCloudSecretKeyConfigured: settings.TencentCaptchaCloudSecretKeyConfigured, + AliyunCaptchaEnabled: settings.AliyunCaptchaEnabled, + AliyunCaptchaAccessKeyID: settings.AliyunCaptchaAccessKeyID, + AliyunCaptchaAccessKeySecretConfigured: settings.AliyunCaptchaAccessKeySecretConfigured, + AliyunCaptchaSceneID: settings.AliyunCaptchaSceneID, + AliyunCaptchaPrefix: settings.AliyunCaptchaPrefix, + AliyunCaptchaRegion: settings.AliyunCaptchaRegion, APIKeyACLTrustForwardedIP: settings.APIKeyACLTrustForwardedIP, ForwardedClientIPHeaders: settings.ForwardedClientIPHeaders, LinuxDoConnectEnabled: settings.LinuxDoConnectEnabled, diff --git a/backend/internal/handler/admin/setting_handler_audit.go b/backend/internal/handler/admin/setting_handler_audit.go index 8154ea8ed1..d108b6ec01 100644 --- a/backend/internal/handler/admin/setting_handler_audit.go +++ b/backend/internal/handler/admin/setting_handler_audit.go @@ -122,6 +122,24 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if req.TencentCaptchaCloudSecretKey != "" { changed = append(changed, "tencent_captcha_cloud_secret_key") } + if before.AliyunCaptchaEnabled != after.AliyunCaptchaEnabled { + changed = append(changed, "aliyun_captcha_enabled") + } + if before.AliyunCaptchaAccessKeyID != after.AliyunCaptchaAccessKeyID { + changed = append(changed, "aliyun_captcha_access_key_id") + } + if req.AliyunCaptchaAccessKeySecret != "" { + changed = append(changed, "aliyun_captcha_access_key_secret") + } + if before.AliyunCaptchaSceneID != after.AliyunCaptchaSceneID { + changed = append(changed, "aliyun_captcha_scene_id") + } + if before.AliyunCaptchaPrefix != after.AliyunCaptchaPrefix { + changed = append(changed, "aliyun_captcha_prefix") + } + if before.AliyunCaptchaRegion != after.AliyunCaptchaRegion { + changed = append(changed, "aliyun_captcha_region") + } if before.APIKeyACLTrustForwardedIP != after.APIKeyACLTrustForwardedIP { changed = append(changed, "api_key_acl_trust_forwarded_ip") } diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index aaf2ad10fd..6635a21899 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -61,6 +61,14 @@ type UpdateSettingsRequest struct { TencentCaptchaCloudSecretID string `json:"tencent_captcha_cloud_secret_id"` TencentCaptchaCloudSecretKey string `json:"tencent_captcha_cloud_secret_key"` + // 阿里云验证码 2.0 设置 + AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"` + AliyunCaptchaAccessKeyID string `json:"aliyun_captcha_access_key_id"` + AliyunCaptchaAccessKeySecret string `json:"aliyun_captcha_access_key_secret"` + AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"` + AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"` + AliyunCaptchaRegion string `json:"aliyun_captcha_region"` + // API Key IP 访问控制设置 APIKeyACLTrustForwardedIP *bool `json:"api_key_acl_trust_forwarded_ip"` ForwardedClientIPHeaders *[]string `json:"forwarded_client_ip_headers"` @@ -450,6 +458,7 @@ func settingsAuditRequest(req UpdateSettingsRequest) UpdateSettingsRequest { req.TencentCaptchaAppSecretKey = strings.TrimSpace(req.TencentCaptchaAppSecretKey) req.TencentCaptchaCloudSecretID = strings.TrimSpace(req.TencentCaptchaCloudSecretID) req.TencentCaptchaCloudSecretKey = strings.TrimSpace(req.TencentCaptchaCloudSecretKey) + req.AliyunCaptchaAccessKeySecret = strings.TrimSpace(req.AliyunCaptchaAccessKeySecret) return req } @@ -614,10 +623,27 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { 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") + aliyunCaptchaEnabled := req.AliyunCaptchaEnabled + if _, sent := sentFields["aliyun_captcha_enabled"]; !sent { + aliyunCaptchaEnabled = previousSettings.AliyunCaptchaEnabled + } + enabledCaptchaProviders := 0 + for _, enabled := range []bool{turnstileEnabled, tencentCaptchaEnabled, aliyunCaptchaEnabled} { + if enabled { + enabledCaptchaProviders++ + } + } + if enabledCaptchaProviders > 1 { + response.BadRequest(c, "Multiple captcha providers (Cloudflare Turnstile / Tencent Captcha / Aliyun Captcha) cannot be enabled at the same time") return } + // 阿里云地域 normalize:未发送保留已存值,非法值一律按中国内地落库 + if _, sent := sentFields["aliyun_captcha_region"]; !sent { + req.AliyunCaptchaRegion = previousSettings.AliyunCaptchaRegion + } + if req.AliyunCaptchaRegion != service.AliyunCaptchaRegionSGP { + req.AliyunCaptchaRegion = service.AliyunCaptchaRegionCN + } // Turnstile 参数验证 if req.TurnstileEnabled { @@ -678,6 +704,51 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } } + // 阿里云验证码 2.0 参数验证 + if aliyunCaptchaEnabled { + if _, sent := sentFields["aliyun_captcha_scene_id"]; !sent { + req.AliyunCaptchaSceneID = previousSettings.AliyunCaptchaSceneID + } + if _, sent := sentFields["aliyun_captcha_prefix"]; !sent { + req.AliyunCaptchaPrefix = previousSettings.AliyunCaptchaPrefix + } + if _, sent := sentFields["aliyun_captcha_access_key_id"]; !sent { + req.AliyunCaptchaAccessKeyID = previousSettings.AliyunCaptchaAccessKeyID + } + if req.AliyunCaptchaSceneID == "" { + response.BadRequest(c, "Aliyun Captcha Scene ID is required when enabled") + return + } + if req.AliyunCaptchaPrefix == "" { + response.BadRequest(c, "Aliyun Captcha Prefix is required when enabled") + return + } + if req.AliyunCaptchaAccessKeyID == "" { + response.BadRequest(c, "Aliyun Captcha AccessKey ID is required when enabled") + return + } + // 如果未提供 AccessKey Secret,使用已保存的值(留空保留当前值) + if req.AliyunCaptchaAccessKeySecret == "" { + if previousSettings.AliyunCaptchaAccessKeySecret == "" { + response.BadRequest(c, "Aliyun Captcha AccessKey Secret is required when enabled") + return + } + req.AliyunCaptchaAccessKeySecret = previousSettings.AliyunCaptchaAccessKeySecret + } + + // 凭证任一变化时真实调用一次阿里云校验(避免配置错误导致无法登录) + credentialsChanged := previousSettings.AliyunCaptchaAccessKeyID != req.AliyunCaptchaAccessKeyID || + previousSettings.AliyunCaptchaAccessKeySecret != req.AliyunCaptchaAccessKeySecret || + previousSettings.AliyunCaptchaSceneID != req.AliyunCaptchaSceneID || + previousSettings.AliyunCaptchaRegion != req.AliyunCaptchaRegion + if credentialsChanged { + if err := h.aliyunCaptchaService.ValidateCredentials(c.Request.Context(), req.AliyunCaptchaAccessKeyID, req.AliyunCaptchaAccessKeySecret, req.AliyunCaptchaSceneID, req.AliyunCaptchaRegion); err != nil { + response.ErrorFrom(c, err) + return + } + } + } + // TOTP 双因素认证参数验证 // 只有手动配置了加密密钥才允许启用 TOTP 功能 if req.TotpEnabled && !previousSettings.TotpEnabled { @@ -1430,6 +1501,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { TencentCaptchaAppSecretKey: req.TencentCaptchaAppSecretKey, TencentCaptchaCloudSecretID: req.TencentCaptchaCloudSecretID, TencentCaptchaCloudSecretKey: req.TencentCaptchaCloudSecretKey, + AliyunCaptchaEnabled: req.AliyunCaptchaEnabled, + AliyunCaptchaAccessKeyID: req.AliyunCaptchaAccessKeyID, + AliyunCaptchaAccessKeySecret: req.AliyunCaptchaAccessKeySecret, + AliyunCaptchaSceneID: req.AliyunCaptchaSceneID, + AliyunCaptchaPrefix: req.AliyunCaptchaPrefix, + AliyunCaptchaRegion: req.AliyunCaptchaRegion, APIKeyACLTrustForwardedIP: func() bool { if req.APIKeyACLTrustForwardedIP != nil { return *req.APIKeyACLTrustForwardedIP @@ -2007,6 +2084,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { TencentCaptchaAppSecretKeyConfigured: updatedSettings.TencentCaptchaAppSecretKeyConfigured, TencentCaptchaCloudSecretIDConfigured: updatedSettings.TencentCaptchaCloudSecretIDConfigured, TencentCaptchaCloudSecretKeyConfigured: updatedSettings.TencentCaptchaCloudSecretKeyConfigured, + AliyunCaptchaEnabled: updatedSettings.AliyunCaptchaEnabled, + AliyunCaptchaAccessKeyID: updatedSettings.AliyunCaptchaAccessKeyID, + AliyunCaptchaAccessKeySecretConfigured: updatedSettings.AliyunCaptchaAccessKeySecretConfigured, + AliyunCaptchaSceneID: updatedSettings.AliyunCaptchaSceneID, + AliyunCaptchaPrefix: updatedSettings.AliyunCaptchaPrefix, + AliyunCaptchaRegion: updatedSettings.AliyunCaptchaRegion, APIKeyACLTrustForwardedIP: updatedSettings.APIKeyACLTrustForwardedIP, ForwardedClientIPHeaders: updatedSettings.ForwardedClientIPHeaders, LinuxDoConnectEnabled: updatedSettings.LinuxDoConnectEnabled, diff --git a/backend/internal/handler/auth_dingtalk_oauth.go b/backend/internal/handler/auth_dingtalk_oauth.go index 7a0e2b1c05..067f27f694 100644 --- a/backend/internal/handler/auth_dingtalk_oauth.go +++ b/backend/internal/handler/auth_dingtalk_oauth.go @@ -113,7 +113,7 @@ 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) { + if !h.requireActionCaptchaForOAuthLoginStart(c) { return } cfg, err := h.getDingTalkOAuthConfig(c.Request.Context()) diff --git a/backend/internal/handler/auth_email_oauth.go b/backend/internal/handler/auth_email_oauth.go index 9e29a43597..8717485ecf 100644 --- a/backend/internal/handler/auth_email_oauth.go +++ b/backend/internal/handler/auth_email_oauth.go @@ -59,7 +59,7 @@ func (h *AuthHandler) CompleteGoogleOAuthRegistration(c *gin.Context) { } func (h *AuthHandler) emailOAuthStart(c *gin.Context, provider string) { - if !h.requireTencentCaptchaForOAuthLoginStart(c) { + if !h.requireActionCaptchaForOAuthLoginStart(c) { return } cfg, err := h.getEmailOAuthConfig(c.Request.Context(), provider) diff --git a/backend/internal/handler/auth_linuxdo_oauth.go b/backend/internal/handler/auth_linuxdo_oauth.go index e1e0e4c73d..8a5b9ae0ca 100644 --- a/backend/internal/handler/auth_linuxdo_oauth.go +++ b/backend/internal/handler/auth_linuxdo_oauth.go @@ -82,7 +82,7 @@ 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) { + if !h.requireActionCaptchaForOAuthLoginStart(c) { return } cfg, err := h.getLinuxDoOAuthConfig(c.Request.Context()) diff --git a/backend/internal/handler/auth_oauth_captcha_start.go b/backend/internal/handler/auth_oauth_captcha_start.go index 56e0bd4ada..e8c174e7d8 100644 --- a/backend/internal/handler/auth_oauth_captcha_start.go +++ b/backend/internal/handler/auth_oauth_captcha_start.go @@ -11,6 +11,8 @@ import ( ) type oauthStartCaptchaRequest struct { + // TurnstileToken 承载阿里云验证码的 captchaVerifyParam(复用既有请求字段名) + TurnstileToken string `json:"turnstile_token"` TencentCaptchaTicket string `json:"tencent_captcha_ticket"` TencentCaptchaRandstr string `json:"tencent_captcha_randstr"` } @@ -19,7 +21,7 @@ type oauthStartResponse struct { AuthorizeURL string `json:"authorize_url"` } -func (h *AuthHandler) requireTencentCaptchaForOAuthLoginStart(c *gin.Context) bool { +func (h *AuthHandler) requireActionCaptchaForOAuthLoginStart(c *gin.Context) bool { if strings.HasSuffix(strings.TrimRight(c.Request.URL.Path, "/"), "/bind/start") { return true } @@ -28,7 +30,8 @@ func (h *AuthHandler) requireTencentCaptchaForOAuthLoginStart(c *gin.Context) bo if c.Request.Method == http.MethodPost { _ = c.ShouldBindJSON(&req) } - if err := h.authService.VerifyTencentCaptchaIfEnabled(c.Request.Context(), service.CaptchaProof{ + if err := h.authService.VerifyActionCaptchaIfEnabled(c.Request.Context(), service.CaptchaProof{ + TurnstileToken: req.TurnstileToken, TencentTicket: req.TencentCaptchaTicket, TencentRandstr: req.TencentCaptchaRandstr, }, ip.GetClientIP(c)); err != nil { diff --git a/backend/internal/handler/auth_oauth_captcha_start_test.go b/backend/internal/handler/auth_oauth_captcha_start_test.go index f630acde0c..d6e9e239c1 100644 --- a/backend/internal/handler/auth_oauth_captcha_start_test.go +++ b/backend/internal/handler/auth_oauth_captcha_start_test.go @@ -122,7 +122,7 @@ func TestOAuthStartPostReturnsAuthorizeURLAfterTencentVerification(t *testing.T) ) c.Request.Header.Set("Content-Type", "application/json") - require.True(t, handler.requireTencentCaptchaForOAuthLoginStart(c)) + require.True(t, handler.requireActionCaptchaForOAuthLoginStart(c)) respondOAuthStart(c, "https://provider.example/authorize") require.Equal(t, http.StatusOK, recorder.Code) @@ -143,7 +143,7 @@ func TestOAuthStartPostRequiresTencentProofWhenEnabled(t *testing.T) { 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.False(t, handler.requireActionCaptchaForOAuthLoginStart(c)) require.Equal(t, http.StatusBadRequest, recorder.Code) require.Contains(t, recorder.Body.String(), "TENCENT_CAPTCHA_VERIFICATION_FAILED") require.Zero(t, verifier.calls) @@ -158,7 +158,7 @@ func TestOAuthBindingPathRemainsOutsideTencentGate(t *testing.T) { 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.True(t, handler.requireActionCaptchaForOAuthLoginStart(c)) require.Equal(t, http.StatusOK, recorder.Code) } @@ -169,7 +169,7 @@ func TestOAuthStartGetRemainsCompatibleWhenTencentDisabled(t *testing.T) { c, _ := gin.CreateTestContext(recorder) c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/github/start", nil) - require.True(t, handler.requireTencentCaptchaForOAuthLoginStart(c)) + require.True(t, handler.requireActionCaptchaForOAuthLoginStart(c)) respondOAuthStart(c, "https://provider.example/authorize") require.Equal(t, http.StatusFound, recorder.Code) diff --git a/backend/internal/handler/auth_oidc_oauth.go b/backend/internal/handler/auth_oidc_oauth.go index ee9e195c12..0367e4cff0 100644 --- a/backend/internal/handler/auth_oidc_oauth.go +++ b/backend/internal/handler/auth_oidc_oauth.go @@ -115,7 +115,7 @@ 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) { + if !h.requireActionCaptchaForOAuthLoginStart(c) { return } cfg, err := h.getOIDCOAuthConfig(c.Request.Context()) diff --git a/backend/internal/handler/auth_wechat_oauth.go b/backend/internal/handler/auth_wechat_oauth.go index 43a076bbb3..432fab5206 100644 --- a/backend/internal/handler/auth_wechat_oauth.go +++ b/backend/internal/handler/auth_wechat_oauth.go @@ -96,7 +96,7 @@ 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) { + if !h.requireActionCaptchaForOAuthLoginStart(c) { return } cfg, err := h.getWeChatOAuthConfig(c.Request.Context(), c.Query("mode"), c) diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 9d15ec99a4..061c4ee3f3 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -64,6 +64,12 @@ type SystemSettings struct { TencentCaptchaAppSecretKeyConfigured bool `json:"tencent_captcha_app_secret_key_configured"` TencentCaptchaCloudSecretIDConfigured bool `json:"tencent_captcha_cloud_secret_id_configured"` TencentCaptchaCloudSecretKeyConfigured bool `json:"tencent_captcha_cloud_secret_key_configured"` + AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"` + AliyunCaptchaAccessKeyID string `json:"aliyun_captcha_access_key_id"` + AliyunCaptchaAccessKeySecretConfigured bool `json:"aliyun_captcha_access_key_secret_configured"` + AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"` + AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"` + AliyunCaptchaRegion string `json:"aliyun_captcha_region"` APIKeyACLTrustForwardedIP bool `json:"api_key_acl_trust_forwarded_ip"` ForwardedClientIPHeaders []string `json:"forwarded_client_ip_headers"` @@ -348,6 +354,10 @@ type PublicSettings struct { TurnstileSiteKey string `json:"turnstile_site_key"` TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"` TencentCaptchaAppID string `json:"tencent_captcha_app_id"` + AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"` + AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"` + AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"` + AliyunCaptchaRegion string `json:"aliyun_captcha_region"` SiteName string `json:"site_name"` SiteLogo string `json:"site_logo"` SiteSubtitle string `json:"site_subtitle"` diff --git a/backend/internal/handler/passkey_handler.go b/backend/internal/handler/passkey_handler.go index 35cb176e88..5691f704f2 100644 --- a/backend/internal/handler/passkey_handler.go +++ b/backend/internal/handler/passkey_handler.go @@ -47,6 +47,8 @@ type passkeyFinishRequest struct { } type passkeyBeginLoginRequest struct { + // TurnstileToken 承载阿里云验证码的 captchaVerifyParam(复用既有请求字段名) + TurnstileToken string `json:"turnstile_token"` TencentCaptchaTicket string `json:"tencent_captcha_ticket"` TencentCaptchaRandstr string `json:"tencent_captcha_randstr"` } @@ -78,7 +80,8 @@ func (h *PasskeyHandler) BeginLogin(c *gin.Context) { } var req passkeyBeginLoginRequest _ = c.ShouldBindJSON(&req) - if err := h.authService.VerifyTencentCaptchaIfEnabled(c.Request.Context(), service.CaptchaProof{ + if err := h.authService.VerifyActionCaptchaIfEnabled(c.Request.Context(), service.CaptchaProof{ + TurnstileToken: req.TurnstileToken, TencentTicket: req.TencentCaptchaTicket, TencentRandstr: req.TencentCaptchaRandstr, }, ip.GetClientIP(c)); err != nil { diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 5070ed35a5..83029fd360 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -62,6 +62,10 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { TurnstileSiteKey: settings.TurnstileSiteKey, TencentCaptchaEnabled: settings.TencentCaptchaEnabled, TencentCaptchaAppID: settings.TencentCaptchaAppID, + AliyunCaptchaEnabled: settings.AliyunCaptchaEnabled, + AliyunCaptchaSceneID: settings.AliyunCaptchaSceneID, + AliyunCaptchaPrefix: settings.AliyunCaptchaPrefix, + AliyunCaptchaRegion: settings.AliyunCaptchaRegion, SiteName: settings.SiteName, SiteLogo: settings.SiteLogo, SiteSubtitle: settings.SiteSubtitle, diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index fe559e6348..73b36dc0cc 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -157,9 +157,10 @@ func ProvideSettingHandler(settingService *service.SettingService, buildInfo Bui } // ProvideAdminSettingHandler creates admin.SettingHandler with notification template APIs. -func ProvideAdminSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, opsService *service.OpsService, paymentConfigService *service.PaymentConfigService, paymentService *service.PaymentService, userAttributeService *service.UserAttributeService, notificationEmailService *service.NotificationEmailService, totpService *service.TotpService, userService *service.UserService) *admin.SettingHandler { +func ProvideAdminSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, aliyunCaptchaService *service.AliyunCaptchaService, opsService *service.OpsService, paymentConfigService *service.PaymentConfigService, paymentService *service.PaymentService, userAttributeService *service.UserAttributeService, notificationEmailService *service.NotificationEmailService, totpService *service.TotpService, userService *service.UserService) *admin.SettingHandler { h := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService, userAttributeService) h.SetNotificationEmailService(notificationEmailService) + h.SetAliyunCaptchaService(aliyunCaptchaService) h.SetStepUpDeps(totpService, userService) return h } diff --git a/backend/internal/repository/aliyun_captcha_verifier.go b/backend/internal/repository/aliyun_captcha_verifier.go new file mode 100644 index 0000000000..5838287e98 --- /dev/null +++ b/backend/internal/repository/aliyun_captcha_verifier.go @@ -0,0 +1,81 @@ +package repository + +import ( + "context" + "errors" + "fmt" + + captcha "github.com/alibabacloud-go/captcha-20230305/client" + openapiutil "github.com/alibabacloud-go/darabonba-openapi/v2/utils" + "github.com/alibabacloud-go/tea/dara" + "github.com/alibabacloud-go/tea/tea" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +const aliyunCaptchaTimeoutMillis = 10_000 + +type aliyunCaptchaVerifier struct { + protocol string // "HTTPS";测试注入 "HTTP" 指向 httptest.Server + timeoutMillis int +} + +func NewAliyunCaptchaVerifier() service.AliyunCaptchaVerifier { + return &aliyunCaptchaVerifier{ + protocol: "HTTPS", + timeoutMillis: aliyunCaptchaTimeoutMillis, + } +} + +// VerifyCaptcha 调用阿里云验证码 2.0 VerifyIntelligentCaptcha。 +// AK/SK 是可热更的后台设置,每次调用按当前凭证新建 client。 +func (v *aliyunCaptchaVerifier) VerifyCaptcha(ctx context.Context, cred service.AliyunCaptchaCredentials, captchaVerifyParam string) (*service.AliyunCaptchaVerifyResult, error) { + client, err := captcha.NewClient(&openapiutil.Config{ + AccessKeyId: dara.String(cred.AccessKeyID), + AccessKeySecret: dara.String(cred.AccessKeySecret), + Endpoint: dara.String(cred.Endpoint), + Protocol: dara.String(v.protocol), + ConnectTimeout: dara.Int(v.timeoutMillis), + ReadTimeout: dara.Int(v.timeoutMillis), + }) + if err != nil { + return nil, fmt.Errorf("create aliyun captcha client: %w", err) + } + + request := &captcha.VerifyIntelligentCaptchaRequest{ + CaptchaVerifyParam: dara.String(captchaVerifyParam), + SceneId: dara.String(cred.SceneID), + } + + response, err := client.VerifyIntelligentCaptchaWithContext(ctx, request, &dara.RuntimeOptions{}) + if err != nil { + return nil, normalizeAliyunCaptchaError(err) + } + + result := &service.AliyunCaptchaVerifyResult{} + if body := response.Body; body != nil && body.Result != nil { + result.VerifyResult = dara.BoolValue(body.Result.VerifyResult) + result.VerifyCode = dara.StringValue(body.Result.VerifyCode) + } + return result, nil +} + +// normalizeAliyunCaptchaError 把 SDK 的两种错误类型归一化为 service.AliyunCaptchaAPIError, +// 其余错误(网络/超时等)原样返回。 +func normalizeAliyunCaptchaError(err error) error { + var teaErr *tea.SDKError + if errors.As(err, &teaErr) { + return &service.AliyunCaptchaAPIError{ + Code: tea.StringValue(teaErr.Code), + Message: tea.StringValue(teaErr.Message), + } + } + var daraErr *dara.SDKError + if errors.As(err, &daraErr) { + return &service.AliyunCaptchaAPIError{ + Code: dara.StringValue(daraErr.Code), + Message: dara.StringValue(daraErr.Message), + } + } + return err +} diff --git a/backend/internal/repository/aliyun_captcha_verifier_test.go b/backend/internal/repository/aliyun_captcha_verifier_test.go new file mode 100644 index 0000000000..9469a64cdb --- /dev/null +++ b/backend/internal/repository/aliyun_captcha_verifier_test.go @@ -0,0 +1,93 @@ +package repository + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +// newAliyunCaptchaTestTarget 起一个假的阿里云端点,让真实 SDK 走完整的签名/序列化链路。 +func newAliyunCaptchaTestTarget(t *testing.T, handler http.HandlerFunc) (*aliyunCaptchaVerifier, service.AliyunCaptchaCredentials) { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + verifier := &aliyunCaptchaVerifier{protocol: "HTTP", timeoutMillis: 2_000} + cred := service.AliyunCaptchaCredentials{ + AccessKeyID: "test-ak-id", + AccessKeySecret: "test-ak-secret", + SceneID: "scene-1", + Endpoint: strings.TrimPrefix(server.URL, "http://"), + } + return verifier, cred +} + +func TestAliyunCaptchaVerifier_VerifySuccess(t *testing.T) { + var capturedParam, capturedSceneID string + verifier, cred := newAliyunCaptchaTestTarget(t, func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, r.ParseForm()) + capturedParam = r.Form.Get("CaptchaVerifyParam") + capturedSceneID = r.Form.Get("SceneId") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"Code":"Success","Message":"success","RequestId":"req-1","Success":true,"Result":{"VerifyResult":true,"VerifyCode":"T001"}}`)) + }) + + result, err := verifier.VerifyCaptcha(context.Background(), cred, "the-verify-param") + require.NoError(t, err) + require.True(t, result.VerifyResult) + require.Equal(t, "T001", result.VerifyCode) + require.Equal(t, "the-verify-param", capturedParam) + require.Equal(t, "scene-1", capturedSceneID) +} + +func TestAliyunCaptchaVerifier_VerifyResultFalse(t *testing.T) { + verifier, cred := newAliyunCaptchaTestTarget(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"Code":"Success","RequestId":"req-2","Success":true,"Result":{"VerifyResult":false,"VerifyCode":"F002"}}`)) + }) + + result, err := verifier.VerifyCaptcha(context.Background(), cred, "bad-param") + require.NoError(t, err) + require.False(t, result.VerifyResult) + require.Equal(t, "F002", result.VerifyCode) +} + +func TestAliyunCaptchaVerifier_APIErrorNormalized(t *testing.T) { + verifier, cred := newAliyunCaptchaTestTarget(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"Code":"SignatureDoesNotMatch","Message":"Specified signature is not matched with our calculation.","RequestId":"req-3"}`)) + }) + + _, err := verifier.VerifyCaptcha(context.Background(), cred, "param") + require.Error(t, err) + var apiErr *service.AliyunCaptchaAPIError + require.ErrorAs(t, err, &apiErr) + require.Equal(t, "SignatureDoesNotMatch", apiErr.Code) +} + +func TestAliyunCaptchaVerifier_TransportError(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + endpoint := strings.TrimPrefix(server.URL, "http://") + server.Close() // 立即关闭,制造连接失败 + + verifier := &aliyunCaptchaVerifier{protocol: "HTTP", timeoutMillis: 2_000} + cred := service.AliyunCaptchaCredentials{ + AccessKeyID: "test-ak-id", + AccessKeySecret: "test-ak-secret", + SceneID: "scene-1", + Endpoint: endpoint, + } + + _, err := verifier.VerifyCaptcha(context.Background(), cred, "param") + require.Error(t, err) + var apiErr *service.AliyunCaptchaAPIError + require.False(t, errors.As(err, &apiErr), "transport errors must not be normalized to API errors") +} diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index 5fbb007f37..84cecf59e4 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -150,6 +150,7 @@ var ProviderSet = wire.NewSet( // HTTP service ports (DI Strategy A: return interface directly) NewTurnstileVerifier, NewTencentCaptchaVerifier, + NewAliyunCaptchaVerifier, ProvidePricingRemoteClient, ProvideGitHubReleaseClient, NewProxyExitInfoProber, diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 0ed2925337..21ffe85542 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -746,6 +746,12 @@ func TestAPIContracts(t *testing.T) { "tencent_captcha_app_secret_key_configured": false, "tencent_captcha_cloud_secret_id_configured": false, "tencent_captcha_cloud_secret_key_configured": false, + "aliyun_captcha_enabled": false, + "aliyun_captcha_access_key_id": "", + "aliyun_captcha_access_key_secret_configured": false, + "aliyun_captcha_scene_id": "", + "aliyun_captcha_prefix": "", + "aliyun_captcha_region": "cn", "linuxdo_connect_enabled": false, "linuxdo_connect_client_id": "", "linuxdo_connect_client_secret_configured": false, @@ -1079,6 +1085,12 @@ func TestAPIContracts(t *testing.T) { "tencent_captcha_app_secret_key_configured": false, "tencent_captcha_cloud_secret_id_configured": false, "tencent_captcha_cloud_secret_key_configured": false, + "aliyun_captcha_enabled": false, + "aliyun_captcha_access_key_id": "", + "aliyun_captcha_access_key_secret_configured": false, + "aliyun_captcha_scene_id": "", + "aliyun_captcha_prefix": "", + "aliyun_captcha_region": "cn", "linuxdo_connect_enabled": false, "linuxdo_connect_client_id": "", "linuxdo_connect_client_secret_configured": false, diff --git a/backend/internal/service/aliyun_captcha_service.go b/backend/internal/service/aliyun_captcha_service.go new file mode 100644 index 0000000000..0a66320a22 --- /dev/null +++ b/backend/internal/service/aliyun_captcha_service.go @@ -0,0 +1,171 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" +) + +var ( + ErrAliyunCaptchaVerificationFailed = infraerrors.BadRequest("ALIYUN_CAPTCHA_VERIFICATION_FAILED", "aliyun captcha verification failed") + ErrAliyunCaptchaNotConfigured = infraerrors.ServiceUnavailable("ALIYUN_CAPTCHA_NOT_CONFIGURED", "aliyun captcha not configured") + // ErrCaptchaInvalidCredentials 阿里云验证码凭证无效(仅后台保存校验时返回,公开接口错误码不变) + ErrCaptchaInvalidCredentials = infraerrors.BadRequest("CAPTCHA_INVALID_CREDENTIALS", "invalid aliyun captcha credentials") +) + +// AliyunCaptchaCredentials 阿里云验证码 2.0 服务端校验所需的完整凭证 +type AliyunCaptchaCredentials struct { + AccessKeyID string + AccessKeySecret string + SceneID string + Endpoint string +} + +// AliyunCaptchaVerifyResult VerifyIntelligentCaptcha 的归一化结果 +type AliyunCaptchaVerifyResult struct { + VerifyResult bool + VerifyCode string // 阿里云细分结果码,仅用于日志 +} + +// AliyunCaptchaAPIError 阿里云 OpenAPI 业务错误。 +// repository 层负责把 SDK 错误归一化为该类型,service 层不依赖 SDK 包。 +type AliyunCaptchaAPIError struct { + Code string + Message string +} + +func (e *AliyunCaptchaAPIError) Error() string { + return fmt.Sprintf("aliyun captcha api error: %s: %s", e.Code, e.Message) +} + +// AliyunCaptchaVerifier 调用阿里云验证码 2.0 服务端校验的端口 +type AliyunCaptchaVerifier interface { + VerifyCaptcha(ctx context.Context, cred AliyunCaptchaCredentials, captchaVerifyParam string) (*AliyunCaptchaVerifyResult, error) +} + +const ( + // AliyunCaptchaRegionCN 中国内地;AliyunCaptchaRegionSGP 新加坡。 + // 该值同时下发给前端 AliyunCaptchaConfig.region,两端必须一致。 + AliyunCaptchaRegionCN = "cn" + AliyunCaptchaRegionSGP = "sgp" + + aliyunCaptchaEndpointCN = "captcha.cn-shanghai.aliyuncs.com" + aliyunCaptchaEndpointSGP = "captcha.ap-southeast-1.aliyuncs.com" +) + +// aliyunCaptchaEndpoint 按后台配置的地域返回服务端接入点,未知值回退中国内地 +func aliyunCaptchaEndpoint(region string) string { + if region == AliyunCaptchaRegionSGP { + return aliyunCaptchaEndpointSGP + } + return aliyunCaptchaEndpointCN +} + +// normalizeAliyunCaptchaRegion 非法值一律视为中国内地 +func normalizeAliyunCaptchaRegion(value string) string { + if value == AliyunCaptchaRegionSGP { + return AliyunCaptchaRegionSGP + } + return AliyunCaptchaRegionCN +} + +// aliyunCredentialValidationParam 用于后台保存时探测凭证有效性的假验证参数 +const aliyunCredentialValidationParam = "sub2api-credential-validation" + +// aliyunInvalidCredentialCodes 表示 AK/SK 本身无效的阿里云错误码; +// 其余错误码(如 param 无效)说明签名已通过、凭证可用。 +var aliyunInvalidCredentialCodes = map[string]struct{}{ + "InvalidAccessKeyId.NotFound": {}, + "InvalidAccessKeyId.Inactive": {}, + "SignatureDoesNotMatch": {}, + "Forbidden.AccessKeyDisabled": {}, + "IncompleteSignature": {}, + "InvalidSecurityToken.Expired": {}, +} + +// AliyunCaptchaService 阿里云验证码 2.0 服务端校验 +type AliyunCaptchaService struct { + settingService *SettingService + verifier AliyunCaptchaVerifier +} + +func NewAliyunCaptchaService(settingService *SettingService, verifier AliyunCaptchaVerifier) *AliyunCaptchaService { + return &AliyunCaptchaService{settingService: settingService, verifier: verifier} +} + +func aliyunCaptchaCredentials(config AliyunCaptchaConfig) (AliyunCaptchaCredentials, bool) { + cred := AliyunCaptchaCredentials{ + AccessKeyID: strings.TrimSpace(config.AccessKeyID), + AccessKeySecret: strings.TrimSpace(config.AccessKeySecret), + SceneID: strings.TrimSpace(config.SceneID), + Endpoint: aliyunCaptchaEndpoint(config.Region), + } + if cred.AccessKeyID == "" || cred.AccessKeySecret == "" || cred.SceneID == "" { + return AliyunCaptchaCredentials{}, false + } + return cred, true +} + +// VerifyParamWithConfig 校验阿里云验证码 2.0 的 captchaVerifyParam。 +// 调用异常时返回错误(fail-closed),与 Turnstile 网络错误行为对称。 +func (s *AliyunCaptchaService) VerifyParamWithConfig(ctx context.Context, config AliyunCaptchaConfig, captchaVerifyParam string) error { + if s == nil || s.verifier == nil { + return ErrAliyunCaptchaNotConfigured + } + cred, ok := aliyunCaptchaCredentials(config) + if !ok { + logger.LegacyPrintf("service.aliyun_captcha", "%s", "[AliyunCaptcha] credentials not configured") + return ErrAliyunCaptchaNotConfigured + } + + if strings.TrimSpace(captchaVerifyParam) == "" { + logger.LegacyPrintf("service.aliyun_captcha", "%s", "[AliyunCaptcha] captchaVerifyParam is empty") + return ErrAliyunCaptchaVerificationFailed + } + + result, err := s.verifier.VerifyCaptcha(ctx, cred, captchaVerifyParam) + if err != nil { + logger.LegacyPrintf("service.aliyun_captcha", "[AliyunCaptcha] verify request failed: %v", err) + return fmt.Errorf("%w: verifier request failed", ErrAliyunCaptchaVerificationFailed) + } + + if result == nil || !result.VerifyResult { + if result != nil { + logger.LegacyPrintf("service.aliyun_captcha", "[AliyunCaptcha] rejected, verify code: %s", result.VerifyCode) + } + return ErrAliyunCaptchaVerificationFailed + } + return nil +} + +// ValidateCredentials 用假验证参数探测阿里云 AK/SK 是否可用(后台保存设置时调用)。 +// 凭证类错误码返回 ErrCaptchaInvalidCredentials;正常响应(包括 param 无效导致的 +// VerifyResult=false)说明签名通过、凭证有效;其余错误原样返回给管理员排查。 +func (s *AliyunCaptchaService) ValidateCredentials(ctx context.Context, accessKeyID, accessKeySecret, sceneID, region string) error { + if s.verifier == nil { + return ErrAliyunCaptchaNotConfigured + } + cred := AliyunCaptchaCredentials{ + AccessKeyID: accessKeyID, + AccessKeySecret: accessKeySecret, + SceneID: sceneID, + Endpoint: aliyunCaptchaEndpoint(region), + } + + _, err := s.verifier.VerifyCaptcha(ctx, cred, aliyunCredentialValidationParam) + if err != nil { + var apiErr *AliyunCaptchaAPIError + if errors.As(err, &apiErr) { + if _, invalid := aliyunInvalidCredentialCodes[apiErr.Code]; invalid { + return ErrCaptchaInvalidCredentials + } + } + return fmt.Errorf("validate aliyun captcha credentials: %w", err) + } + + return nil +} diff --git a/backend/internal/service/aliyun_captcha_service_test.go b/backend/internal/service/aliyun_captcha_service_test.go new file mode 100644 index 0000000000..68b1e9e535 --- /dev/null +++ b/backend/internal/service/aliyun_captcha_service_test.go @@ -0,0 +1,233 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +type aliyunVerifierSpy struct { + called int + lastCred AliyunCaptchaCredentials + lastParam string + result *AliyunCaptchaVerifyResult + err error +} + +func (s *aliyunVerifierSpy) VerifyCaptcha(_ context.Context, cred AliyunCaptchaCredentials, param string) (*AliyunCaptchaVerifyResult, error) { + s.called++ + s.lastCred = cred + s.lastParam = param + if s.err != nil { + return nil, s.err + } + if s.result != nil { + return s.result, nil + } + return &AliyunCaptchaVerifyResult{VerifyResult: true}, nil +} + +func aliyunEnabledSettings() map[string]string { + return map[string]string{ + SettingKeyAliyunCaptchaEnabled: "true", + SettingKeyAliyunCaptchaAccessKeyID: "ak-id", + SettingKeyAliyunCaptchaAccessKeySecret: "ak-secret", + SettingKeyAliyunCaptchaSceneID: "scene-1", + SettingKeyAliyunCaptchaPrefix: "prefix-1", + } +} + +func aliyunTestConfig() AliyunCaptchaConfig { + return AliyunCaptchaConfig{ + Enabled: true, + AccessKeyID: "ak-id", + AccessKeySecret: "ak-secret", + SceneID: "scene-1", + Region: AliyunCaptchaRegionCN, + } +} + +func newAliyunAuthServiceForTest(cfg *config.Config, settings map[string]string, aliyunSpy *aliyunVerifierSpy) *AuthService { + settingService := NewSettingService(&settingPublicRepoStub{values: settings}, cfg) + authService := NewAuthService( + nil, // entClient + nil, // userRepo + nil, // redeemRepo + nil, // refreshTokenCache + cfg, + settingService, + nil, // emailService + NewTurnstileService(settingService, &turnstileVerifierSpy{}), + nil, // emailQueueService + nil, // promoService + nil, // defaultSubAssigner + nil, // affiliateService + nil, // userPlatformQuotaRepo + ) + authService.SetAliyunCaptchaService(NewAliyunCaptchaService(settingService, aliyunSpy)) + return authService +} + +func TestAliyunCaptchaServiceVerifyParamDispatch(t *testing.T) { + spy := &aliyunVerifierSpy{} + svc := NewAliyunCaptchaService(nil, spy) + + err := svc.VerifyParamWithConfig(context.Background(), aliyunTestConfig(), "captcha-verify-param") + + require.NoError(t, err) + require.Equal(t, 1, spy.called) + require.Equal(t, "captcha-verify-param", spy.lastParam) + require.Equal(t, "ak-id", spy.lastCred.AccessKeyID) + require.Equal(t, "scene-1", spy.lastCred.SceneID) + require.Equal(t, "captcha.cn-shanghai.aliyuncs.com", spy.lastCred.Endpoint) +} + +func TestAliyunCaptchaServiceSgpEndpoint(t *testing.T) { + spy := &aliyunVerifierSpy{} + svc := NewAliyunCaptchaService(nil, spy) + cfg := aliyunTestConfig() + cfg.Region = AliyunCaptchaRegionSGP + + err := svc.VerifyParamWithConfig(context.Background(), cfg, "captcha-verify-param") + + require.NoError(t, err) + require.Equal(t, "captcha.ap-southeast-1.aliyuncs.com", spy.lastCred.Endpoint) +} + +func TestAliyunCaptchaServiceFailsClosedOnVerifierError(t *testing.T) { + spy := &aliyunVerifierSpy{err: errors.New("network down")} + svc := NewAliyunCaptchaService(nil, spy) + + err := svc.VerifyParamWithConfig(context.Background(), aliyunTestConfig(), "captcha-verify-param") + + require.ErrorIs(t, err, ErrAliyunCaptchaVerificationFailed) +} + +func TestAliyunCaptchaServiceRejectsVerifyResultFalse(t *testing.T) { + spy := &aliyunVerifierSpy{result: &AliyunCaptchaVerifyResult{VerifyResult: false, VerifyCode: "F001"}} + svc := NewAliyunCaptchaService(nil, spy) + + err := svc.VerifyParamWithConfig(context.Background(), aliyunTestConfig(), "captcha-verify-param") + + require.ErrorIs(t, err, ErrAliyunCaptchaVerificationFailed) +} + +func TestAliyunCaptchaServiceRejectsIncompleteCredentials(t *testing.T) { + spy := &aliyunVerifierSpy{} + svc := NewAliyunCaptchaService(nil, spy) + cfg := aliyunTestConfig() + cfg.AccessKeySecret = "" + + err := svc.VerifyParamWithConfig(context.Background(), cfg, "captcha-verify-param") + + require.ErrorIs(t, err, ErrAliyunCaptchaNotConfigured) + require.Zero(t, spy.called) +} + +func TestAliyunCaptchaServiceRejectsEmptyParam(t *testing.T) { + spy := &aliyunVerifierSpy{} + svc := NewAliyunCaptchaService(nil, spy) + + err := svc.VerifyParamWithConfig(context.Background(), aliyunTestConfig(), "") + + require.ErrorIs(t, err, ErrAliyunCaptchaVerificationFailed) + require.Zero(t, spy.called) +} + +func TestAliyunCaptchaServiceValidateCredentials(t *testing.T) { + t.Run("invalid credential code", func(t *testing.T) { + spy := &aliyunVerifierSpy{err: &AliyunCaptchaAPIError{Code: "SignatureDoesNotMatch", Message: "bad sk"}} + svc := NewAliyunCaptchaService(nil, spy) + + err := svc.ValidateCredentials(context.Background(), "id", "sk", "scene", "cn") + require.ErrorIs(t, err, ErrCaptchaInvalidCredentials) + }) + + t.Run("network error surfaces", func(t *testing.T) { + spy := &aliyunVerifierSpy{err: errors.New("timeout")} + svc := NewAliyunCaptchaService(nil, spy) + + err := svc.ValidateCredentials(context.Background(), "id", "sk", "scene", "cn") + require.Error(t, err) + require.NotErrorIs(t, err, ErrCaptchaInvalidCredentials) + }) + + t.Run("verify result false means credentials valid", func(t *testing.T) { + spy := &aliyunVerifierSpy{result: &AliyunCaptchaVerifyResult{VerifyResult: false}} + svc := NewAliyunCaptchaService(nil, spy) + + err := svc.ValidateCredentials(context.Background(), "id", "sk", "scene", "sgp") + require.NoError(t, err) + require.Equal(t, "captcha.ap-southeast-1.aliyuncs.com", spy.lastCred.Endpoint) + }) +} + +func TestAuthServiceVerifyCaptchaDispatchesAliyun(t *testing.T) { + spy := &aliyunVerifierSpy{} + authService := newAliyunAuthServiceForTest(&config.Config{}, aliyunEnabledSettings(), spy) + + // 阿里云 captchaVerifyParam 复用 turnstile_token 请求字段 + err := authService.VerifyCaptcha(context.Background(), CaptchaProof{TurnstileToken: "captcha-verify-param"}, "127.0.0.1") + + require.NoError(t, err) + require.Equal(t, 1, spy.called) + require.Equal(t, "captcha-verify-param", spy.lastParam) +} + +func TestAuthServiceVerifyCaptchaRejectsProviderConflict(t *testing.T) { + settings := aliyunEnabledSettings() + settings[SettingKeyTurnstileEnabled] = "true" + settings[SettingKeyTurnstileSecretKey] = "secret" + spy := &aliyunVerifierSpy{} + authService := newAliyunAuthServiceForTest(&config.Config{}, settings, spy) + + err := authService.VerifyCaptcha(context.Background(), CaptchaProof{TurnstileToken: "param"}, "127.0.0.1") + + require.ErrorIs(t, err, ErrCaptchaProviderConflict) + require.Zero(t, spy.called) +} + +func TestAuthServiceVerifyCaptchaRequiredModeWithAliyun(t *testing.T) { + cfg := &config.Config{ + Server: config.ServerConfig{Mode: "release"}, + Turnstile: config.TurnstileConfig{Required: true}, + } + spy := &aliyunVerifierSpy{} + authService := newAliyunAuthServiceForTest(cfg, aliyunEnabledSettings(), spy) + + // required 模式 + 阿里云启用且凭证齐全:不误报 NOT_CONFIGURED,正常走阿里云校验 + err := authService.VerifyCaptcha(context.Background(), CaptchaProof{TurnstileToken: "captcha-verify-param"}, "127.0.0.1") + + require.NoError(t, err) + require.Equal(t, 1, spy.called) +} + +func TestAuthServiceVerifyActionCaptchaIfEnabledDispatchesAliyun(t *testing.T) { + spy := &aliyunVerifierSpy{} + authService := newAliyunAuthServiceForTest(&config.Config{}, aliyunEnabledSettings(), spy) + + err := authService.VerifyActionCaptchaIfEnabled(context.Background(), CaptchaProof{TurnstileToken: "captcha-verify-param"}, "127.0.0.1") + + require.NoError(t, err) + require.Equal(t, 1, spy.called) + require.Equal(t, "captcha-verify-param", spy.lastParam) +} + +func TestAuthServiceVerifyActionCaptchaIfEnabledSkipsWhenOnlyTurnstile(t *testing.T) { + spy := &aliyunVerifierSpy{} + authService := newAliyunAuthServiceForTest(&config.Config{}, map[string]string{ + SettingKeyTurnstileEnabled: "true", + SettingKeyTurnstileSecretKey: "secret", + }, spy) + + // Turnstile 不扩大既有覆盖:扩展入口不拦截 + err := authService.VerifyActionCaptchaIfEnabled(context.Background(), CaptchaProof{}, "127.0.0.1") + + require.NoError(t, err) + require.Zero(t, spy.called) +} diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index 3de2d4f6bb..c9d9c57eb2 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -76,6 +76,7 @@ type AuthService struct { emailService *EmailService turnstileService *TurnstileService tencentCaptchaService *TencentCaptchaService + aliyunCaptchaService *AliyunCaptchaService emailQueueService *EmailQueueService promoService *PromoService affiliateService *AffiliateService @@ -84,6 +85,7 @@ type AuthService struct { } type CaptchaProof struct { + // TurnstileToken 承载 Cloudflare Turnstile token;阿里云验证码复用该字段承载 captchaVerifyParam TurnstileToken string TencentTicket string TencentRandstr string @@ -144,6 +146,10 @@ func (s *AuthService) SetTencentCaptchaService(tencentCaptchaService *TencentCap s.tencentCaptchaService = tencentCaptchaService } +func (s *AuthService) SetAliyunCaptchaService(aliyunCaptchaService *AliyunCaptchaService) { + s.aliyunCaptchaService = aliyunCaptchaService +} + // Register 用户注册,返回token和用户 func (s *AuthService) Register(ctx context.Context, email, password string) (string, *User, error) { return s.RegisterWithVerification(ctx, email, password, "", "", "", "") @@ -412,7 +418,8 @@ func (s *AuthService) VerifyCaptcha(ctx context.Context, proof CaptchaProof, rem } turnstileEnabled := providerConfig.TurnstileEnabled tencentEnabled := providerConfig.Tencent.Enabled - if turnstileEnabled && tencentEnabled { + aliyunEnabled := providerConfig.Aliyun.Enabled + if captchaProvidersConflict(turnstileEnabled, tencentEnabled, aliyunEnabled) { return ErrCaptchaProviderConflict } if tencentEnabled { @@ -421,6 +428,12 @@ func (s *AuthService) VerifyCaptcha(ctx context.Context, proof CaptchaProof, rem } return s.tencentCaptchaService.VerifyTicketWithConfig(ctx, providerConfig.Tencent, proof.TencentTicket, proof.TencentRandstr, remoteIP) } + if aliyunEnabled { + if s.aliyunCaptchaService == nil { + return ErrAliyunCaptchaNotConfigured + } + return s.aliyunCaptchaService.VerifyParamWithConfig(ctx, providerConfig.Aliyun, proof.TurnstileToken) + } if turnstileEnabled { if s.turnstileService == nil || strings.TrimSpace(providerConfig.TurnstileSecretKey) == "" { return ErrTurnstileNotConfigured @@ -433,9 +446,20 @@ func (s *AuthService) VerifyCaptcha(ctx context.Context, proof CaptchaProof, rem return nil } -// VerifyTencentCaptchaIfEnabled 仅保护新增的腾讯验证码动作入口, -// 不扩大 Cloudflare Turnstile 的既有覆盖范围。 -func (s *AuthService) VerifyTencentCaptchaIfEnabled(ctx context.Context, proof CaptchaProof, remoteIP string) error { +// captchaProvidersConflict 同一时间仅允许启用一家人机验证服务商 +func captchaProvidersConflict(enabled ...bool) bool { + count := 0 + for _, e := range enabled { + if e { + count++ + } + } + return count > 1 +} + +// VerifyActionCaptchaIfEnabled 仅保护动作触发的扩展入口(OAuth 登录启动、passkey 登录), +// 腾讯天御与阿里云验证码启用时拦截;不扩大 Cloudflare Turnstile 的既有覆盖范围。 +func (s *AuthService) VerifyActionCaptchaIfEnabled(ctx context.Context, proof CaptchaProof, remoteIP string) error { if s == nil || s.settingService == nil { return ErrServiceUnavailable } @@ -445,12 +469,20 @@ func (s *AuthService) VerifyTencentCaptchaIfEnabled(ctx context.Context, proof C logger.LegacyPrintf("service.auth", "%s", "[Auth] Failed to read captcha provider settings") return ErrServiceUnavailable } - if !providerConfig.Tencent.Enabled { + tencentEnabled := providerConfig.Tencent.Enabled + aliyunEnabled := providerConfig.Aliyun.Enabled + if !tencentEnabled && !aliyunEnabled { return nil } - if providerConfig.TurnstileEnabled { + if captchaProvidersConflict(providerConfig.TurnstileEnabled, tencentEnabled, aliyunEnabled) { return ErrCaptchaProviderConflict } + if aliyunEnabled { + if s.aliyunCaptchaService == nil { + return ErrAliyunCaptchaNotConfigured + } + return s.aliyunCaptchaService.VerifyParamWithConfig(ctx, providerConfig.Aliyun, proof.TurnstileToken) + } if s.tencentCaptchaService == nil { return ErrTencentCaptchaNotConfigured } diff --git a/backend/internal/service/auth_service_captcha_test.go b/backend/internal/service/auth_service_captcha_test.go index 6de121dc36..343ff4ca55 100644 --- a/backend/internal/service/auth_service_captcha_test.go +++ b/backend/internal/service/auth_service_captcha_test.go @@ -150,11 +150,11 @@ func TestVerifyCaptchaRejectsEnabledTencentProviderWithIncompleteCredentials(t * require.Zero(t, verifier.calls) } -func TestVerifyTencentCaptchaIfEnabledVerifiesTencentProof(t *testing.T) { +func TestVerifyActionCaptchaIfEnabledVerifiesTencentProof(t *testing.T) { verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}} svc := newAuthServiceForCaptchaTest(tencentCaptchaSettings(), false, nil, verifier) - err := svc.VerifyTencentCaptchaIfEnabled(context.Background(), CaptchaProof{ + err := svc.VerifyActionCaptchaIfEnabled(context.Background(), CaptchaProof{ TencentTicket: "ticket", TencentRandstr: "@rand", }, "203.0.113.10") @@ -164,7 +164,7 @@ func TestVerifyTencentCaptchaIfEnabledVerifiesTencentProof(t *testing.T) { require.Equal(t, TencentCaptchaProof{Ticket: "ticket", Randstr: "@rand"}, verifier.proof) } -func TestVerifyTencentCaptchaIfEnabledDoesNotExpandTurnstileCoverage(t *testing.T) { +func TestVerifyActionCaptchaIfEnabledDoesNotExpandTurnstileCoverage(t *testing.T) { settings := map[string]string{ SettingKeyTurnstileEnabled: "true", SettingKeyTurnstileSecretKey: "turnstile-secret", @@ -172,17 +172,17 @@ func TestVerifyTencentCaptchaIfEnabledDoesNotExpandTurnstileCoverage(t *testing. turnstileVerifier := &turnstileVerifierSpy{} svc := newAuthServiceForCaptchaTest(settings, false, turnstileVerifier, nil) - err := svc.VerifyTencentCaptchaIfEnabled(context.Background(), CaptchaProof{}, "203.0.113.10") + err := svc.VerifyActionCaptchaIfEnabled(context.Background(), CaptchaProof{}, "203.0.113.10") require.NoError(t, err) require.Zero(t, turnstileVerifier.called) } -func TestVerifyTencentCaptchaIfEnabledFailsClosedOnSettingReadError(t *testing.T) { +func TestVerifyActionCaptchaIfEnabledFailsClosedOnSettingReadError(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") + err := svc.VerifyActionCaptchaIfEnabled(context.Background(), CaptchaProof{}, "203.0.113.10") require.ErrorIs(t, err, ErrServiceUnavailable) } diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index a505979fce..52197cc659 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -171,6 +171,14 @@ const ( SettingKeyTencentCaptchaCloudSecretID = "tencent_captcha_cloud_secret_id" SettingKeyTencentCaptchaCloudSecretKey = "tencent_captcha_cloud_secret_key" + // 阿里云验证码 2.0 设置(与 Turnstile、腾讯天御互斥,同一时间仅可启用一家) + SettingKeyAliyunCaptchaEnabled = "aliyun_captcha_enabled" // 是否启用阿里云验证码 + SettingKeyAliyunCaptchaAccessKeyID = "aliyun_captcha_access_key_id" // 阿里云 AccessKey ID + SettingKeyAliyunCaptchaAccessKeySecret = "aliyun_captcha_access_key_secret" // 阿里云 AccessKey Secret + SettingKeyAliyunCaptchaSceneID = "aliyun_captcha_scene_id" // 验证场景 ID(所有认证流程共用) + SettingKeyAliyunCaptchaPrefix = "aliyun_captcha_prefix" // 身份标,前端 SDK 初始化用 + SettingKeyAliyunCaptchaRegion = "aliyun_captcha_region" // 地域:"cn"|"sgp",决定前端脚本区域与服务端接入点 + // API Key IP 访问控制设置 SettingKeyAPIKeyACLTrustForwardedIP = "api_key_acl_trust_forwarded_ip" // API Key IP 白/黑名单是否信任转发 IP SettingKeyForwardedClientIPHeaders = "forwarded_client_ip_headers" // 自定义 CDN 客户端 IP 请求头(JSON 数组) diff --git a/backend/internal/service/setting_features.go b/backend/internal/service/setting_features.go index 8a7cc12c35..83747c57a3 100644 --- a/backend/internal/service/setting_features.go +++ b/backend/internal/service/setting_features.go @@ -465,10 +465,21 @@ type TencentCaptchaConfig struct { CloudSecretKey string } +// AliyunCaptchaConfig contains the credentials required by Aliyun Captcha 2.0's +// server-side verification API. It must never be returned by a public handler. +type AliyunCaptchaConfig struct { + Enabled bool + AccessKeyID string + AccessKeySecret string + SceneID string + Region string +} + type CaptchaProviderConfig struct { TurnstileEnabled bool TurnstileSecretKey string Tencent TencentCaptchaConfig + Aliyun AliyunCaptchaConfig } func (s *SettingService) GetCaptchaProviderConfig(ctx context.Context) (CaptchaProviderConfig, error) { @@ -480,6 +491,11 @@ func (s *SettingService) GetCaptchaProviderConfig(ctx context.Context) (CaptchaP SettingKeyTencentCaptchaAppSecretKey, SettingKeyTencentCaptchaCloudSecretID, SettingKeyTencentCaptchaCloudSecretKey, + SettingKeyAliyunCaptchaEnabled, + SettingKeyAliyunCaptchaAccessKeyID, + SettingKeyAliyunCaptchaAccessKeySecret, + SettingKeyAliyunCaptchaSceneID, + SettingKeyAliyunCaptchaRegion, }) if err != nil { return CaptchaProviderConfig{}, fmt.Errorf("read captcha provider settings: %w", err) @@ -494,6 +510,13 @@ func (s *SettingService) GetCaptchaProviderConfig(ctx context.Context) (CaptchaP CloudSecretID: values[SettingKeyTencentCaptchaCloudSecretID], CloudSecretKey: values[SettingKeyTencentCaptchaCloudSecretKey], }, + Aliyun: AliyunCaptchaConfig{ + Enabled: values[SettingKeyAliyunCaptchaEnabled] == "true", + AccessKeyID: values[SettingKeyAliyunCaptchaAccessKeyID], + AccessKeySecret: values[SettingKeyAliyunCaptchaAccessKeySecret], + SceneID: values[SettingKeyAliyunCaptchaSceneID], + Region: normalizeAliyunCaptchaRegion(values[SettingKeyAliyunCaptchaRegion]), + }, }, nil } diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index 3c311267e3..b0a356a6f2 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -329,6 +329,12 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin TencentCaptchaAppSecretKeyConfigured: settings[SettingKeyTencentCaptchaAppSecretKey] != "", TencentCaptchaCloudSecretIDConfigured: settings[SettingKeyTencentCaptchaCloudSecretID] != "", TencentCaptchaCloudSecretKeyConfigured: settings[SettingKeyTencentCaptchaCloudSecretKey] != "", + AliyunCaptchaEnabled: settings[SettingKeyAliyunCaptchaEnabled] == "true", + AliyunCaptchaAccessKeyID: settings[SettingKeyAliyunCaptchaAccessKeyID], + AliyunCaptchaAccessKeySecretConfigured: settings[SettingKeyAliyunCaptchaAccessKeySecret] != "", + AliyunCaptchaSceneID: settings[SettingKeyAliyunCaptchaSceneID], + AliyunCaptchaPrefix: settings[SettingKeyAliyunCaptchaPrefix], + AliyunCaptchaRegion: normalizeAliyunCaptchaRegion(settings[SettingKeyAliyunCaptchaRegion]), APIKeyACLTrustForwardedIP: apiKeyACLTrustForwardedIP, ForwardedClientIPHeaders: forwardedClientIPHeaders, SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), @@ -403,6 +409,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin result.TencentCaptchaAppSecretKey = settings[SettingKeyTencentCaptchaAppSecretKey] result.TencentCaptchaCloudSecretID = settings[SettingKeyTencentCaptchaCloudSecretID] result.TencentCaptchaCloudSecretKey = settings[SettingKeyTencentCaptchaCloudSecretKey] + result.AliyunCaptchaAccessKeySecret = settings[SettingKeyAliyunCaptchaAccessKeySecret] // LinuxDo Connect 设置: // - 兼容 config.yaml/env(避免老部署因为未迁移到数据库设置而被意外关闭) diff --git a/backend/internal/service/setting_public.go b/backend/internal/service/setting_public.go index 8bb44fc40e..a05ed44a28 100644 --- a/backend/internal/service/setting_public.go +++ b/backend/internal/service/setting_public.go @@ -173,6 +173,10 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyTurnstileSiteKey, SettingKeyTencentCaptchaEnabled, SettingKeyTencentCaptchaAppID, + SettingKeyAliyunCaptchaEnabled, + SettingKeyAliyunCaptchaSceneID, + SettingKeyAliyunCaptchaPrefix, + SettingKeyAliyunCaptchaRegion, SettingKeyAPIKeyACLTrustForwardedIP, SettingKeySiteName, SettingKeySiteLogo, @@ -305,6 +309,10 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], TencentCaptchaEnabled: settings[SettingKeyTencentCaptchaEnabled] == "true", TencentCaptchaAppID: settings[SettingKeyTencentCaptchaAppID], + AliyunCaptchaEnabled: settings[SettingKeyAliyunCaptchaEnabled] == "true", + AliyunCaptchaSceneID: settings[SettingKeyAliyunCaptchaSceneID], + AliyunCaptchaPrefix: settings[SettingKeyAliyunCaptchaPrefix], + AliyunCaptchaRegion: normalizeAliyunCaptchaRegion(settings[SettingKeyAliyunCaptchaRegion]), SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), SiteLogo: settings[SettingKeySiteLogo], SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), @@ -496,6 +504,10 @@ type PublicSettingsInjectionPayload struct { TurnstileSiteKey string `json:"turnstile_site_key"` TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"` TencentCaptchaAppID string `json:"tencent_captcha_app_id"` + AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"` + AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"` + AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"` + AliyunCaptchaRegion string `json:"aliyun_captcha_region"` SiteName string `json:"site_name"` SiteLogo string `json:"site_logo"` SiteSubtitle string `json:"site_subtitle"` @@ -571,6 +583,10 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any TurnstileSiteKey: settings.TurnstileSiteKey, TencentCaptchaEnabled: settings.TencentCaptchaEnabled, TencentCaptchaAppID: settings.TencentCaptchaAppID, + AliyunCaptchaEnabled: settings.AliyunCaptchaEnabled, + AliyunCaptchaSceneID: settings.AliyunCaptchaSceneID, + AliyunCaptchaPrefix: settings.AliyunCaptchaPrefix, + AliyunCaptchaRegion: settings.AliyunCaptchaRegion, SiteName: settings.SiteName, SiteLogo: settings.SiteLogo, SiteSubtitle: settings.SiteSubtitle, diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index 0677360968..e086a6bcf3 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -221,6 +221,15 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting if settings.TencentCaptchaCloudSecretKey != "" { updates[SettingKeyTencentCaptchaCloudSecretKey] = settings.TencentCaptchaCloudSecretKey } + // 阿里云验证码 2.0 设置(只有非空才更新密钥) + updates[SettingKeyAliyunCaptchaEnabled] = strconv.FormatBool(settings.AliyunCaptchaEnabled) + updates[SettingKeyAliyunCaptchaAccessKeyID] = settings.AliyunCaptchaAccessKeyID + if settings.AliyunCaptchaAccessKeySecret != "" { + updates[SettingKeyAliyunCaptchaAccessKeySecret] = settings.AliyunCaptchaAccessKeySecret + } + updates[SettingKeyAliyunCaptchaSceneID] = settings.AliyunCaptchaSceneID + updates[SettingKeyAliyunCaptchaPrefix] = settings.AliyunCaptchaPrefix + updates[SettingKeyAliyunCaptchaRegion] = normalizeAliyunCaptchaRegion(settings.AliyunCaptchaRegion) updates[SettingKeyAPIKeyACLTrustForwardedIP] = strconv.FormatBool(settings.APIKeyACLTrustForwardedIP) forwardedClientIPHeadersJSON, err := json.Marshal(settings.ForwardedClientIPHeaders) if err != nil { diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index c70af50661..ceae9f270b 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -50,6 +50,13 @@ type SystemSettings struct { TencentCaptchaCloudSecretIDConfigured bool TencentCaptchaCloudSecretKey string TencentCaptchaCloudSecretKeyConfigured bool + AliyunCaptchaEnabled bool + AliyunCaptchaAccessKeyID string + AliyunCaptchaAccessKeySecret string + AliyunCaptchaAccessKeySecretConfigured bool + AliyunCaptchaSceneID string + AliyunCaptchaPrefix string + AliyunCaptchaRegion string APIKeyACLTrustForwardedIP bool ForwardedClientIPHeaders []string @@ -312,6 +319,10 @@ type PublicSettings struct { TurnstileSiteKey string TencentCaptchaEnabled bool TencentCaptchaAppID string + AliyunCaptchaEnabled bool + AliyunCaptchaSceneID string + AliyunCaptchaPrefix string + AliyunCaptchaRegion string SiteName string SiteLogo string SiteSubtitle string diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 6b169d1875..0e9f61c95a 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -53,6 +53,7 @@ func ProvideAuthService( emailService *EmailService, turnstileService *TurnstileService, tencentCaptchaService *TencentCaptchaService, + aliyunCaptchaService *AliyunCaptchaService, emailQueueService *EmailQueueService, promoService *PromoService, defaultSubAssigner DefaultSubscriptionAssigner, @@ -75,6 +76,7 @@ func ProvideAuthService( userPlatformQuotaRepo, ) svc.SetTencentCaptchaService(tencentCaptchaService) + svc.SetAliyunCaptchaService(aliyunCaptchaService) return svc } @@ -799,6 +801,7 @@ var ProviderSet = wire.NewSet( ProvideEmailQueueService, NewTurnstileService, NewTencentCaptchaService, + NewAliyunCaptchaService, NewSubscriptionService, wire.Bind(new(DefaultSubscriptionAssigner), new(*SubscriptionService)), ProvideConcurrencyService, diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index fd10deda57..39f6ade423 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -181,7 +181,7 @@ security: # 默认 CSP 策略(如果静态资源托管在其他域名,请自行覆盖) # Note: __CSP_NONCE__ will be replaced with 'nonce-xxx' at request time for inline script security # 注意:__CSP_NONCE__ 会在请求时被替换为 'nonce-xxx',用于内联脚本安全 - policy: "default-src 'self'; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://static.cloudflareinsights.com https://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'" + policy: "default-src 'self'; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" proxy_probe: # Allow skipping TLS verification for proxy probe (debug only) # 允许代理探测时跳过 TLS 证书验证(仅用于调试) diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index f954081bc8..079556fe98 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -464,6 +464,12 @@ export interface SystemSettings { tencent_captcha_app_secret_key_configured: boolean; tencent_captcha_cloud_secret_id_configured: boolean; tencent_captcha_cloud_secret_key_configured: boolean; + aliyun_captcha_enabled: boolean; + aliyun_captcha_access_key_id: string; + aliyun_captcha_access_key_secret_configured: boolean; + aliyun_captcha_scene_id: string; + aliyun_captcha_prefix: string; + aliyun_captcha_region: string; api_key_acl_trust_forwarded_ip: boolean; forwarded_client_ip_headers: string[]; @@ -783,6 +789,12 @@ export interface UpdateSettingsRequest { tencent_captcha_app_secret_key?: string; tencent_captcha_cloud_secret_id?: string; tencent_captcha_cloud_secret_key?: string; + aliyun_captcha_enabled?: boolean; + aliyun_captcha_access_key_id?: string; + aliyun_captcha_access_key_secret?: string; + aliyun_captcha_scene_id?: string; + aliyun_captcha_prefix?: string; + aliyun_captcha_region?: string; api_key_acl_trust_forwarded_ip?: boolean; forwarded_client_ip_headers?: string[]; linuxdo_connect_enabled?: boolean; diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 1ede775d62..6f880e3482 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -14,7 +14,7 @@ import type { SendVerifyCodeRequest, SendVerifyCodeResponse, PublicSettings, - TencentCaptchaRequestProof, + ActionCaptchaRequestProof, TotpLoginResponse, TotpLogin2FARequest } from '@/types' @@ -51,7 +51,7 @@ export function buildOAuthLoginStartURL(request: OAuthLoginStart): string { export async function startOAuthLogin( request: OAuthLoginStart, - proof: TencentCaptchaRequestProof + proof: ActionCaptchaRequestProof ): Promise { const { data } = await apiClient.post( `/auth/oauth/${request.provider}/start`, diff --git a/frontend/src/api/passkey.ts b/frontend/src/api/passkey.ts index 5b87616622..5d010aeb5e 100644 --- a/frontend/src/api/passkey.ts +++ b/frontend/src/api/passkey.ts @@ -1,5 +1,5 @@ import { apiClient } from './client' -import type { AuthResponse, TencentCaptchaRequestProof } from '@/types' +import type { ActionCaptchaRequestProof, AuthResponse } from '@/types' export interface PasskeyCredentialSummary { id: number @@ -104,7 +104,7 @@ function serializeAssertionCredential(credential: PublicKeyCredential): Record { +async function login(proof?: ActionCaptchaRequestProof): Promise { requirePasskeySupport() const { data: begin } = proof ? await apiClient.post('/auth/passkey/login/begin', proof) diff --git a/frontend/src/components/AliyunCaptchaWidget.vue b/frontend/src/components/AliyunCaptchaWidget.vue new file mode 100644 index 0000000000..6eec329a66 --- /dev/null +++ b/frontend/src/components/AliyunCaptchaWidget.vue @@ -0,0 +1,363 @@ + + + + + diff --git a/frontend/src/components/CaptchaChallenge.vue b/frontend/src/components/CaptchaChallenge.vue index ba9301307a..cfb3bde1fb 100644 --- a/frontend/src/components/CaptchaChallenge.vue +++ b/frontend/src/components/CaptchaChallenge.vue @@ -12,13 +12,30 @@ ref="tencentRef" :app-id="tencentAppId" /> + diff --git a/frontend/src/components/__tests__/AliyunCaptchaWidget.spec.ts b/frontend/src/components/__tests__/AliyunCaptchaWidget.spec.ts new file mode 100644 index 0000000000..6158b856f8 --- /dev/null +++ b/frontend/src/components/__tests__/AliyunCaptchaWidget.spec.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mount } from '@vue/test-utils' +import AliyunCaptchaWidget from '../AliyunCaptchaWidget.vue' + +interface CapturedInitOptions { + SceneId: string + prefix: string + mode: string + element: string + button: string + captchaVerifyCallback: (param: string) => { captchaResult: boolean } + language?: string +} + +const i18nStub = { + install(app: { config: { globalProperties: Record } }) { + app.config.globalProperties.$t = (key: string) => key + } +} + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ locale: { value: 'zh-CN' }, t: (key: string) => key }) +})) + +describe('AliyunCaptchaWidget', () => { + let initOptions: CapturedInitOptions | null + + beforeEach(() => { + initOptions = null + window.initAliyunCaptcha = vi.fn((options: CapturedInitOptions) => { + initOptions = options + }) as unknown as typeof window.initAliyunCaptcha + }) + + afterEach(() => { + vi.useRealTimers() + delete window.initAliyunCaptcha + delete window.AliyunCaptchaConfig + document.getElementById('aliyunCaptcha-window-popup')?.remove() + document.getElementById('aliyunCaptcha-mask')?.remove() + }) + + function mountWidget() { + return mount(AliyunCaptchaWidget, { + props: { sceneId: 'scene-1', prefix: 'prefix-1', region: 'cn' as const }, + attachTo: document.body, + global: { plugins: [i18nStub] } + }) + } + + function createVisiblePopup(): HTMLElement { + const popup = document.createElement('div') + popup.id = 'aliyunCaptcha-window-popup' + popup.style.display = 'block' + document.body.appendChild(popup) + return popup + } + + it('渲染可见验证按钮并以 popup 模式初始化,全局配置就位', async () => { + const wrapper = mountWidget() + await Promise.resolve() + await Promise.resolve() + + const button = wrapper.get('button') + expect(button.text()).toContain('auth.captchaClickToVerify') + expect(window.AliyunCaptchaConfig).toEqual({ region: 'cn', prefix: 'prefix-1' }) + expect(initOptions).not.toBeNull() + expect(initOptions!.mode).toBe('popup') + expect(initOptions!.SceneId).toBe('scene-1') + expect(initOptions!.language).toBe('cn') + + wrapper.unmount() + }) + + it('用户点击按钮进入验证中,验证完成后 emit verify 并置已通过', async () => { + const wrapper = mountWidget() + await Promise.resolve() + await Promise.resolve() + + await wrapper.get('button').trigger('click') + expect(wrapper.get('button').text()).toContain('auth.captchaVerifying') + + const result = initOptions!.captchaVerifyCallback('captcha-param-1') + expect(result).toEqual({ captchaResult: true }) + await wrapper.vm.$nextTick() + + expect(wrapper.emitted('verify')).toEqual([['captcha-param-1']]) + expect(wrapper.get('button').text()).toContain('auth.captchaVerified') + expect(wrapper.get('button').attributes('disabled')).toBeDefined() + + wrapper.unmount() + }) + + it('verify() 在已预验证时直接复用缓存的 captchaVerifyParam', async () => { + const wrapper = mountWidget() + await Promise.resolve() + await Promise.resolve() + + await wrapper.get('button').trigger('click') + initOptions!.captchaVerifyCallback('captcha-param-2') + + const vm = wrapper.vm as unknown as { verify: () => Promise } + await expect(vm.verify()).resolves.toBe('captcha-param-2') + + wrapper.unmount() + }) + + it('verify() 在未预验证时触发弹窗流程并等待结果', async () => { + const wrapper = mountWidget() + await Promise.resolve() + await Promise.resolve() + + const vm = wrapper.vm as unknown as { verify: () => Promise } + const pending = vm.verify() + await Promise.resolve() + await Promise.resolve() + await wrapper.vm.$nextTick() + expect(wrapper.get('button').text()).toContain('auth.captchaVerifying') + + initOptions!.captchaVerifyCallback('captcha-param-3') + await expect(pending).resolves.toBe('captcha-param-3') + + wrapper.unmount() + }) + + it('弹窗未出现前会按 tick 重试触发按钮(SDK 异步绑定兜底)', async () => { + vi.useFakeTimers() + const wrapper = mountWidget() + await Promise.resolve() + await Promise.resolve() + + const vm = wrapper.vm as unknown as { verify: () => Promise } + void vm.verify() + await Promise.resolve() + await Promise.resolve() + + const button = wrapper.get('button').element as HTMLButtonElement + const clickSpy = vi.fn() + button.addEventListener('click', clickSpy) + + await vi.advanceTimersByTimeAsync(1000) + expect(clickSpy.mock.calls.length).toBeGreaterThanOrEqual(3) + + button.removeEventListener('click', clickSpy) + wrapper.unmount() + }) + + it('弹窗出现后被用户关闭时 resolve null 并回到未验证态', async () => { + vi.useFakeTimers() + const wrapper = mountWidget() + await Promise.resolve() + await Promise.resolve() + + const vm = wrapper.vm as unknown as { verify: () => Promise } + const pending = vm.verify() + await Promise.resolve() + await Promise.resolve() + + const popup = createVisiblePopup() + await vi.advanceTimersByTimeAsync(400) + popup.remove() + await vi.advanceTimersByTimeAsync(400) + + await expect(pending).resolves.toBeNull() + expect(wrapper.get('button').text()).toContain('auth.captchaClickToVerify') + + wrapper.unmount() + }) + + it('reset() 清空缓存并取消进行中的验证', async () => { + const wrapper = mountWidget() + await Promise.resolve() + await Promise.resolve() + + await wrapper.get('button').trigger('click') + initOptions!.captchaVerifyCallback('captcha-param-4') + + const vm = wrapper.vm as unknown as { + verify: () => Promise + reset: () => void + } + vm.reset() + await wrapper.vm.$nextTick() + expect(wrapper.get('button').text()).toContain('auth.captchaClickToVerify') + + // reset 后缓存失效,verify() 重新走弹窗流程 + const pending = vm.verify() + await Promise.resolve() + initOptions!.captchaVerifyCallback('captcha-param-5') + await expect(pending).resolves.toBe('captcha-param-5') + + wrapper.unmount() + }) +}) + +declare global { + interface Window { + initAliyunCaptcha?: (options: unknown) => void + AliyunCaptchaConfig?: { region: string; prefix: string } + } +} diff --git a/frontend/src/components/auth/PendingOAuthCreateAccountForm.vue b/frontend/src/components/auth/PendingOAuthCreateAccountForm.vue index ff8721dbdc..319cf364b6 100644 --- a/frontend/src/components/auth/PendingOAuthCreateAccountForm.vue +++ b/frontend/src/components/auth/PendingOAuthCreateAccountForm.vue @@ -24,6 +24,10 @@ :turnstile-site-key="turnstileSiteKey" :tencent-enabled="tencentCaptchaEnabled" :tencent-app-id="tencentCaptchaAppId" + :aliyun-enabled="aliyunCaptchaEnabled" + :aliyun-scene-id="aliyunCaptchaSceneId" + :aliyun-prefix="aliyunCaptchaPrefix" + :aliyun-region="aliyunCaptchaRegion" @verify="onTurnstileVerify" @expire="onTurnstileExpire" @error="onTurnstileError" @@ -137,13 +141,28 @@ const turnstileEnabled = ref(false) const turnstileSiteKey = ref('') const tencentCaptchaEnabled = ref(false) const tencentCaptchaAppId = ref('') +const aliyunCaptchaEnabled = ref(false) +const aliyunCaptchaSceneId = ref('') +const aliyunCaptchaPrefix = ref('') +const aliyunCaptchaRegion = ref('cn') const turnstileToken = ref('') const tencentCaptchaRandstr = ref('') const turnstileRef = ref | null>(null) +const aliyunCaptchaReady = computed( + () => + aliyunCaptchaEnabled.value && + Boolean(aliyunCaptchaSceneId.value) && + Boolean(aliyunCaptchaPrefix.value) +) +// 动作触发式验证码(腾讯/阿里云):发送验证码、提交时弹窗验证 +const actionCaptchaEnabled = computed( + () => + (tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value)) || + aliyunCaptchaReady.value +) const captchaEnabled = computed( () => - (turnstileEnabled.value && Boolean(turnstileSiteKey.value)) || - (tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value)) + (turnstileEnabled.value && Boolean(turnstileSiteKey.value)) || actionCaptchaEnabled.value ) let countdownTimer: ReturnType | null = null @@ -229,13 +248,13 @@ function onTurnstileError() { sendCodeError.value = t('auth.turnstileFailed') } -async function acquireTencentProof(): Promise { - if (!tencentCaptchaEnabled.value) return true +async function acquireActionProof(): Promise { + if (!actionCaptchaEnabled.value) return true - const proof = await turnstileRef.value?.verifyTencent() + const proof = await turnstileRef.value?.verifyAction() if (!proof) return false - turnstileToken.value = proof.ticket + turnstileToken.value = proof.token tencentCaptchaRandstr.value = proof.randstr return true } @@ -251,7 +270,7 @@ async function handleSendCode() { return } - if (!(await acquireTencentProof())) { + if (!(await acquireActionProof())) { return } @@ -262,7 +281,8 @@ async function handleSendCode() { try { const response = await sendPendingOAuthVerifyCode({ email: trimmedEmail, - turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined, + turnstile_token: + turnstileEnabled.value || aliyunCaptchaEnabled.value ? turnstileToken.value : undefined, tencent_captcha_ticket: tencentCaptchaEnabled.value ? turnstileToken.value : undefined, tencent_captcha_randstr: tencentCaptchaEnabled.value ? tencentCaptchaRandstr.value : undefined }) @@ -292,7 +312,7 @@ async function handleSubmit() { return } - if (!(await acquireTencentProof())) { + if (!(await acquireActionProof())) { return } @@ -300,7 +320,9 @@ async function handleSubmit() { email: trimmedEmail, password: password.value, verifyCode: emailVerifyEnabled.value ? verifyCode.value.trim() : '', - ...(turnstileEnabled.value && turnstileToken.value ? { turnstileToken: turnstileToken.value } : {}), + ...((turnstileEnabled.value || aliyunCaptchaEnabled.value) && turnstileToken.value + ? { turnstileToken: turnstileToken.value } + : {}), ...(tencentCaptchaEnabled.value && turnstileToken.value ? { tencentCaptchaTicket: turnstileToken.value, @@ -310,7 +332,7 @@ async function handleSubmit() { invitationCode: invitationCode.value.trim() || undefined }) - if (tencentCaptchaEnabled.value) { + if (actionCaptchaEnabled.value) { resetTurnstile() } } @@ -328,6 +350,10 @@ onMounted(async () => { turnstileSiteKey.value = settings.turnstile_site_key || '' tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true tencentCaptchaAppId.value = settings.tencent_captcha_app_id || '' + aliyunCaptchaEnabled.value = settings.aliyun_captcha_enabled === true + aliyunCaptchaSceneId.value = settings.aliyun_captcha_scene_id || '' + aliyunCaptchaPrefix.value = settings.aliyun_captcha_prefix || '' + aliyunCaptchaRegion.value = settings.aliyun_captcha_region || 'cn' } catch { invitationCodeEnabled.value = false emailVerifyEnabled.value = true @@ -335,6 +361,10 @@ onMounted(async () => { turnstileSiteKey.value = '' tencentCaptchaEnabled.value = false tencentCaptchaAppId.value = '' + aliyunCaptchaEnabled.value = false + aliyunCaptchaSceneId.value = '' + aliyunCaptchaPrefix.value = '' + aliyunCaptchaRegion.value = 'cn' } }) diff --git a/frontend/src/components/auth/__tests__/PendingOAuthCreateAccountForm.spec.ts b/frontend/src/components/auth/__tests__/PendingOAuthCreateAccountForm.spec.ts index 0485db1e9a..a1a5841ea5 100644 --- a/frontend/src/components/auth/__tests__/PendingOAuthCreateAccountForm.spec.ts +++ b/frontend/src/components/auth/__tests__/PendingOAuthCreateAccountForm.spec.ts @@ -9,7 +9,7 @@ const sendPendingOAuthVerifyCode = vi.fn() const getPublicSettings = vi.fn() const showError = vi.fn() const turnstileReset = vi.fn() -const verifyTencent = vi.fn() +const verifyAction = vi.fn() vi.mock('vue-i18n', async () => { const actual = await vi.importActual('vue-i18n') @@ -44,7 +44,7 @@ describe('PendingOAuthCreateAccountForm', () => { getPublicSettings.mockReset() showError.mockReset() turnstileReset.mockReset() - verifyTencent.mockReset() + verifyAction.mockReset() getPublicSettings.mockResolvedValue({ turnstile_enabled: false, turnstile_site_key: '' @@ -60,12 +60,12 @@ describe('PendingOAuthCreateAccountForm', () => { tencent_captcha_app_id: 'tencent-app-id' }) sendPendingOAuthVerifyCode.mockResolvedValue({ countdown: 0 }) - verifyTencent - .mockResolvedValueOnce({ ticket: 'ticket-1', randstr: '@rand-1' }) - .mockResolvedValueOnce({ ticket: 'ticket-2', randstr: '@rand-2' }) + verifyAction + .mockResolvedValueOnce({ token: 'ticket-1', randstr: '@rand-1' }) + .mockResolvedValueOnce({ token: 'ticket-2', randstr: '@rand-2' }) const CaptchaChallengeStub = defineComponent({ setup(_, { expose }) { - expose({ verifyTencent, reset: turnstileReset }) + expose({ verifyAction, reset: turnstileReset }) return () => h('div') } }) @@ -89,7 +89,7 @@ describe('PendingOAuthCreateAccountForm', () => { await wrapper.get('[data-testid="oidc-create-account-submit"]').trigger('click') await flushPromises() - expect(verifyTencent).toHaveBeenCalledTimes(2) + expect(verifyAction).toHaveBeenCalledTimes(2) expect(sendPendingOAuthVerifyCode).toHaveBeenCalledWith({ email: 'user@example.com', tencent_captcha_ticket: 'ticket-1', diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index fda93b2f15..586a3a53c1 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -183,6 +183,16 @@ export default { secretKeyHint: 'Server-side verification key (keep this secret)', secretKeyConfiguredHint: 'Secret key configured. Leave empty to keep the current value.' }, + captcha: { + title: 'CAPTCHA', + description: 'Bot protection for login and registration', + enable: 'Enable CAPTCHA', + enableHint: 'Require human verification on login, registration and related flows', + provider: 'Provider', + providerTurnstile: 'Cloudflare Turnstile', + providerTencent: 'Tencent Captcha', + providerAliyun: 'Aliyun Captcha 2.0' + }, tencentCaptcha: { title: 'Tencent Captcha', description: 'Slider captcha protection for login, registration, and third-party account creation', @@ -191,7 +201,7 @@ export default { keepExisting: 'Leave empty to keep current value', configured: 'Configured. Leave empty to keep it.', required: 'Required before enabling.', - mutualExclusion: 'Tencent Captcha and Cloudflare Turnstile are mutually exclusive. Enabling one disables the other.', + mutualExclusion: 'Tencent Captcha, Cloudflare Turnstile and Aliyun Captcha are mutually exclusive. Enabling one disables the others.', appCredentialsTitle: 'Captcha application credentials', appCredentialsHint: 'Get CaptchaAppId and AppSecretKey from Verification Management in the Captcha console.', cloudCredentialsTitle: 'Cloud API credentials', @@ -206,6 +216,21 @@ export default { createCloudKeys: 'Create SecretId / SecretKey', openWebDocs: 'View Web integration guide' }, + aliyunCaptcha: { + accessKeyId: 'AccessKey ID', + accessKeyIdHint: 'Alibaba Cloud AccessKey ID used for server-side verification; a captcha-only RAM user is recommended', + accessKeySecret: 'AccessKey Secret', + accessKeySecretHint: 'Server-side verification secret (keep this secret)', + accessKeySecretConfiguredHint: 'Secret configured. Leave empty to keep the current value.', + sceneId: 'Scene ID', + sceneIdHint: 'Create a verification scene in the Alibaba Cloud Captcha console; the captcha type (invisible/slider/puzzle) is configured per scene there', + prefix: 'Prefix', + prefixHint: 'Found in the instance information on the console overview page', + region: 'Region', + regionCn: 'Mainland China', + regionSgp: 'Singapore', + regionHint: 'Determines the frontend script region and the server endpoint; must match your captcha instance region' + }, apiKeyAcl: { title: 'API Key IP Access Control', description: diff --git a/frontend/src/i18n/locales/en/common.ts b/frontend/src/i18n/locales/en/common.ts index 988500c226..6ebcca2977 100644 --- a/frontend/src/i18n/locales/en/common.ts +++ b/frontend/src/i18n/locales/en/common.ts @@ -245,6 +245,8 @@ export default { turnstileFailed: 'Verification failed, please try again', captchaVerified: 'Verification completed', captchaLoading: 'Loading verification…', + captchaClickToVerify: 'Click to complete verification', + captchaVerifying: 'Verifying…', completeVerification: 'Please complete the verification', verifyYourEmail: 'Verify Your Email', sessionExpired: 'Session expired', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index c99497fc69..646cf56018 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -183,6 +183,16 @@ export default { secretKeyHint: '服务端验证密钥(请保密)', secretKeyConfiguredHint: '密钥已配置,留空以保留当前值。' }, + captcha: { + title: '人机验证', + description: '登录和注册的机器人防护', + enable: '启用人机验证', + enableHint: '开启后登录、注册等入口需要通过人机验证', + provider: '验证服务商', + providerTurnstile: 'Cloudflare Turnstile', + providerTencent: '腾讯天御验证码', + providerAliyun: '阿里云验证码 2.0' + }, tencentCaptcha: { title: '腾讯天御验证码', description: '为登录、注册及第三方登录创建账号流程提供滑动验证码保护', @@ -191,7 +201,7 @@ export default { keepExisting: '留空以保留当前值', configured: '已配置,留空不会覆盖。', required: '启用前必须填写此项。', - mutualExclusion: '腾讯天御验证码与 Cloudflare Turnstile 互斥,开启其中一个会自动关闭另一个。', + mutualExclusion: '腾讯天御验证码与 Cloudflare Turnstile、阿里云验证码互斥,开启其中一个会自动关闭其它。', appCredentialsTitle: '验证码应用密钥', appCredentialsHint: 'CaptchaAppId 与 AppSecretKey 来自验证码控制台的验证管理页面。', cloudCredentialsTitle: '云 API 调用密钥', @@ -206,6 +216,21 @@ export default { createCloudKeys: '创建 SecretId / SecretKey', openWebDocs: '查看 Web 接入文档' }, + aliyunCaptcha: { + accessKeyId: 'AccessKey ID', + accessKeyIdHint: '用于服务端验证的阿里云 AccessKey ID,建议使用仅含验证码权限的 RAM 子账号', + accessKeySecret: 'AccessKey Secret', + accessKeySecretHint: '服务端验证密钥(请保密)', + accessKeySecretConfiguredHint: '密钥已配置,留空以保留当前值。', + sceneId: '场景 ID', + sceneIdHint: '在阿里云验证码控制台创建验证场景后获取;验证方式(无痕/滑块/拼图)在控制台按场景配置', + prefix: '身份标(prefix)', + prefixHint: '在控制台概览页实例基本信息中获取', + region: '服务地域', + regionCn: '中国内地', + regionSgp: '新加坡', + regionHint: '决定前端脚本接入区域与服务端接入点,需与阿里云验证码实例所属地域一致' + }, apiKeyAcl: { title: 'API Key IP 访问控制', description: '控制 API Key 白/黑名单、操作审计日志与会话 IP/UA 绑定使用哪个客户端 IP 判断', diff --git a/frontend/src/i18n/locales/zh/common.ts b/frontend/src/i18n/locales/zh/common.ts index ce611fcca1..192c30c5d8 100644 --- a/frontend/src/i18n/locales/zh/common.ts +++ b/frontend/src/i18n/locales/zh/common.ts @@ -244,6 +244,8 @@ export default { turnstileFailed: '验证失败,请重试', captchaVerified: '验证已完成', captchaLoading: '正在加载验证码…', + captchaClickToVerify: '点击完成人机验证', + captchaVerifying: '验证中…', completeVerification: '请完成验证', verifyYourEmail: '验证您的邮箱', sessionExpired: '会话已过期', diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index 6080ddf041..e2d614ec3d 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -335,6 +335,10 @@ export const useAppStore = defineStore('app', () => { invitation_code_enabled: false, turnstile_enabled: false, turnstile_site_key: '', + aliyun_captcha_enabled: false, + aliyun_captcha_scene_id: '', + aliyun_captcha_prefix: '', + aliyun_captcha_region: 'cn', site_name: siteName.value, site_logo: siteLogo.value, site_subtitle: '', diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 928997b1bc..26da19f832 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -11,7 +11,7 @@ import type { LoginRequest, RegisterRequest, AuthResponse, - TencentCaptchaRequestProof + ActionCaptchaRequestProof } from '@/types' const AUTH_TOKEN_KEY = 'auth_token' @@ -281,7 +281,7 @@ export const useAuthStore = defineStore('auth', () => { } } - async function loginWithPasskey(proof?: TencentCaptchaRequestProof): Promise { + async function loginWithPasskey(proof?: ActionCaptchaRequestProof): Promise { try { const response = await passkeyAPI.login(proof) setAuthFromResponse(response) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 5c85f4a854..62a3e60895 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -124,6 +124,12 @@ export interface TencentCaptchaRequestProof { tencent_captcha_randstr: string } +// 动作触发式验证码(OAuth 启动、passkey 等入口)的请求凭据: +// 腾讯填 tencent_captcha_*,阿里云的 captchaVerifyParam 复用 turnstile_token 字段 +export interface ActionCaptchaRequestProof extends Partial { + turnstile_token?: string +} + export interface RegisterRequest { email: string password: string @@ -216,6 +222,10 @@ export interface PublicSettings { tencent_captcha_app_id?: string passkey_enabled?: boolean turnstile_site_key: string + aliyun_captcha_enabled?: boolean + aliyun_captcha_scene_id?: string + aliyun_captcha_prefix?: string + aliyun_captcha_region?: string site_name: string site_logo: string site_subtitle: string diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 70ef88f93e..a7e066f1c1 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1967,42 +1967,97 @@ - +

- {{ t("admin.settings.turnstile.title") }} + {{ t("admin.settings.captcha.title") }}

- {{ t("admin.settings.turnstile.description") }} + {{ t("admin.settings.captcha.description") }}

- +

- {{ t("admin.settings.turnstile.enableTurnstileHint") }} + {{ t("admin.settings.captcha.enableHint") }}

- +
-
+ +
+ +
+ + + +
+
+ + +
-
-
-
- -
-
-

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

-

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

-
-
-
-
- -

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

+
+
+

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

+

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

+
+
+ + +
+
+ + +

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

+
+
+

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

+

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

+
+
+ + +

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

+
+
+ + +

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

+
+
+

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

+

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

+
- -
-
-
-
-

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

-

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

+ +
+
+
+ +
+ + +
+

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

+
+
+ + +

+ {{ t("admin.settings.aliyunCaptcha.prefixHint") }} +

+
-
-
- -

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

-
-
-

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

-

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

-
-
-

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

-

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

-

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

-
@@ -8990,6 +9126,7 @@ type SettingsForm = Omit< tencent_captcha_app_secret_key: string; tencent_captcha_cloud_secret_id: string; tencent_captcha_cloud_secret_key: string; + aliyun_captcha_access_key_secret: string; linuxdo_connect_client_secret: string; dingtalk_connect_client_secret: string; wechat_connect_app_secret: string; @@ -9127,6 +9264,13 @@ const form = reactive({ tencent_captcha_cloud_secret_id_configured: false, tencent_captcha_cloud_secret_key: "", tencent_captcha_cloud_secret_key_configured: false, + aliyun_captcha_enabled: false, + aliyun_captcha_access_key_id: "", + aliyun_captcha_access_key_secret: "", + aliyun_captcha_access_key_secret_configured: false, + aliyun_captcha_scene_id: "", + aliyun_captcha_prefix: "", + aliyun_captcha_region: "cn", api_key_acl_trust_forwarded_ip: true, forwarded_client_ip_headers: [], // LinuxDo Connect OAuth 登录 @@ -9290,12 +9434,40 @@ const form = reactive({ allow_user_view_error_requests: false, }); -function onTurnstileToggle(enabled: boolean): void { - if (enabled) form.tencent_captcha_enabled = false; +// 人机验证 UI 状态:单卡片「总开关 + 服务商单选」,落库仍是三个独立 +// enabled 键(与上游一致),由下面的映射保证同一时间至多一家启用。 +type CaptchaProviderSelection = "turnstile" | "tencent" | "aliyun"; + +const captchaProviderSelection = ref("turnstile"); + +function applyCaptchaSelection(provider: CaptchaProviderSelection | null): void { + form.turnstile_enabled = provider === "turnstile"; + form.tencent_captcha_enabled = provider === "tencent"; + form.aliyun_captcha_enabled = provider === "aliyun"; } -function onTencentCaptchaToggle(enabled: boolean): void { - if (enabled) form.turnstile_enabled = false; +const captchaMasterEnabled = computed({ + get: () => + form.turnstile_enabled || + form.tencent_captcha_enabled || + form.aliyun_captcha_enabled, + set: (enabled: boolean) => + applyCaptchaSelection(enabled ? captchaProviderSelection.value : null), +}); + +function selectCaptchaProvider(provider: CaptchaProviderSelection): void { + captchaProviderSelection.value = provider; + applyCaptchaSelection(provider); +} + +function syncCaptchaProviderSelection(): void { + if (form.tencent_captcha_enabled) { + captchaProviderSelection.value = "tencent"; + } else if (form.aliyun_captcha_enabled) { + captchaProviderSelection.value = "aliyun"; + } else if (form.turnstile_enabled) { + captchaProviderSelection.value = "turnstile"; + } } type OpenAIAdvancedSchedulerOverrideKey = @@ -10209,6 +10381,7 @@ async function loadSettings() { (form as Record)[key] = value; } } + syncCaptchaProviderSelection(); if (!form.claude_oauth_system_prompt_blocks?.trim()) { form.claude_oauth_system_prompt_blocks = defaultClaudeOAuthSystemPromptBlocks; @@ -10266,6 +10439,7 @@ async function loadSettings() { form.tencent_captcha_app_secret_key = ""; form.tencent_captcha_cloud_secret_id = ""; form.tencent_captcha_cloud_secret_key = ""; + form.aliyun_captcha_access_key_secret = ""; form.linuxdo_connect_client_secret = ""; form.dingtalk_connect_client_secret = ""; form.github_oauth_client_secret = ""; @@ -10643,6 +10817,13 @@ async function saveSettings() { form.tencent_captcha_cloud_secret_id || undefined, tencent_captcha_cloud_secret_key: form.tencent_captcha_cloud_secret_key || undefined, + aliyun_captcha_enabled: form.aliyun_captcha_enabled, + aliyun_captcha_access_key_id: form.aliyun_captcha_access_key_id, + aliyun_captcha_access_key_secret: + form.aliyun_captcha_access_key_secret || undefined, + aliyun_captcha_scene_id: form.aliyun_captcha_scene_id, + aliyun_captcha_prefix: form.aliyun_captcha_prefix, + aliyun_captcha_region: form.aliyun_captcha_region, api_key_acl_trust_forwarded_ip: form.api_key_acl_trust_forwarded_ip, forwarded_client_ip_headers: form.forwarded_client_ip_headers, linuxdo_connect_enabled: form.linuxdo_connect_enabled, @@ -10933,6 +11114,7 @@ async function saveSettings() { form.smtp_password = ""; smtpPasswordManuallyEdited.value = false; form.turnstile_secret_key = ""; + form.aliyun_captcha_access_key_secret = ""; form.linuxdo_connect_client_secret = ""; form.dingtalk_connect_client_secret = ""; form.github_oauth_client_secret = ""; diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts index 07e33a0ff3..65bf2020ff 100644 --- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts +++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts @@ -772,24 +772,24 @@ describe("admin SettingsView payment visible method controls", () => { ); }); - it("腾讯天御验证码与 Turnstile 开关互斥并保存四项配置", async () => { + it("人机验证切换到腾讯天御并保存四项配置", async () => { const wrapper = mountView(); await flushPromises(); await openSecurityTab(wrapper); - const turnstileToggle = wrapper.get('[data-testid="turnstile-enabled-toggle"]'); - const tencentToggle = wrapper.get('[data-testid="tencent-captcha-enabled-toggle"]'); - await turnstileToggle.setValue(true); - expect((turnstileToggle.element as HTMLInputElement).checked).toBe(true); + const masterToggle = wrapper.get('[data-testid="captcha-enabled-toggle"]'); + await masterToggle.setValue(true); + // 默认选中 Turnstile + expect(wrapper.text()).toContain("admin.settings.turnstile.siteKey"); - await tencentToggle.setValue(true); - expect((turnstileToggle.element as HTMLInputElement).checked).toBe(false); - expect((tencentToggle.element as HTMLInputElement).checked).toBe(true); + await wrapper.get('[data-testid="captcha-provider-tencent"]').trigger("click"); + await flushPromises(); const card = wrapper .findAll(".card") - .find((node) => node.text().includes("admin.settings.tencentCaptcha.title")); + .find((node) => node.text().includes("admin.settings.captcha.title")); expect(card).toBeDefined(); + expect(card!.text()).not.toContain("admin.settings.turnstile.siteKey"); expect(card!.get('a[href="https://console.cloud.tencent.com/captcha"]').exists()).toBe(true); expect(card!.get('a[href="https://console.cloud.tencent.com/cam/capi"]').exists()).toBe(true); expect( @@ -808,6 +808,7 @@ describe("admin SettingsView payment visible method controls", () => { expect.objectContaining({ turnstile_enabled: false, tencent_captcha_enabled: true, + aliyun_captcha_enabled: false, tencent_captcha_app_id: "123456789", tencent_captcha_app_secret_key: "app-secret-value", tencent_captcha_cloud_secret_id: "cloud-secret-id-value", @@ -816,6 +817,77 @@ describe("admin SettingsView payment visible method controls", () => { ); }); + it("人机验证切换到阿里云并保存配置", async () => { + const wrapper = mountView(); + await flushPromises(); + await openSecurityTab(wrapper); + + const masterToggle = wrapper.get('[data-testid="captcha-enabled-toggle"]'); + await masterToggle.setValue(true); + + await wrapper.get('[data-testid="captcha-provider-aliyun"]').trigger("click"); + await flushPromises(); + + const card = wrapper + .findAll(".card") + .find((node) => node.text().includes("admin.settings.captcha.title")); + expect(card).toBeDefined(); + expect(card!.text()).toContain("admin.settings.aliyunCaptcha.region"); + expect(card!.text()).not.toContain("admin.settings.turnstile.siteKey"); + const inputs = card!.findAll("input").filter((input) => input.attributes("type") !== "checkbox"); + await inputs[0]!.setValue("prefix-1"); + await inputs[1]!.setValue("scene-1"); + await inputs[2]!.setValue("ak-id"); + await inputs[3]!.setValue("ak-secret-value"); + + await wrapper.find("form").trigger("submit.prevent"); + await flushPromises(); + + expect(updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ + turnstile_enabled: false, + tencent_captcha_enabled: false, + aliyun_captcha_enabled: true, + aliyun_captcha_prefix: "prefix-1", + aliyun_captcha_scene_id: "scene-1", + aliyun_captcha_access_key_id: "ak-id", + aliyun_captcha_access_key_secret: "ak-secret-value", + aliyun_captcha_region: "cn", + }), + ); + }); + + it("关闭人机验证总开关会同时关闭所有服务商", async () => { + getSettings.mockResolvedValueOnce({ + ...baseSettingsResponse, + tencent_captcha_enabled: true, + tencent_captcha_app_id: "123456789", + tencent_captcha_app_secret_key_configured: true, + tencent_captcha_cloud_secret_id_configured: true, + tencent_captcha_cloud_secret_key_configured: true, + }); + const wrapper = mountView(); + await flushPromises(); + await openSecurityTab(wrapper); + + const masterToggle = wrapper.get('[data-testid="captcha-enabled-toggle"]'); + expect((masterToggle.element as HTMLInputElement).checked).toBe(true); + // 加载后选中项跟随已启用的服务商 + expect(wrapper.text()).toContain("admin.settings.tencentCaptcha.appId"); + + await masterToggle.setValue(false); + await wrapper.find("form").trigger("submit.prevent"); + await flushPromises(); + + expect(updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ + turnstile_enabled: false, + tencent_captcha_enabled: false, + aliyun_captcha_enabled: false, + }), + ); + }); + it("disables passkey sign-in when the RP configuration is unavailable", async () => { getSettings.mockResolvedValueOnce({ ...baseSettingsResponse, diff --git a/frontend/src/views/auth/EmailVerifyView.vue b/frontend/src/views/auth/EmailVerifyView.vue index f515340fbd..89f59844d0 100644 --- a/frontend/src/views/auth/EmailVerifyView.vue +++ b/frontend/src/views/auth/EmailVerifyView.vue @@ -67,7 +67,7 @@
-
+
(false) const turnstileSiteKey = ref('') const tencentCaptchaEnabled = ref(false) const tencentCaptchaAppId = ref('') +const aliyunCaptchaEnabled = ref(false) +const aliyunCaptchaSceneId = ref('') +const aliyunCaptchaPrefix = ref('') +const aliyunCaptchaRegion = ref('cn') const siteName = ref('Sub2API') const registrationEmailSuffixWhitelist = ref([]) @@ -265,10 +277,21 @@ const resendTencentCaptchaRandstr = ref('') const createAccountTurnstileToken = ref('') const createAccountTencentCaptchaRandstr = ref('') const showResendTurnstile = ref(false) +const aliyunCaptchaReady = computed( + () => + aliyunCaptchaEnabled.value && + Boolean(aliyunCaptchaSceneId.value) && + Boolean(aliyunCaptchaPrefix.value) +) +// 动作触发式验证码(腾讯/阿里云):重发验证码、创建账号时弹窗验证 +const actionCaptchaEnabled = computed( + () => + (tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value)) || + aliyunCaptchaReady.value +) const captchaEnabled = computed( () => - (turnstileEnabled.value && Boolean(turnstileSiteKey.value)) || - (tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value)) + (turnstileEnabled.value && Boolean(turnstileSiteKey.value)) || actionCaptchaEnabled.value ) const errors = ref({ @@ -338,6 +361,10 @@ onMounted(async () => { turnstileSiteKey.value = settings.turnstile_site_key || '' tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true tencentCaptchaAppId.value = settings.tencent_captcha_app_id || '' + aliyunCaptchaEnabled.value = settings.aliyun_captcha_enabled === true + aliyunCaptchaSceneId.value = settings.aliyun_captcha_scene_id || '' + aliyunCaptchaPrefix.value = settings.aliyun_captcha_prefix || '' + aliyunCaptchaRegion.value = settings.aliyun_captcha_region || 'cn' siteName.value = settings.site_name || 'Sub2API' registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist( settings.registration_email_suffix_whitelist || [] @@ -424,24 +451,24 @@ function resetCreateAccountTurnstile(): void { createAccountTurnstileRef.value?.reset() } -async function acquireResendTencentProof(): Promise { - if (!tencentCaptchaEnabled.value) return true +async function acquireResendActionProof(): Promise { + if (!actionCaptchaEnabled.value) return true - const proof = await turnstileRef.value?.verifyTencent() + const proof = await turnstileRef.value?.verifyAction() if (!proof) return false - resendTurnstileToken.value = proof.ticket + resendTurnstileToken.value = proof.token resendTencentCaptchaRandstr.value = proof.randstr return true } -async function acquireCreateAccountTencentProof(): Promise { - if (!isPendingOAuthFlow() || !tencentCaptchaEnabled.value) return true +async function acquireCreateAccountActionProof(): Promise { + if (!isPendingOAuthFlow() || !actionCaptchaEnabled.value) return true - const proof = await createAccountTurnstileRef.value?.verifyTencent() + const proof = await createAccountTurnstileRef.value?.verifyAction() if (!proof) return false - createAccountTurnstileToken.value = proof.ticket + createAccountTurnstileToken.value = proof.token createAccountTencentCaptchaRandstr.value = proof.randstr return true } @@ -505,9 +532,10 @@ async function sendCode(): Promise { email: email.value, [pendingAuthTokenField.value]: pendingAuthToken.value || undefined, // 优先使用重发时新获取的 token(因为初始 token 可能已被使用) - turnstile_token: turnstileEnabled.value - ? resendTurnstileToken.value || initialTurnstileToken.value || undefined - : undefined, + turnstile_token: + turnstileEnabled.value || aliyunCaptchaEnabled.value + ? resendTurnstileToken.value || initialTurnstileToken.value || undefined + : undefined, tencent_captcha_ticket: tencentCaptchaEnabled.value ? resendTurnstileToken.value || initialTurnstileToken.value || undefined : undefined, @@ -593,7 +621,7 @@ async function handleResendCode(): Promise { return } - if (!(await acquireResendTencentProof())) { + if (!(await acquireResendActionProof())) { return } @@ -629,7 +657,7 @@ async function handleVerify(): Promise { return } - if (!(await acquireCreateAccountTencentProof())) { + if (!(await acquireCreateAccountActionProof())) { return } @@ -641,7 +669,8 @@ async function handleVerify(): Promise { email: email.value, password: password.value, verify_code: verifyCode.value.trim(), - ...(turnstileEnabled.value && createAccountTurnstileToken.value + ...((turnstileEnabled.value || aliyunCaptchaEnabled.value) && + createAccountTurnstileToken.value ? { turnstile_token: createAccountTurnstileToken.value } : {}), ...(tencentCaptchaEnabled.value && createAccountTurnstileToken.value @@ -685,7 +714,10 @@ async function handleVerify(): Promise { email: email.value, password: password.value, verify_code: verifyCode.value.trim(), - turnstile_token: turnstileEnabled.value ? initialTurnstileToken.value || undefined : undefined, + turnstile_token: + turnstileEnabled.value || aliyunCaptchaEnabled.value + ? initialTurnstileToken.value || undefined + : undefined, tencent_captcha_ticket: tencentCaptchaEnabled.value ? initialTurnstileToken.value || undefined : undefined, tencent_captcha_randstr: tencentCaptchaEnabled.value ? initialTencentCaptchaRandstr.value || undefined : undefined, promo_code: promoCode.value || undefined, diff --git a/frontend/src/views/auth/ForgotPasswordView.vue b/frontend/src/views/auth/ForgotPasswordView.vue index 8ae7951305..d420984b37 100644 --- a/frontend/src/views/auth/ForgotPasswordView.vue +++ b/frontend/src/views/auth/ForgotPasswordView.vue @@ -74,6 +74,10 @@ :turnstile-site-key="turnstileSiteKey" :tencent-enabled="tencentCaptchaEnabled" :tencent-app-id="tencentCaptchaAppId" + :aliyun-enabled="aliyunCaptchaEnabled" + :aliyun-scene-id="aliyunCaptchaSceneId" + :aliyun-prefix="aliyunCaptchaPrefix" + :aliyun-region="aliyunCaptchaRegion" @verify="onTurnstileVerify" @expire="onTurnstileExpire" @error="onTurnstileError" @@ -153,15 +157,30 @@ const turnstileEnabled = ref(false) const turnstileSiteKey = ref('') const tencentCaptchaEnabled = ref(false) const tencentCaptchaAppId = ref('') +const aliyunCaptchaEnabled = ref(false) +const aliyunCaptchaSceneId = ref('') +const aliyunCaptchaPrefix = ref('') +const aliyunCaptchaRegion = ref('cn') // Turnstile const turnstileRef = ref | null>(null) const turnstileToken = ref('') const tencentCaptchaRandstr = ref('') +const aliyunCaptchaReady = computed( + () => + aliyunCaptchaEnabled.value && + Boolean(aliyunCaptchaSceneId.value) && + Boolean(aliyunCaptchaPrefix.value) +) +// 动作触发式验证码(腾讯/阿里云):提交时弹窗验证 +const actionCaptchaEnabled = computed( + () => + (tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value)) || + aliyunCaptchaReady.value +) const captchaEnabled = computed( () => - (turnstileEnabled.value && Boolean(turnstileSiteKey.value)) || - (tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value)) + (turnstileEnabled.value && Boolean(turnstileSiteKey.value)) || actionCaptchaEnabled.value ) const formData = reactive({ @@ -190,6 +209,10 @@ onMounted(async () => { turnstileSiteKey.value = settings.turnstile_site_key || '' tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true tencentCaptchaAppId.value = settings.tencent_captcha_app_id || '' + aliyunCaptchaEnabled.value = settings.aliyun_captcha_enabled === true + aliyunCaptchaSceneId.value = settings.aliyun_captcha_scene_id || '' + aliyunCaptchaPrefix.value = settings.aliyun_captcha_prefix || '' + aliyunCaptchaRegion.value = settings.aliyun_captcha_region || 'cn' } catch (error) { console.error('Failed to load public settings:', error) } @@ -222,13 +245,13 @@ function resetCaptchaProof(): void { errors.turnstile = '' } -async function acquireTencentProof(): Promise { - if (!tencentCaptchaEnabled.value) return true +async function acquireActionProof(): Promise { + if (!actionCaptchaEnabled.value) return true - const proof = await turnstileRef.value?.verifyTencent() + const proof = await turnstileRef.value?.verifyAction() if (!proof) return false - turnstileToken.value = proof.ticket + turnstileToken.value = proof.token tencentCaptchaRandstr.value = proof.randstr return true } @@ -268,7 +291,7 @@ async function handleSubmit(): Promise { return } - if (!(await acquireTencentProof())) { + if (!(await acquireActionProof())) { return } @@ -277,7 +300,8 @@ async function handleSubmit(): Promise { try { await forgotPassword({ email: formData.email, - turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined, + turnstile_token: + turnstileEnabled.value || aliyunCaptchaEnabled.value ? turnstileToken.value : undefined, tencent_captcha_ticket: tencentCaptchaEnabled.value ? turnstileToken.value : undefined, tencent_captcha_randstr: tencentCaptchaEnabled.value ? tencentCaptchaRandstr.value : undefined }) diff --git a/frontend/src/views/auth/LoginView.vue b/frontend/src/views/auth/LoginView.vue index de4915b029..9e560e1def 100644 --- a/frontend/src/views/auth/LoginView.vue +++ b/frontend/src/views/auth/LoginView.vue @@ -86,6 +86,10 @@ :turnstile-site-key="turnstileSiteKey" :tencent-enabled="tencentCaptchaEnabled" :tencent-app-id="tencentCaptchaAppId" + :aliyun-enabled="aliyunCaptchaEnabled" + :aliyun-scene-id="aliyunCaptchaSceneId" + :aliyun-prefix="aliyunCaptchaPrefix" + :aliyun-region="aliyunCaptchaRegion" @verify="onTurnstileVerify" @expire="onTurnstileExpire" @error="onTurnstileError" @@ -240,8 +244,8 @@ import { type OAuthLoginStart } from '@/api/auth' import type { + ActionCaptchaRequestProof, LoginAgreementDocument, - TencentCaptchaRequestProof, TotpLoginResponse } from '@/types' import { extractI18nErrorMessage } from '@/utils/apiError' @@ -269,6 +273,10 @@ const turnstileEnabled = ref(false) const turnstileSiteKey = ref('') const tencentCaptchaEnabled = ref(false) const tencentCaptchaAppId = ref('') +const aliyunCaptchaEnabled = ref(false) +const aliyunCaptchaSceneId = ref('') +const aliyunCaptchaPrefix = ref('') +const aliyunCaptchaRegion = ref('cn') const linuxdoOAuthEnabled = ref(false) const dingtalkOAuthEnabled = ref(false) const wechatOAuthEnabled = ref(false) @@ -291,10 +299,21 @@ const showAgreementModal = ref(false) const turnstileRef = ref | null>(null) const turnstileToken = ref('') const tencentCaptchaRandstr = ref('') +const aliyunCaptchaReady = computed( + () => + aliyunCaptchaEnabled.value && + Boolean(aliyunCaptchaSceneId.value) && + Boolean(aliyunCaptchaPrefix.value) +) +// 动作触发式验证码(腾讯/阿里云):提交、OAuth 启动、passkey 时弹窗验证 +const actionCaptchaEnabled = computed( + () => + (tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value)) || + aliyunCaptchaReady.value +) const captchaEnabled = computed( () => - (turnstileEnabled.value && Boolean(turnstileSiteKey.value)) || - (tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value)) + (turnstileEnabled.value && Boolean(turnstileSiteKey.value)) || actionCaptchaEnabled.value ) // 2FA state @@ -364,6 +383,10 @@ onMounted(async () => { turnstileSiteKey.value = settings.turnstile_site_key || '' tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true tencentCaptchaAppId.value = settings.tencent_captcha_app_id || '' + aliyunCaptchaEnabled.value = settings.aliyun_captcha_enabled === true + aliyunCaptchaSceneId.value = settings.aliyun_captcha_scene_id || '' + aliyunCaptchaPrefix.value = settings.aliyun_captcha_prefix || '' + aliyunCaptchaRegion.value = settings.aliyun_captcha_region || 'cn' linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled dingtalkOAuthEnabled.value = settings.dingtalk_oauth_enabled ?? false wechatOAuthEnabled.value = isWeChatWebOAuthEnabled(settings) @@ -474,13 +497,13 @@ function resetCaptchaProof(): void { errors.turnstile = '' } -async function acquireTencentProof(): Promise { - if (!tencentCaptchaEnabled.value) return true +async function acquireActionProof(): Promise { + if (!actionCaptchaEnabled.value) return true - const proof = await turnstileRef.value?.verifyTencent() + const proof = await turnstileRef.value?.verifyAction() if (!proof) return false - turnstileToken.value = proof.ticket + turnstileToken.value = proof.token tencentCaptchaRandstr.value = proof.randstr return true } @@ -541,18 +564,19 @@ async function handleLogin(): Promise { return } - if (!(await acquireTencentProof())) { + if (!(await acquireActionProof())) { return } isLoading.value = true try { - // Call auth store login + // Call auth store login(阿里云 captchaVerifyParam 复用 turnstile_token 字段) const response = await authStore.login({ email: formData.email, password: formData.password, - turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined, + turnstile_token: + turnstileEnabled.value || aliyunCaptchaEnabled.value ? turnstileToken.value : undefined, tencent_captcha_ticket: tencentCaptchaEnabled.value ? turnstileToken.value : undefined, tencent_captcha_randstr: tencentCaptchaEnabled.value ? tencentCaptchaRandstr.value @@ -600,14 +624,16 @@ async function handlePasskeyLogin(): Promise { passkeyLoading.value = true try { - let proof: TencentCaptchaRequestProof | undefined - if (tencentCaptchaEnabled.value) { - const result = await turnstileRef.value?.verifyTencent() + let proof: ActionCaptchaRequestProof | undefined + if (actionCaptchaEnabled.value) { + const result = await turnstileRef.value?.verifyAction() if (!result) return - proof = { - tencent_captcha_ticket: result.ticket, - tencent_captcha_randstr: result.randstr - } + proof = tencentCaptchaEnabled.value + ? { + tencent_captcha_ticket: result.token, + tencent_captcha_randstr: result.randstr + } + : { turnstile_token: result.token } } await authStore.loginWithPasskey(proof) @@ -622,7 +648,7 @@ async function handlePasskeyLogin(): Promise { errorMessage.value = extractI18nErrorMessage(error, t, 'auth.errors', fallback) appStore.showError(errorMessage.value) } finally { - if (tencentCaptchaEnabled.value) { + if (actionCaptchaEnabled.value) { resetCaptchaProof() } passkeyLoading.value = false @@ -632,20 +658,25 @@ async function handlePasskeyLogin(): Promise { async function handleOAuthStart(request: OAuthLoginStart): Promise { if (authActionDisabled.value) return - if (!tencentCaptchaEnabled.value) { + if (!actionCaptchaEnabled.value) { window.location.href = buildOAuthLoginStartURL(request) return } isLoading.value = true try { - const proof = await turnstileRef.value?.verifyTencent() + const proof = await turnstileRef.value?.verifyAction() if (!proof) return - const result = await startOAuthLogin(request, { - tencent_captcha_ticket: proof.ticket, - tencent_captcha_randstr: proof.randstr - }) + const result = await startOAuthLogin( + request, + tencentCaptchaEnabled.value + ? { + tencent_captcha_ticket: proof.token, + tencent_captcha_randstr: proof.randstr + } + : { turnstile_token: proof.token } + ) window.location.href = result.authorize_url } catch (error: unknown) { errorMessage.value = extractI18nErrorMessage( diff --git a/frontend/src/views/auth/RegisterView.vue b/frontend/src/views/auth/RegisterView.vue index 5c4ce61ef1..5dd46aaf60 100644 --- a/frontend/src/views/auth/RegisterView.vue +++ b/frontend/src/views/auth/RegisterView.vue @@ -211,6 +211,10 @@ :turnstile-site-key="turnstileSiteKey" :tencent-enabled="tencentCaptchaEnabled" :tencent-app-id="tencentCaptchaAppId" + :aliyun-enabled="aliyunCaptchaEnabled" + :aliyun-scene-id="aliyunCaptchaSceneId" + :aliyun-prefix="aliyunCaptchaPrefix" + :aliyun-region="aliyunCaptchaRegion" @verify="onTurnstileVerify" @expire="onTurnstileExpire" @error="onTurnstileError" @@ -388,6 +392,10 @@ const turnstileEnabled = ref(false) const turnstileSiteKey = ref('') const tencentCaptchaEnabled = ref(false) const tencentCaptchaAppId = ref('') +const aliyunCaptchaEnabled = ref(false) +const aliyunCaptchaSceneId = ref('') +const aliyunCaptchaPrefix = ref('') +const aliyunCaptchaRegion = ref('cn') const siteName = ref('Sub2API') const linuxdoOAuthEnabled = ref(false) const wechatOAuthEnabled = ref(false) @@ -408,10 +416,21 @@ const showAgreementModal = ref(false) const turnstileRef = ref | null>(null) const turnstileToken = ref('') const tencentCaptchaRandstr = ref('') +const aliyunCaptchaReady = computed( + () => + aliyunCaptchaEnabled.value && + Boolean(aliyunCaptchaSceneId.value) && + Boolean(aliyunCaptchaPrefix.value) +) +// 动作触发式验证码(腾讯/阿里云):提交、OAuth 启动时弹窗验证 +const actionCaptchaEnabled = computed( + () => + (tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value)) || + aliyunCaptchaReady.value +) const captchaEnabled = computed( () => - (turnstileEnabled.value && Boolean(turnstileSiteKey.value)) || - (tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value)) + (turnstileEnabled.value && Boolean(turnstileSiteKey.value)) || actionCaptchaEnabled.value ) // Promo code validation @@ -505,6 +524,10 @@ onMounted(async () => { turnstileSiteKey.value = settings.turnstile_site_key || '' tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true tencentCaptchaAppId.value = settings.tencent_captcha_app_id || '' + aliyunCaptchaEnabled.value = settings.aliyun_captcha_enabled === true + aliyunCaptchaSceneId.value = settings.aliyun_captcha_scene_id || '' + aliyunCaptchaPrefix.value = settings.aliyun_captcha_prefix || '' + aliyunCaptchaRegion.value = settings.aliyun_captcha_region || 'cn' siteName.value = settings.site_name || 'Sub2API' linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled wechatOAuthEnabled.value = isWeChatWebOAuthEnabled(settings) @@ -778,13 +801,13 @@ function resetCaptchaProof(): void { errors.turnstile = '' } -async function acquireTencentProof(): Promise { - if (!tencentCaptchaEnabled.value) return true +async function acquireActionProof(): Promise { + if (!actionCaptchaEnabled.value) return true - const proof = await turnstileRef.value?.verifyTencent() + const proof = await turnstileRef.value?.verifyAction() if (!proof) return false - turnstileToken.value = proof.ticket + turnstileToken.value = proof.token tencentCaptchaRandstr.value = proof.randstr return true } @@ -792,20 +815,25 @@ async function acquireTencentProof(): Promise { async function handleOAuthStart(request: OAuthLoginStart): Promise { if (registrationActionDisabled.value) return - if (!tencentCaptchaEnabled.value) { + if (!actionCaptchaEnabled.value) { window.location.href = buildOAuthLoginStartURL(request) return } isLoading.value = true try { - const proof = await turnstileRef.value?.verifyTencent() + const proof = await turnstileRef.value?.verifyAction() if (!proof) return - const result = await startOAuthLogin(request, { - tencent_captcha_ticket: proof.ticket, - tencent_captcha_randstr: proof.randstr - }) + const result = await startOAuthLogin( + request, + tencentCaptchaEnabled.value + ? { + tencent_captcha_ticket: proof.token, + tencent_captcha_randstr: proof.randstr + } + : { turnstile_token: proof.token } + ) window.location.href = result.authorize_url } catch (error: unknown) { errorMessage.value = extractI18nErrorMessage( @@ -950,7 +978,7 @@ async function handleRegister(): Promise { } } - if (!(await acquireTencentProof())) { + if (!(await acquireActionProof())) { return } @@ -970,7 +998,8 @@ async function handleRegister(): Promise { JSON.stringify({ email: formData.email, password: formData.password, - turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined, + turnstile_token: + turnstileEnabled.value || aliyunCaptchaEnabled.value ? turnstileToken.value : undefined, tencent_captcha_ticket: tencentCaptchaEnabled.value ? turnstileToken.value : undefined, tencent_captcha_randstr: tencentCaptchaEnabled.value ? tencentCaptchaRandstr.value : undefined, promo_code: formData.promo_code || undefined, @@ -988,7 +1017,8 @@ async function handleRegister(): Promise { await authStore.register({ email: formData.email, password: formData.password, - turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined, + turnstile_token: + turnstileEnabled.value || aliyunCaptchaEnabled.value ? turnstileToken.value : undefined, tencent_captcha_ticket: tencentCaptchaEnabled.value ? turnstileToken.value : undefined, tencent_captcha_randstr: tencentCaptchaEnabled.value ? tencentCaptchaRandstr.value : undefined, promo_code: formData.promo_code || undefined, diff --git a/frontend/src/views/auth/__tests__/EmailVerifyView.spec.ts b/frontend/src/views/auth/__tests__/EmailVerifyView.spec.ts index a4ebb27bc4..f0c536f976 100644 --- a/frontend/src/views/auth/__tests__/EmailVerifyView.spec.ts +++ b/frontend/src/views/auth/__tests__/EmailVerifyView.spec.ts @@ -18,7 +18,7 @@ const { apiClientPostMock, authStoreState, createTurnstileResetMock, - verifyTencentMock, + verifyActionMock, } = vi.hoisted(() => ({ pushMock: vi.fn(), showSuccessMock: vi.fn(), @@ -33,7 +33,7 @@ const { persistOAuthTokenContextMock: vi.fn(), apiClientPostMock: vi.fn(), createTurnstileResetMock: vi.fn(), - verifyTencentMock: vi.fn(), + verifyActionMock: vi.fn(), authStoreState: { pendingAuthSession: null as null | { token: string @@ -116,7 +116,7 @@ describe('EmailVerifyView', () => { persistOAuthTokenContextMock.mockReset() apiClientPostMock.mockReset() createTurnstileResetMock.mockReset() - verifyTencentMock.mockReset() + verifyActionMock.mockReset() authStoreState.pendingAuthSession = null sessionStorage.clear() localStorage.clear() @@ -142,9 +142,9 @@ describe('EmailVerifyView', () => { registration_email_suffix_whitelist: [], }) sendVerifyCodeMock.mockResolvedValue({ countdown: 0 }) - verifyTencentMock - .mockResolvedValueOnce({ ticket: 'ticket-1', randstr: '@rand-1' }) - .mockResolvedValueOnce({ ticket: 'ticket-2', randstr: '@rand-2' }) + verifyActionMock + .mockResolvedValueOnce({ token: 'ticket-1', randstr: '@rand-1' }) + .mockResolvedValueOnce({ token: 'ticket-2', randstr: '@rand-2' }) sessionStorage.setItem( 'register_data', JSON.stringify({ @@ -157,7 +157,7 @@ describe('EmailVerifyView', () => { const CaptchaChallengeStub = defineComponent({ setup(_, { expose }) { - expose({ verifyTencent: verifyTencentMock, reset: createTurnstileResetMock }) + expose({ verifyAction: verifyActionMock, reset: createTurnstileResetMock }) return () => h('div') }, }) @@ -182,7 +182,7 @@ describe('EmailVerifyView', () => { await resendButton().trigger('click') await flushPromises() - expect(verifyTencentMock).toHaveBeenCalledTimes(2) + expect(verifyActionMock).toHaveBeenCalledTimes(2) expect(sendVerifyCodeMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ tencent_captcha_ticket: 'ticket-1', tencent_captcha_randstr: '@rand-1', diff --git a/frontend/src/views/auth/__tests__/TencentCaptchaActionGate.spec.ts b/frontend/src/views/auth/__tests__/TencentCaptchaActionGate.spec.ts index a9056eab6c..ee414cd5ca 100644 --- a/frontend/src/views/auth/__tests__/TencentCaptchaActionGate.spec.ts +++ b/frontend/src/views/auth/__tests__/TencentCaptchaActionGate.spec.ts @@ -7,7 +7,7 @@ const loginMock = vi.fn() const loginWithPasskeyMock = vi.fn() const getPublicSettingsMock = vi.fn() const startOAuthLoginMock = vi.fn() -const verifyTencentMock = vi.fn() +const verifyActionMock = vi.fn() const captchaResetMock = vi.fn() const locationState = { href: 'http://localhost/login' } @@ -54,7 +54,7 @@ vi.mock('@/api/auth', async () => { const CaptchaChallengeStub = defineComponent({ setup(_, { expose }) { expose({ - verifyTencent: verifyTencentMock, + verifyAction: verifyActionMock, reset: captchaResetMock }) return () => h('div') @@ -101,7 +101,7 @@ describe('Tencent captcha action gate', () => { loginWithPasskeyMock.mockReset() getPublicSettingsMock.mockReset() startOAuthLoginMock.mockReset() - verifyTencentMock.mockReset() + verifyActionMock.mockReset() captchaResetMock.mockReset() getPublicSettingsMock.mockResolvedValue({ turnstile_enabled: false, @@ -117,7 +117,7 @@ describe('Tencent captcha action gate', () => { loginMock.mockResolvedValue({}) loginWithPasskeyMock.mockResolvedValue({}) startOAuthLoginMock.mockResolvedValue({ authorize_url: 'https://github.example/authorize' }) - verifyTencentMock.mockResolvedValue({ ticket: 'ticket-1', randstr: '@rand-1' }) + verifyActionMock.mockResolvedValue({ token: 'ticket-1', randstr: '@rand-1' }) Object.defineProperty(window, 'PublicKeyCredential', { configurable: true, value: class PublicKeyCredential {} @@ -138,7 +138,7 @@ describe('Tencent captcha action gate', () => { await wrapper.get('form').trigger('submit') await flushPromises() - expect(verifyTencentMock).toHaveBeenCalledOnce() + expect(verifyActionMock).toHaveBeenCalledOnce() expect(loginMock).toHaveBeenCalledWith(expect.objectContaining({ tencent_captcha_ticket: 'ticket-1', tencent_captcha_randstr: '@rand-1' @@ -146,7 +146,7 @@ describe('Tencent captcha action gate', () => { }) it('does not call login when Tencent captcha is closed', async () => { - verifyTencentMock.mockResolvedValue(null) + verifyActionMock.mockResolvedValue(null) const wrapper = mountLogin() await flushPromises() await wrapper.get('#email').setValue('user@example.com') @@ -155,7 +155,7 @@ describe('Tencent captcha action gate', () => { await wrapper.get('form').trigger('submit') await flushPromises() - expect(verifyTencentMock).toHaveBeenCalledOnce() + expect(verifyActionMock).toHaveBeenCalledOnce() expect(loginMock).not.toHaveBeenCalled() }) @@ -166,7 +166,7 @@ describe('Tencent captcha action gate', () => { await wrapper.get('form').trigger('submit') await flushPromises() - expect(verifyTencentMock).not.toHaveBeenCalled() + expect(verifyActionMock).not.toHaveBeenCalled() expect(loginMock).not.toHaveBeenCalled() }) @@ -177,7 +177,7 @@ describe('Tencent captcha action gate', () => { await wrapper.get('[data-testid="oauth-start"]').trigger('click') await flushPromises() - expect(verifyTencentMock).toHaveBeenCalledOnce() + expect(verifyActionMock).toHaveBeenCalledOnce() expect(startOAuthLoginMock).toHaveBeenCalledWith( { provider: 'github', params: { redirect: '/dashboard' } }, { @@ -190,7 +190,7 @@ describe('Tencent captcha action gate', () => { }) it('does not start OAuth when Tencent captcha is closed', async () => { - verifyTencentMock.mockResolvedValue(null) + verifyActionMock.mockResolvedValue(null) const wrapper = mountLogin() await flushPromises() @@ -208,7 +208,7 @@ describe('Tencent captcha action gate', () => { await wrapper.get('button.btn-secondary.w-full').trigger('click') await flushPromises() - expect(verifyTencentMock).toHaveBeenCalledOnce() + expect(verifyActionMock).toHaveBeenCalledOnce() expect(loginWithPasskeyMock).toHaveBeenCalledWith({ tencent_captcha_ticket: 'ticket-1', tencent_captcha_randstr: '@rand-1' @@ -217,7 +217,7 @@ describe('Tencent captcha action gate', () => { }) it('does not invoke Passkey when Tencent captcha is closed', async () => { - verifyTencentMock.mockResolvedValue(null) + verifyActionMock.mockResolvedValue(null) const wrapper = mountLogin() await flushPromises()