fix: 完善腾讯验证码区域适配与 CSP 白名单

修复国内站和国际站 SDK 构造、验证容器、票据重置及动态资源加载问题,并补充认证流程回归测试。
This commit is contained in:
shaw
2026-08-06 20:34:37 +08:00
parent a19c9f8d8a
commit 8e102b3a0f
42 changed files with 922 additions and 70 deletions
@@ -11,6 +11,7 @@
v-else-if="tencentEnabled && tencentAppId"
ref="tencentRef"
:app-id="tencentAppId"
:region="tencentRegion"
/>
<AliyunCaptchaWidget
v-else-if="aliyunEnabled && aliyunSceneId && aliyunPrefix"
@@ -43,6 +44,7 @@ const props = defineProps<{
turnstileSiteKey: string
tencentEnabled: boolean
tencentAppId: string
tencentRegion?: string
aliyunEnabled?: boolean
aliyunSceneId?: string
aliyunPrefix?: string
+122 -17
View File
@@ -1,44 +1,70 @@
<template>
<span v-if="false" />
<div
v-if="isInternational"
ref="internationalContainerRef"
data-testid="tencent-captcha-international-container"
:class="
internationalContainerVisible
? 'flex min-h-[60px] w-full justify-center'
: 'pointer-events-none fixed left-1/2 top-0 flex h-[60px] w-[302px] -translate-x-1/2 scale-[0.01] opacity-0'
"
/>
</template>
<script setup lang="ts">
import { onBeforeUnmount } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
loadTencentCaptcha,
normalizeTencentCaptchaRegion,
type TencentCaptchaProof,
type TencentCaptchaResult
} from '@/utils/tencentCaptcha'
const { locale } = useI18n()
const props = defineProps<{ appId: string }>()
const props = withDefaults(defineProps<{ appId: string; region?: string }>(), { region: 'cn' })
const isInternational = computed(
() => normalizeTencentCaptchaRegion(props.region) === 'intl'
)
const internationalContainerRef = ref<HTMLDivElement | null>(null)
const internationalContainerVisible = ref<boolean>(isInternational.value)
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 isMounted = false
function createVerificationPromise(): Promise<TencentCaptchaProof | null> {
function createVerificationPromise(revealInternational: boolean = true): Promise<TencentCaptchaProof | null> {
return new Promise((resolve, reject) => {
let settled = false
const finish = (callback: () => void): void => {
const finish = (callback: () => void, keepInternationalContainer: boolean = false): void => {
if (settled) return
settled = true
if (cancelPending === cancel) cancelPending = null
instance?.destroy()
instance = null
if (!keepInternationalContainer) {
instance?.destroy()
instance = null
internationalContainerVisible.value = false
}
callback()
}
const cancel = (): void => finish(() => resolve(null))
cancelPending = cancel
void loadTencentCaptcha()
const region = normalizeTencentCaptchaRegion(props.region)
if (region === 'intl' && revealInternational) {
internationalContainerVisible.value = true
}
void nextTick()
.then(() => loadTencentCaptcha(region))
.then((TencentCaptcha) => {
if (cancelPending !== cancel) return
const userLanguage = locale.value.toLowerCase().startsWith('zh') ? 'zh-cn' : 'en'
instance = new TencentCaptcha(props.appId, (result: TencentCaptchaResult) => {
const handleResult = (result: TencentCaptchaResult): void => {
if (result.ret === 2) {
finish(() => resolve(null))
return
@@ -51,8 +77,27 @@ function createVerificationPromise(): Promise<TencentCaptchaProof | null> {
return
}
finish(() => resolve({ ticket, randstr }))
}, { userLanguage })
// 国际站保留已勾选的控件,避免成功回调后页面出现跳动;登录流程结束时由 reset 清理。
finish(() => resolve({ ticket, randstr }), region === 'intl')
}
if (region === 'intl') {
// 新版国际站先在指定容器内展示 Robot checkbox,用户点击后才弹出挑战。
// document.body 会把 checkbox 追加到整个页面末尾,在登录页上通常落到视口外,
// 表现为 SDK 已成功初始化但页面没有任何可见组件。
const container = internationalContainerRef.value
if (!container) {
throw new Error('Tencent Captcha international container is unavailable')
}
instance = new TencentCaptcha(container, props.appId, handleResult, {
// 国际站与国内站保持一致:页面加载后显示 Robot checkbox,由用户主动确认。
enableAutoCheck: false,
userLanguage,
type: 'popup'
})
} else {
instance = new TencentCaptcha(props.appId, handleResult, { userLanguage })
}
instance.show()
})
.catch((error: unknown) => finish(() => reject(error)))
@@ -60,11 +105,53 @@ function createVerificationPromise(): Promise<TencentCaptchaProof | null> {
}
function verify(): Promise<TencentCaptchaProof | null> {
if (pending) return pending
pending = createVerificationPromise().finally(() => {
pending = null
})
return pending
if (cachedProof) {
const proof = cachedProof
cachedProof = null
return Promise.resolve(proof)
}
if (pending) {
const verification = pending
internationalContainerVisible.value = true
// 预加载阶段可能因容器不可见而被 SDK 延迟绘制,提交时在容器显示后重新触发同一个实例。
void nextTick().then(() => {
if (pending === verification) instance?.show()
})
return verification.then((proof) => {
if (cachedProof === proof) cachedProof = null
return proof
})
}
internationalContainerVisible.value = true
const verification = createVerificationPromise(true)
pending = verification
void verification.then(
() => {
if (pending === verification) pending = null
},
() => {
if (pending === verification) pending = null
}
)
return verification
}
function preload(): void {
if (!isInternational.value || pending || cachedProof) return
// 国际站要求首屏直接展示 checkbox,避免用户点击登录后才看到验证码。
const verification = createVerificationPromise(true)
pending = verification
void verification
.then((proof) => {
if (proof) cachedProof = proof
})
.catch(() => undefined)
.finally(() => {
if (pending === verification) pending = null
})
}
function reset(): void {
@@ -72,8 +159,26 @@ function reset(): void {
instance = null
cancelPending?.()
cancelPending = null
pending = null
cachedProof = null
internationalContainerVisible.value = isInternational.value
// 国际站的票据一次性使用,登录失败后需要立即创建新的 checkbox,
// 不能等下一次点击登录才重新初始化。卸载阶段不再启动预加载。
if (isMounted && isInternational.value) {
void nextTick().then(() => {
if (isMounted) preload()
})
}
}
onBeforeUnmount(reset)
onMounted(() => {
isMounted = true
preload()
})
onBeforeUnmount(() => {
isMounted = false
reset()
})
defineExpose({ verify, reset })
</script>
@@ -20,7 +20,10 @@ describe('TencentCaptchaGate', () => {
beforeEach(() => {
locale.value = 'zh'
delete window.TencentCaptcha
document.head.querySelectorAll('script[src*="TJCaptcha.js"]').forEach((node) => node.remove())
delete window.TCaptchaGlobal
document.head
.querySelectorAll('script[src*="TJCaptcha.js"], script[src*="TJNCaptcha-global.js"]')
.forEach((node) => node.remove())
resetTencentCaptchaLoaderForTest()
})
@@ -69,6 +72,7 @@ describe('TencentCaptchaGate', () => {
it('rejects SDK load failures and disaster-recovery tickets', async () => {
const failedLoad = mount(TencentCaptchaGate, { props: { appId: '123456789' } })
const loadVerification = failedLoad.vm.verify()
await flushPromises()
const script = document.head.querySelector<HTMLScriptElement>('script[src*="TJCaptcha.js"]')
expect(script).not.toBeNull()
script?.dispatchEvent(new Event('error'))
@@ -112,6 +116,138 @@ describe('TencentCaptchaGate', () => {
expect(show).toHaveBeenCalledOnce()
})
// 国际站新版 SDK 会先把 Robot checkbox 渲染到首参容器,点击后才弹出挑战。
// 容器必须位于当前表单中;传 document.body 会让 checkbox 落在页面末尾并超出视口。
it('passes a visible in-form container first for the international site', async () => {
const args: unknown[][] = []
let callback: ((result: CaptchaResult) => void) | undefined
window.TCaptchaGlobal = true
window.TencentCaptcha = class {
constructor(...received: unknown[]) {
args.push(received)
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' }
})
const verification = wrapper.vm.verify()
await flushPromises()
const container = wrapper.get('[data-testid="tencent-captcha-international-container"]')
expect(args).toHaveLength(1)
expect(args[0][0]).toBe(container.element)
expect(args[0][0]).not.toBe(document.body)
await vi.waitFor(() => expect(container.classes()).not.toContain('scale-[0.01]'))
expect(args[0][1]).toBe('123456789')
expect(typeof args[0][2]).toBe('function')
expect(args[0][3]).toMatchObject({ enableAutoCheck: false, type: 'popup' })
callback?.({ ret: 0, ticket: 'ticket-value', randstr: 'rand-value' })
await expect(verification).resolves.toEqual({
ticket: 'ticket-value',
randstr: 'rand-value'
})
expect(container.classes()).not.toContain('scale-[0.01]')
})
it('preloads and displays the international checkbox on mount', async () => {
window.TCaptchaGlobal = true
window.TencentCaptcha = class {
constructor() {}
show = vi.fn()
destroy = vi.fn()
} as unknown as typeof window.TencentCaptcha
const wrapper = mount(TencentCaptchaGate, {
props: { appId: '123456789', region: 'intl' }
})
const container = wrapper.get('[data-testid="tencent-captcha-international-container"]')
await flushPromises()
await vi.waitFor(() => expect(container.classes()).not.toContain('scale-[0.01]'))
})
it('reuses a preloaded proof when the SDK reports success', async () => {
let callback: ((result: CaptchaResult) => void) | undefined
window.TCaptchaGlobal = true
window.TencentCaptcha = class {
constructor(...received: unknown[]) {
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: 'preloaded-ticket', randstr: 'preloaded-rand' })
await flushPromises()
await expect(wrapper.vm.verify()).resolves.toEqual({
ticket: 'preloaded-ticket',
randstr: 'preloaded-rand'
})
expect(wrapper.get('[data-testid="tencent-captcha-international-container"]').classes()).not.toContain(
'scale-[0.01]'
)
})
it('keeps the three-argument form for the Chinese mainland site', async () => {
const args: unknown[][] = []
window.TencentCaptcha = class {
constructor(...received: unknown[]) {
args.push(received)
}
show = vi.fn()
destroy = vi.fn()
} as unknown as typeof window.TencentCaptcha
const wrapper = mount(TencentCaptchaGate, { props: { appId: '123456789' } })
void wrapper.vm.verify()
await flushPromises()
expect(args[0][0]).toBe('123456789')
expect(typeof args[0][1]).toBe('function')
})
it('loads the international SDK script for the international site', async () => {
const wrapper = mount(TencentCaptchaGate, {
props: { appId: '123456789', region: 'intl' }
})
void wrapper.vm.verify()
await flushPromises()
const script = document.head.querySelector<HTMLScriptElement>(
'script[src*="TJNCaptcha-global.js"]'
)
expect(script?.src).toBe('https://ca.turing.captcha.qcloud.com/TJNCaptcha-global.js')
})
// 页面上已有的全局构造函数可能来自另一个站点(例如开发期 HMR 重置了模块状态)。
// 两个站点签名不兼容,错配会抛错,因此站点不一致时必须重新加载对应脚本而不是复用。
it('does not reuse a mainland global for the international site', async () => {
window.TencentCaptcha = class {
constructor() {}
show = vi.fn()
destroy = vi.fn()
} as unknown as typeof window.TencentCaptcha
const wrapper = mount(TencentCaptchaGate, {
props: { appId: '123456789', region: 'intl' }
})
void wrapper.vm.verify()
await flushPromises()
expect(
document.head.querySelector('script[src*="TJNCaptcha-global.js"]')
).not.toBeNull()
})
it('settles a pending verification when reset', async () => {
const destroy = vi.fn()
window.TencentCaptcha = class {
@@ -128,4 +264,31 @@ describe('TencentCaptchaGate', () => {
await expect(verification).resolves.toBeNull()
expect(destroy).toHaveBeenCalledOnce()
})
it('reinitializes the international checkbox after reset', async () => {
const destroy = vi.fn()
let constructorCount = 0
window.TCaptchaGlobal = true
window.TencentCaptcha = class {
constructor() {
constructorCount += 1
}
show = vi.fn()
destroy = destroy
} as unknown as typeof window.TencentCaptcha
const wrapper = mount(TencentCaptchaGate, {
props: { appId: '123456789', region: 'intl' }
})
await flushPromises()
expect(constructorCount).toBe(1)
wrapper.vm.reset()
await flushPromises()
expect(destroy).toHaveBeenCalledOnce()
expect(constructorCount).toBe(2)
expect(
wrapper.get('[data-testid="tencent-captcha-international-container"]').classes()
).not.toContain('scale-[0.01]')
})
})
@@ -24,6 +24,7 @@
:turnstile-site-key="turnstileSiteKey"
:tencent-enabled="tencentCaptchaEnabled"
:tencent-app-id="tencentCaptchaAppId"
:tencent-region="tencentCaptchaRegion"
:aliyun-enabled="aliyunCaptchaEnabled"
:aliyun-scene-id="aliyunCaptchaSceneId"
:aliyun-prefix="aliyunCaptchaPrefix"
@@ -141,6 +142,7 @@ const turnstileEnabled = ref(false)
const turnstileSiteKey = ref('')
const tencentCaptchaEnabled = ref(false)
const tencentCaptchaAppId = ref('')
const tencentCaptchaRegion = ref('cn')
const aliyunCaptchaEnabled = ref(false)
const aliyunCaptchaSceneId = ref('')
const aliyunCaptchaPrefix = ref('')
@@ -350,6 +352,7 @@ onMounted(async () => {
turnstileSiteKey.value = settings.turnstile_site_key || ''
tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true
tencentCaptchaAppId.value = settings.tencent_captcha_app_id || ''
tencentCaptchaRegion.value = settings.tencent_captcha_region || 'cn'
aliyunCaptchaEnabled.value = settings.aliyun_captcha_enabled === true
aliyunCaptchaSceneId.value = settings.aliyun_captcha_scene_id || ''
aliyunCaptchaPrefix.value = settings.aliyun_captcha_prefix || ''
@@ -361,6 +364,7 @@ onMounted(async () => {
turnstileSiteKey.value = ''
tencentCaptchaEnabled.value = false
tencentCaptchaAppId.value = ''
tencentCaptchaRegion.value = 'cn'
aliyunCaptchaEnabled.value = false
aliyunCaptchaSceneId.value = ''
aliyunCaptchaPrefix.value = ''