mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
fix: 修复腾讯验证码票据过期与区域切换
This commit is contained in:
@@ -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'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
||||
const DefaultCSPPolicy = "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
||||
|
||||
// UMQ(用户消息队列)模式常量
|
||||
const (
|
||||
|
||||
@@ -31,6 +31,10 @@ const (
|
||||
TencentCaptchaGlobalCDNDomain = "https://global.turing.captcha.gtimg.com"
|
||||
// TencentCaptchaPrehandleDomain 是天御 SDK 动态预处理脚本与预处理接口主机。
|
||||
TencentCaptchaPrehandleDomain = "https://www.tycaptcha.com"
|
||||
// TencentCaptchaJQueryDomain 是国内站入口脚本动态加载的 jQuery CDN 主机。
|
||||
TencentCaptchaJQueryDomain = "https://cloudcache.tencentcs.com"
|
||||
// TencentCaptchaRceDomain 是国际站风控校验接口主机。
|
||||
TencentCaptchaRceDomain = "https://rce.tencentrio.com"
|
||||
// TencentCaptchaWorkerSource 是天御国际站创建验证码 Web Worker 时使用的来源。
|
||||
TencentCaptchaWorkerSource = "blob:"
|
||||
// StripeDomain is the domain for Stripe.js SDK
|
||||
@@ -57,8 +61,10 @@ var requiredCSPDirectiveValues = []struct {
|
||||
{"script-src", TencentCaptchaGlobalDomain},
|
||||
{"script-src", TencentCaptchaGlobalCDNDomain},
|
||||
{"script-src", TencentCaptchaPrehandleDomain},
|
||||
{"script-src", TencentCaptchaJQueryDomain},
|
||||
{"connect-src", TencentCaptchaDomain},
|
||||
{"connect-src", TencentCaptchaPrehandleDomain},
|
||||
{"connect-src", TencentCaptchaRceDomain},
|
||||
{"frame-src", TencentCaptchaGlobalDomain},
|
||||
{"frame-src", TencentCaptchaPrehandleDomain},
|
||||
{"worker-src", TencentCaptchaWorkerSource},
|
||||
|
||||
@@ -330,8 +330,10 @@ func TestEnhanceCSPPolicy(t *testing.T) {
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaGlobalDomain))
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaGlobalCDNDomain))
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaPrehandleDomain))
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaJQueryDomain))
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "connect-src", TencentCaptchaDomain))
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "connect-src", TencentCaptchaPrehandleDomain))
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "connect-src", TencentCaptchaRceDomain))
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", TencentCaptchaGlobalDomain))
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", TencentCaptchaPrehandleDomain))
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "worker-src", TencentCaptchaWorkerSource))
|
||||
|
||||
@@ -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'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
||||
policy: "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
||||
proxy_probe:
|
||||
# Allow skipping TLS verification for proxy probe (debug only)
|
||||
# 允许代理探测时跳过 TLS 证书验证(仅用于调试)
|
||||
|
||||
@@ -33,8 +33,12 @@ let instance: { show(): void; destroy(): void } | null = null
|
||||
let pending: Promise<TencentCaptchaProof | null> | null = null
|
||||
let cancelPending: (() => void) | null = null
|
||||
let cachedProof: TencentCaptchaProof | null = null
|
||||
let cachedProofCreatedAt = 0
|
||||
let isMounted = false
|
||||
|
||||
// 腾讯国际站票据官方有效期为 5 分钟,提前 1 分钟放弃缓存,避免提交时刚好过期。
|
||||
const cachedProofMaxAgeMs = 4 * 60 * 1000
|
||||
|
||||
function createVerificationPromise(revealInternational: boolean = true): Promise<TencentCaptchaProof | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
@@ -106,9 +110,17 @@ function createVerificationPromise(revealInternational: boolean = true): Promise
|
||||
|
||||
function verify(): Promise<TencentCaptchaProof | null> {
|
||||
if (cachedProof) {
|
||||
const proof = cachedProof
|
||||
cachedProof = null
|
||||
return Promise.resolve(proof)
|
||||
if (Date.now() - cachedProofCreatedAt >= cachedProofMaxAgeMs) {
|
||||
// 过期票据不能再次提交;先同步销毁旧实例,再在本次调用中创建新验证。
|
||||
reset(false)
|
||||
} else {
|
||||
const proof = cachedProof
|
||||
cachedProof = null
|
||||
cachedProofCreatedAt = 0
|
||||
// 预加载 promise 已经完成,消费缓存时同步清除引用,避免同一票据被并发复用。
|
||||
pending = null
|
||||
return Promise.resolve(proof)
|
||||
}
|
||||
}
|
||||
|
||||
if (pending) {
|
||||
@@ -119,7 +131,10 @@ function verify(): Promise<TencentCaptchaProof | null> {
|
||||
if (pending === verification) instance?.show()
|
||||
})
|
||||
return verification.then((proof) => {
|
||||
if (cachedProof === proof) cachedProof = null
|
||||
if (cachedProof === proof) {
|
||||
cachedProof = null
|
||||
cachedProofCreatedAt = 0
|
||||
}
|
||||
return proof
|
||||
})
|
||||
}
|
||||
@@ -146,7 +161,10 @@ function preload(): void {
|
||||
pending = verification
|
||||
void verification
|
||||
.then((proof) => {
|
||||
if (proof) cachedProof = proof
|
||||
if (proof) {
|
||||
cachedProof = proof
|
||||
cachedProofCreatedAt = Date.now()
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
@@ -154,18 +172,19 @@ function preload(): void {
|
||||
})
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
function reset(reinitialize: boolean = true): void {
|
||||
instance?.destroy()
|
||||
instance = null
|
||||
cancelPending?.()
|
||||
cancelPending = null
|
||||
pending = null
|
||||
cachedProof = null
|
||||
cachedProofCreatedAt = 0
|
||||
internationalContainerVisible.value = isInternational.value
|
||||
|
||||
// 国际站的票据一次性使用,登录失败后需要立即创建新的 checkbox,
|
||||
// 不能等下一次点击登录才重新初始化。卸载阶段不再启动预加载。
|
||||
if (isMounted && isInternational.value) {
|
||||
if (reinitialize && isMounted && isInternational.value) {
|
||||
void nextTick().then(() => {
|
||||
if (isMounted) preload()
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import TencentCaptchaGate from '@/components/TencentCaptchaGate.vue'
|
||||
import { resetTencentCaptchaLoaderForTest } from '@/utils/tencentCaptcha'
|
||||
import { loadTencentCaptcha, resetTencentCaptchaLoaderForTest } from '@/utils/tencentCaptcha'
|
||||
|
||||
const locale = { value: 'zh' }
|
||||
|
||||
@@ -197,6 +197,43 @@ describe('TencentCaptchaGate', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('refreshes a preloaded proof before the provider ticket expires', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let callback: ((result: CaptchaResult) => void) | undefined
|
||||
let constructorCount = 0
|
||||
window.TCaptchaGlobal = true
|
||||
window.TencentCaptcha = class {
|
||||
constructor(...received: unknown[]) {
|
||||
constructorCount += 1
|
||||
callback = received[2] as (result: CaptchaResult) => void
|
||||
}
|
||||
show = vi.fn()
|
||||
destroy = vi.fn()
|
||||
} as unknown as typeof window.TencentCaptcha
|
||||
const wrapper = mount(TencentCaptchaGate, {
|
||||
props: { appId: '123456789', region: 'intl' }
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
callback?.({ ret: 0, ticket: 'expired-ticket', randstr: 'expired-rand' })
|
||||
await flushPromises()
|
||||
vi.advanceTimersByTime(4 * 60 * 1000)
|
||||
|
||||
const verification = wrapper.vm.verify()
|
||||
await flushPromises()
|
||||
expect(constructorCount).toBe(2)
|
||||
|
||||
callback?.({ ret: 0, ticket: 'fresh-ticket', randstr: 'fresh-rand' })
|
||||
await expect(verification).resolves.toEqual({
|
||||
ticket: 'fresh-ticket',
|
||||
randstr: 'fresh-rand'
|
||||
})
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the three-argument form for the Chinese mainland site', async () => {
|
||||
const args: unknown[][] = []
|
||||
window.TencentCaptcha = class {
|
||||
@@ -248,6 +285,26 @@ describe('TencentCaptchaGate', () => {
|
||||
).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not inject a second SDK when the region changes in one page', async () => {
|
||||
const constructor = class {
|
||||
constructor() {}
|
||||
show = vi.fn()
|
||||
destroy = vi.fn()
|
||||
} as unknown as typeof window.TencentCaptcha
|
||||
|
||||
const firstLoad = loadTencentCaptcha('cn')
|
||||
const firstScript = document.head.querySelector<HTMLScriptElement>('script[src*="TJCaptcha.js"]')
|
||||
expect(firstScript).not.toBeNull()
|
||||
window.TencentCaptcha = constructor
|
||||
firstScript?.dispatchEvent(new Event('load'))
|
||||
await expect(firstLoad).resolves.toBe(constructor)
|
||||
|
||||
await expect(loadTencentCaptcha('intl')).rejects.toThrow(
|
||||
'Tencent Captcha region changed; reload the page to apply it'
|
||||
)
|
||||
expect(document.head.querySelector('script[src*="TJNCaptcha-global.js"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('settles a pending verification when reset', async () => {
|
||||
const destroy = vi.fn()
|
||||
window.TencentCaptcha = class {
|
||||
|
||||
@@ -79,6 +79,11 @@ export function loadTencentCaptcha(
|
||||
if (window.TencentCaptcha && (loadedRegion === region || globalRegion === region)) {
|
||||
return Promise.resolve(window.TencentCaptcha)
|
||||
}
|
||||
if (window.TencentCaptcha && loadedRegion !== null && loadedRegion !== region) {
|
||||
// 两个站点共享 TencentCaptcha 全局且构造签名不兼容,不能在同一页面注入第二份 SDK。
|
||||
// 管理员切换区域后由部署流程刷新页面,刷新前直接失败可避免全局构造函数被污染。
|
||||
return Promise.reject(new Error('Tencent Captcha region changed; reload the page to apply it'))
|
||||
}
|
||||
if (scriptPromise && loadedRegion === region) return scriptPromise
|
||||
|
||||
loadedRegion = region
|
||||
|
||||
Reference in New Issue
Block a user