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
+2
View File
@@ -464,6 +464,7 @@ export interface SystemSettings {
tencent_captcha_app_secret_key_configured: boolean;
tencent_captcha_cloud_secret_id_configured: boolean;
tencent_captcha_cloud_secret_key_configured: boolean;
tencent_captcha_region: string;
aliyun_captcha_enabled: boolean;
aliyun_captcha_access_key_id: string;
aliyun_captcha_access_key_secret_configured: boolean;
@@ -789,6 +790,7 @@ export interface UpdateSettingsRequest {
tencent_captcha_app_secret_key?: string;
tencent_captcha_cloud_secret_id?: string;
tencent_captcha_cloud_secret_key?: string;
tencent_captcha_region?: string;
aliyun_captcha_enabled?: boolean;
aliyun_captcha_access_key_id?: string;
aliyun_captcha_access_key_secret?: string;
@@ -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 = ''
@@ -202,6 +202,10 @@ export default {
configured: 'Configured. Leave empty to keep it.',
required: 'Required before enabling.',
mutualExclusion: 'Tencent Captcha, Cloudflare Turnstile and Aliyun Captcha are mutually exclusive. Enabling one disables the others.',
region: 'Service site',
regionCn: 'Chinese mainland',
regionIntl: 'International',
regionHint: 'Selects the SDK script and the server-side verification endpoint. It must match the site that issued your CaptchaAppId; international apps are created in the tencentcloud.com console.',
appCredentialsTitle: 'Captcha application credentials',
appCredentialsHint: 'Get CaptchaAppId and AppSecretKey from Verification Management in the Captcha console.',
cloudCredentialsTitle: 'Cloud API credentials',
@@ -202,6 +202,10 @@ export default {
configured: '已配置,留空不会覆盖。',
required: '启用前必须填写此项。',
mutualExclusion: '腾讯天御验证码与 Cloudflare Turnstile、阿里云验证码互斥,开启其中一个会自动关闭其它。',
region: '服务站点',
regionCn: '中国站',
regionIntl: '国际站',
regionHint: '决定前端加载的 SDK 脚本与服务端校验接入点,需与 CaptchaAppId 所属站点一致;国际站请在 tencentcloud.com 控制台创建验证',
appCredentialsTitle: '验证码应用密钥',
appCredentialsHint: 'CaptchaAppId 与 AppSecretKey 来自验证码控制台的验证管理页面。',
cloudCredentialsTitle: '云 API 调用密钥',
+1
View File
@@ -220,6 +220,7 @@ export interface PublicSettings {
turnstile_enabled: boolean
tencent_captcha_enabled?: boolean
tencent_captcha_app_id?: string
tencent_captcha_region?: string
passkey_enabled?: boolean
turnstile_site_key: string
aliyun_captcha_enabled?: boolean
+64 -11
View File
@@ -1,3 +1,7 @@
// 腾讯天御验证码站点:cn=中国站(cloud.tencent.com),intl=国际站(tencentcloud.com)。
// 两个站点的 CaptchaAppId 不互通,SDK 脚本与服务端校验接入点都必须成对使用。
export type TencentCaptchaRegion = 'cn' | 'intl'
export interface TencentCaptchaProof {
ticket: string
randstr: string
@@ -16,39 +20,87 @@ interface TencentCaptchaInstance {
destroy(): void
}
type TencentCaptchaConstructor = new (
appId: string,
callback: (result: TencentCaptchaResult) => void,
options?: Record<string, unknown>
) => TencentCaptchaInstance
type TencentCaptchaCallback = (result: TencentCaptchaResult) => void
// 两个站点的构造函数签名不同,且不可互换:
// - 中国站 TJCaptcha.js:第一个参数是 CaptchaAppId 字符串(传 DOM 走另一条分支)。
// - 国际站 TJNCaptcha-global.js:第一个参数必须是承载 Robot checkbox 的 DOM 容器,传字符串会直接抛
// "The parameter of the constructor of the Captcha is passed incorrectly"。
type TencentCaptchaConstructor = {
new (
appId: string,
callback: TencentCaptchaCallback,
options?: Record<string, unknown>
): TencentCaptchaInstance
new (
element: HTMLElement,
appId: string,
callback: TencentCaptchaCallback,
options?: Record<string, unknown>
): TencentCaptchaInstance
}
declare global {
interface Window {
TencentCaptcha?: TencentCaptchaConstructor
// 国际站入口脚本会置为 true;国内站脚本只读取、从不写入。
// 用于判定页面上已存在的 TencentCaptcha 属于哪个站点。
TCaptchaGlobal?: boolean
}
}
const SCRIPT_SRC = 'https://turing.captcha.qcloud.com/TJCaptcha.js'
const SCRIPT_SRC: Record<TencentCaptchaRegion, string> = {
cn: 'https://turing.captcha.qcloud.com/TJCaptcha.js',
intl: 'https://ca.turing.captcha.qcloud.com/TJNCaptcha-global.js'
}
export function normalizeTencentCaptchaRegion(value?: string | null): TencentCaptchaRegion {
return value === 'intl' ? 'intl' : 'cn'
}
let scriptPromise: Promise<TencentCaptchaConstructor> | null = null
let loadedRegion: TencentCaptchaRegion | null = null
export function loadTencentCaptcha(): Promise<TencentCaptchaConstructor> {
if (window.TencentCaptcha) return Promise.resolve(window.TencentCaptcha)
if (scriptPromise) return scriptPromise
// existingGlobalRegion 推断页面上已存在的全局构造函数属于哪个站点。
// 两个站点的构造函数签名不兼容(国际站首参必须是 DOM 元素),错配会直接抛错,
// 因此复用已存在的全局前必须先确认站点一致。
function existingGlobalRegion(): TencentCaptchaRegion {
return window.TCaptchaGlobal === true ? 'intl' : 'cn'
}
export function loadTencentCaptcha(
region: TencentCaptchaRegion = 'cn'
): Promise<TencentCaptchaConstructor> {
// 两个站点的 SDK 注册同名全局 TencentCaptcha,缓存必须按站点隔离,
// 否则管理员切换站点后仍会复用上一个站点的构造函数。
// loadedRegion 为 null 表示全局不是本模块注入的(如开发期 HMR 后模块状态被重置),
// 此时靠 TCaptchaGlobal 判定站点,一致才复用。
const globalRegion = window.TencentCaptcha ? existingGlobalRegion() : null
if (window.TencentCaptcha && (loadedRegion === region || globalRegion === region)) {
return Promise.resolve(window.TencentCaptcha)
}
if (scriptPromise && loadedRegion === region) return scriptPromise
loadedRegion = region
scriptPromise = new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = SCRIPT_SRC
script.src = SCRIPT_SRC[region]
script.async = true
script.onload = () => {
if (window.TencentCaptcha) {
// 入口脚本被重复引入时腾讯会在求值阶段抛错("请勿多次引用腾讯验证码的接入js"),
// 此时 onload 仍会触发而全局仍是上一个站点的构造函数。校验站点,避免把
// 签名不兼容的构造函数当成本站点的返回出去。
if (window.TencentCaptcha && existingGlobalRegion() === region) {
resolve(window.TencentCaptcha)
return
}
scriptPromise = null
loadedRegion = null
reject(new Error('Tencent Captcha SDK is unavailable'))
}
script.onerror = () => {
scriptPromise = null
loadedRegion = null
reject(new Error('Failed to load Tencent Captcha SDK'))
}
document.head.appendChild(script)
@@ -59,4 +111,5 @@ export function loadTencentCaptcha(): Promise<TencentCaptchaConstructor> {
export function resetTencentCaptchaLoaderForTest(): void {
scriptPromise = null
loadedRegion = null
}
+57 -3
View File
@@ -2108,6 +2108,42 @@
<!-- Tencent Captcha fields -->
<div v-else-if="captchaProviderSelection === 'tencent'">
<div class="mb-6 max-w-sm">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("admin.settings.tencentCaptcha.region") }}
</label>
<div class="grid grid-cols-2 gap-2 rounded-lg bg-gray-100 p-1 dark:bg-dark-700">
<button
type="button"
data-testid="tencent-captcha-region-cn"
class="inline-flex items-center justify-center rounded-md px-3 py-1.5 text-sm font-medium transition"
:class="
form.tencent_captcha_region !== 'intl'
? 'bg-white text-primary-700 shadow-sm dark:bg-dark-800 dark:text-primary-300'
: 'text-gray-600 hover:text-gray-900 dark:text-dark-300 dark:hover:text-white'
"
@click="form.tencent_captcha_region = 'cn'"
>
{{ t("admin.settings.tencentCaptcha.regionCn") }}
</button>
<button
type="button"
data-testid="tencent-captcha-region-intl"
class="inline-flex items-center justify-center rounded-md px-3 py-1.5 text-sm font-medium transition"
:class="
form.tencent_captcha_region === 'intl'
? 'bg-white text-primary-700 shadow-sm dark:bg-dark-800 dark:text-primary-300'
: 'text-gray-600 hover:text-gray-900 dark:text-dark-300 dark:hover:text-white'
"
@click="form.tencent_captcha_region = 'intl'"
>
{{ t("admin.settings.tencentCaptcha.regionIntl") }}
</button>
</div>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.tencentCaptcha.regionHint") }}
</p>
</div>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="md:col-span-2">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
@@ -2191,7 +2227,7 @@
</p>
<div class="mt-3 flex flex-wrap gap-x-4 gap-y-2 text-sm">
<a
href="https://console.cloud.tencent.com/captcha"
:href="tencentCaptchaLinks.console"
target="_blank"
rel="noopener noreferrer"
class="text-primary-600 hover:text-primary-500"
@@ -2199,7 +2235,7 @@
{{ t("admin.settings.tencentCaptcha.openCaptchaConsole") }}
</a>
<a
href="https://console.cloud.tencent.com/cam/capi"
:href="tencentCaptchaLinks.cloudKeys"
target="_blank"
rel="noopener noreferrer"
class="text-primary-600 hover:text-primary-500"
@@ -2207,7 +2243,7 @@
{{ t("admin.settings.tencentCaptcha.createCloudKeys") }}
</a>
<a
href="https://cloud.tencent.com/document/product/1110/36841"
:href="tencentCaptchaLinks.webDocs"
target="_blank"
rel="noopener noreferrer"
class="text-primary-600 hover:text-primary-500"
@@ -9264,6 +9300,7 @@ const form = reactive<SettingsForm>({
tencent_captcha_cloud_secret_id_configured: false,
tencent_captcha_cloud_secret_key: "",
tencent_captcha_cloud_secret_key_configured: false,
tencent_captcha_region: "cn",
aliyun_captcha_enabled: false,
aliyun_captcha_access_key_id: "",
aliyun_captcha_access_key_secret: "",
@@ -9460,6 +9497,22 @@ function selectCaptchaProvider(provider: CaptchaProviderSelection): void {
applyCaptchaSelection(provider);
}
// 天御中国站与国际站是两套独立账号体系,控制台与文档入口不通用,
// 按当前选择的站点给出对应链接,避免管理员在错误的控制台里找不到 CaptchaAppId。
const tencentCaptchaLinks = computed(() =>
form.tencent_captcha_region === "intl"
? {
console: "https://console.tencentcloud.com/captcha/graphical",
cloudKeys: "https://console.tencentcloud.com/cam/capi",
webDocs: "https://www.tencentcloud.com/document/product/1159/49680",
}
: {
console: "https://console.cloud.tencent.com/captcha",
cloudKeys: "https://console.cloud.tencent.com/cam/capi",
webDocs: "https://cloud.tencent.com/document/product/1110/36841",
},
);
function syncCaptchaProviderSelection(): void {
if (form.tencent_captcha_enabled) {
captchaProviderSelection.value = "tencent";
@@ -10817,6 +10870,7 @@ async function saveSettings() {
form.tencent_captcha_cloud_secret_id || undefined,
tencent_captcha_cloud_secret_key:
form.tencent_captcha_cloud_secret_key || undefined,
tencent_captcha_region: form.tencent_captcha_region,
aliyun_captcha_enabled: form.aliyun_captcha_enabled,
aliyun_captcha_access_key_id: form.aliyun_captcha_access_key_id,
aliyun_captcha_access_key_secret:
@@ -813,6 +813,36 @@ describe("admin SettingsView payment visible method controls", () => {
tencent_captcha_app_secret_key: "app-secret-value",
tencent_captcha_cloud_secret_id: "cloud-secret-id-value",
tencent_captcha_cloud_secret_key: "cloud-secret-key-value",
tencent_captcha_region: "cn",
}),
);
});
it("腾讯天御切换到国际站后保存站点并更新控制台入口", async () => {
const wrapper = mountView();
await flushPromises();
await openSecurityTab(wrapper);
await wrapper.get('[data-testid="captcha-enabled-toggle"]').setValue(true);
await wrapper.get('[data-testid="captcha-provider-tencent"]').trigger("click");
await wrapper.get('[data-testid="tencent-captcha-region-intl"]').trigger("click");
const card = wrapper
.findAll(".card")
.find((node) => node.text().includes("admin.settings.captcha.title"));
expect(card).toBeDefined();
expect(card!.get('a[href="https://console.tencentcloud.com/captcha/graphical"]').exists()).toBe(
true,
);
expect(card!.get('a[href="https://console.tencentcloud.com/cam/capi"]').exists()).toBe(true);
await wrapper.find("form").trigger("submit.prevent");
await flushPromises();
expect(updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
tencent_captcha_enabled: true,
tencent_captcha_region: "intl",
}),
);
});
@@ -75,6 +75,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"
@@ -93,6 +94,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"
@@ -262,6 +264,7 @@ const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('')
const tencentCaptchaEnabled = ref<boolean>(false)
const tencentCaptchaAppId = ref<string>('')
const tencentCaptchaRegion = ref<string>('cn')
const aliyunCaptchaEnabled = ref<boolean>(false)
const aliyunCaptchaSceneId = ref<string>('')
const aliyunCaptchaPrefix = ref<string>('')
@@ -361,6 +364,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 || ''
@@ -74,6 +74,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"
@@ -157,6 +158,7 @@ const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('')
const tencentCaptchaEnabled = ref<boolean>(false)
const tencentCaptchaAppId = ref<string>('')
const tencentCaptchaRegion = ref<string>('cn')
const aliyunCaptchaEnabled = ref<boolean>(false)
const aliyunCaptchaSceneId = ref<string>('')
const aliyunCaptchaPrefix = ref<string>('')
@@ -209,6 +211,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 || ''
+3
View File
@@ -86,6 +86,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"
@@ -273,6 +274,7 @@ const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('')
const tencentCaptchaEnabled = ref<boolean>(false)
const tencentCaptchaAppId = ref<string>('')
const tencentCaptchaRegion = ref<string>('cn')
const aliyunCaptchaEnabled = ref<boolean>(false)
const aliyunCaptchaSceneId = ref<string>('')
const aliyunCaptchaPrefix = ref<string>('')
@@ -383,6 +385,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 || ''
+3
View File
@@ -211,6 +211,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"
@@ -392,6 +393,7 @@ const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('')
const tencentCaptchaEnabled = ref<boolean>(false)
const tencentCaptchaAppId = ref<string>('')
const tencentCaptchaRegion = ref<string>('cn')
const aliyunCaptchaEnabled = ref<boolean>(false)
const aliyunCaptchaSceneId = ref<string>('')
const aliyunCaptchaPrefix = ref<string>('')
@@ -524,6 +526,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 || ''
@@ -0,0 +1,104 @@
import { defineComponent, h } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ForgotPasswordView from '@/views/auth/ForgotPasswordView.vue'
const getPublicSettingsMock = vi.fn()
const forgotPasswordMock = vi.fn()
const verifyActionMock = vi.fn()
const captchaResetMock = vi.fn()
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({ t: (key: string) => key })
}
})
vi.mock('@/stores', () => ({
useAppStore: () => ({
showError: vi.fn(),
showSuccess: vi.fn()
})
}))
vi.mock('@/api/auth', () => ({
getPublicSettings: (...args: unknown[]) => getPublicSettingsMock(...args),
forgotPassword: (...args: unknown[]) => forgotPasswordMock(...args)
}))
const CaptchaChallengeStub = defineComponent({
props: {
tencentEnabled: Boolean,
tencentAppId: String,
tencentRegion: String
},
setup(_, { expose }) {
expose({
verifyAction: verifyActionMock,
reset: captchaResetMock
})
return () => h('div', { 'data-testid': 'captcha-challenge' })
}
})
function mountForgotPassword() {
return mount(ForgotPasswordView, {
global: {
stubs: {
AuthLayout: { template: '<div><slot /><slot name="footer" /></div>' },
Icon: true,
RouterLink: true,
TurnstileWidget: CaptchaChallengeStub
}
}
})
}
describe('忘记密码腾讯验证码门禁', () => {
beforeEach(() => {
getPublicSettingsMock.mockReset()
forgotPasswordMock.mockReset()
verifyActionMock.mockReset()
captchaResetMock.mockReset()
getPublicSettingsMock.mockResolvedValue({
turnstile_enabled: false,
turnstile_site_key: '',
tencent_captcha_enabled: true,
tencent_captcha_app_id: '123456789',
tencent_captcha_region: 'intl',
aliyun_captcha_enabled: false
})
verifyActionMock.mockResolvedValue({ token: 'ticket-value', randstr: '@rand-value' })
forgotPasswordMock.mockResolvedValue({ message: 'ok' })
})
it('把公开设置中的站点传给验证码组件', async () => {
const wrapper = mountForgotPassword()
await flushPromises()
const captcha = wrapper.findComponent(CaptchaChallengeStub)
expect(captcha.props('tencentEnabled')).toBe(true)
expect(captcha.props('tencentAppId')).toBe('123456789')
expect(captcha.props('tencentRegion')).toBe('intl')
})
it('提交前获取新票据并原样发送到忘记密码接口', async () => {
const wrapper = mountForgotPassword()
await flushPromises()
await wrapper.get('#email').setValue('user@example.com')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(verifyActionMock).toHaveBeenCalledOnce()
expect(forgotPasswordMock).toHaveBeenCalledWith({
email: 'user@example.com',
turnstile_token: undefined,
tencent_captcha_ticket: 'ticket-value',
tencent_captcha_randstr: '@rand-value'
})
expect(captchaResetMock).toHaveBeenCalledOnce()
})
})