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
+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()
})
})