fix: 修复腾讯验证码票据过期与区域切换

This commit is contained in:
shaw
2026-08-06 21:27:06 +08:00
parent 8e102b3a0f
commit 287a9f386b
7 changed files with 99 additions and 10 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ const (
// DefaultCSPPolicy is the default Content-Security-Policy with nonce support
// __CSP_NONCE__ will be replaced with actual nonce at request time by the SecurityHeaders middleware
const DefaultCSPPolicy = "default-src 'self'; 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))
+1 -1
View File
@@ -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 证书验证(仅用于调试)
+26 -7
View File
@@ -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 {
+5
View File
@@ -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