人机验证增加阿里云验证码 2.0

沿用腾讯天御验证码引入的多服务商模型:aliyun_captcha_enabled 作为独立
开关,与 Cloudflare Turnstile、腾讯天御三方互斥(保存校验 + 运行时
CAPTCHA_PROVIDER_CONFLICT)。后台「安全与认证」合并为单张人机验证卡片:
总开关 + 服务商单选(Turnstile / 腾讯天御 / 阿里云),选中即启用该家并
关闭其它,落库仍是三个独立开关键,由前端映射保证互斥。

阿里云侧同时支持 aliyun 中国站与国际站(alibabacloud.com):两站前端脚本、
region 取值与服务端 API 完全一致,仅账号与 AccessKey 相互独立,因此由
「服务地域」决定线路即可——中国内地走 captcha.cn-shanghai.aliyuncs.com,
非中国内地(新加坡)走 captcha.ap-southeast-1.aliyuncs.com,AccessKey
取自持有该实例的账号,无需在配置中区分站点。

- AliyunCaptchaService 对称 TencentCaptchaService:服务端校验走官方 SDK
  VerifyIntelligentCaptcha,调用异常按 fail-closed 拦截,与 Turnstile
  网络错误行为对称;保存设置时真实探测 AK/SK 有效性
- 保护面对齐腾讯扩展入口:VerifyTencentCaptchaIfEnabled 通用化为
  VerifyActionCaptchaIfEnabled,OAuth 登录启动、passkey 登录在阿里云
  启用时同样拦截;Turnstile 维持既有覆盖不扩大
- 前端 AliyunCaptchaWidget 为表单内预验证按钮(popup 模式),同时暴露
  verify() 供 OAuth 启动、passkey 等动作入口程序化弹窗;未预验证直接
  提交时弹窗兜底。SDK 按钮绑定异步完成,弹窗未出现前按 tick 重试触发,
  并轮询弹窗可见性识别用户关闭
- captchaVerifyParam 复用 turnstile_token 请求字段提交;公开设置下发
  aliyun_captcha_enabled / scene_id / prefix / region
- CSP 放行验证码 CDN:script-src/style-src 加 *.alicdn.com
This commit is contained in:
feeeei
2026-08-04 20:57:15 +08:00
parent d431c57f2e
commit 26e0a89323
57 changed files with 2445 additions and 302 deletions
@@ -0,0 +1,363 @@
<template>
<div v-if="sceneId && prefix" class="aliyun-captcha-wrapper">
<button
:id="buttonId"
type="button"
class="aliyun-captcha-button"
:class="state === 'verified' ? 'aliyun-captcha-button--verified' : ''"
:disabled="state === 'verified'"
>
<svg
v-if="state === 'verified'"
class="aliyun-captcha-icon"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
<svg
v-else
class="aliyun-captcha-icon"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M9.661 2.237a.531.531 0 01.678 0 11.947 11.947 0 007.078 2.749.5.5 0 01.479.425c.069.52.104 1.05.104 1.59 0 5.162-3.26 9.563-7.834 11.256a.48.48 0 01-.332 0C5.26 16.564 2 12.163 2 7c0-.538.035-1.069.104-1.589a.5.5 0 01.48-.425 11.947 11.947 0 007.077-2.75z"
clip-rule="evenodd"
/>
</svg>
<span>{{ buttonText }}</span>
</button>
<div :id="elementId"></div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
interface AliyunCaptchaVerifyResult {
captchaResult: boolean
bizResult?: boolean
}
interface AliyunCaptchaInitOptions {
SceneId: string
prefix: string
mode: 'popup' | 'embed'
element: string
button: string
captchaVerifyCallback: (
captchaVerifyParam: string
) => AliyunCaptchaVerifyResult | Promise<AliyunCaptchaVerifyResult>
onBizResultCallback: (bizResult: boolean) => void
getInstance: (instance: unknown) => void
slideStyle?: { width: number; height: number }
language?: string
}
declare global {
interface Window {
initAliyunCaptcha?: (options: AliyunCaptchaInitOptions) => void
AliyunCaptchaConfig?: { region: string; prefix: string }
}
}
const props = withDefaults(
defineProps<{
sceneId: string
prefix: string
region?: 'cn' | 'sgp'
}>(),
{
region: 'cn'
}
)
const emit = defineEmits<{
(e: 'verify', param: string): void
(e: 'expire'): void
(e: 'error'): void
}>()
const { t, locale } = useI18n()
const uid = Math.random().toString(36).slice(2, 10)
const buttonId = `aliyun-captcha-button-${uid}`
const elementId = `aliyun-captcha-element-${uid}`
// idle: 未验证可点击;verifying: 弹窗已拉起(关闭后回到 idle 可重试);verified: 已通过
const state = ref<'idle' | 'verifying' | 'verified'>('idle')
const buttonText = computed(() => {
switch (state.value) {
case 'verified':
return t('auth.captchaVerified')
case 'verifying':
return t('auth.captchaVerifying')
default:
return t('auth.captchaClickToVerify')
}
})
const SCRIPT_SRC = 'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js'
const POPUP_ID = 'aliyunCaptcha-window-popup'
const MASK_ID = 'aliyunCaptcha-mask'
const POPUP_OPEN_TIMEOUT_MS = 8000
const POPUP_WATCH_INTERVAL_MS = 300
// captchaVerifyParam 是一次性参数:verified 后缓存于此,提交失败需 reset 后重新验证
let cachedParam: string | null = null
let pending: { resolve: (value: string | null) => void } | null = null
let popupWatchTimer: number | null = null
let readyPromise: Promise<void> | null = null
const loadScript = (): Promise<void> => {
return new Promise((resolve, reject) => {
// 全局配置必须在脚本加载前就位(region/prefix 全站一致,重复赋值无副作用)
window.AliyunCaptchaConfig = { region: props.region, prefix: props.prefix }
if (window.initAliyunCaptcha) {
resolve()
return
}
const existingScript = document.querySelector<HTMLScriptElement>(
'script[src*="aliyunCaptcha/AliyunCaptcha"]'
)
if (existingScript) {
existingScript.addEventListener('load', () => resolve())
existingScript.addEventListener('error', () =>
reject(new Error('Failed to load Aliyun captcha script'))
)
return
}
const script = document.createElement('script')
script.src = SCRIPT_SRC
script.async = true
script.onload = () => resolve()
script.onerror = () => reject(new Error('Failed to load Aliyun captcha script'))
document.head.appendChild(script)
})
}
function initCaptcha(): void {
if (!window.initAliyunCaptcha) {
throw new Error('Aliyun captcha script not ready')
}
window.initAliyunCaptcha({
SceneId: props.sceneId,
prefix: props.prefix,
mode: 'popup',
element: `#${elementId}`,
button: `#${buttonId}`,
// 这里不发业务请求,只把 captchaVerifyParam 当 token 交给页面,随登录/注册等
// 业务请求的 turnstile_token 字段提交,由后端在业务接口内调阿里云校验。
captchaVerifyCallback: (captchaVerifyParam: string) => {
onCaptchaParam(captchaVerifyParam)
return { captchaResult: true }
},
onBizResultCallback: () => {},
getInstance: () => {},
slideStyle: { width: 360, height: 40 },
language: locale.value.toLowerCase().startsWith('zh') ? 'cn' : 'en'
})
}
function ensureReady(): Promise<void> {
if (!readyPromise) {
readyPromise = loadScript().then(() => initCaptcha())
readyPromise.catch(() => {
// 失败后允许下次重试(如网络恢复)
readyPromise = null
})
}
return readyPromise
}
function onCaptchaParam(param: string): void {
stopPopupWatch()
cachedParam = param
state.value = 'verified'
emit('verify', param)
const current = pending
pending = null
current?.resolve(param)
}
function settlePending(value: string | null): void {
const current = pending
pending = null
current?.resolve(value)
}
function isPopupVisible(): boolean {
const popup = document.getElementById(POPUP_ID)
if (!popup) return false
return window.getComputedStyle(popup).display !== 'none'
}
function stopPopupWatch(): void {
if (popupWatchTimer !== null) {
window.clearInterval(popupWatchTimer)
popupWatchTimer = null
}
}
// SDK 没有用户关闭弹窗的回调,靠轮询弹窗可见性兜底:
// 弹窗出现过又消失且未产生 param → 用户主动关闭;迟迟未出现 → 打开失败。
// initAliyunCaptcha 对触发按钮的事件绑定是异步完成的,首次 click 可能落空,
// 因此弹窗出现前每个 tick 重试触发一次。
function startPopupWatch(): void {
stopPopupWatch()
const startedAt = Date.now()
let seen = false
popupWatchTimer = window.setInterval(() => {
if (state.value === 'verified') {
stopPopupWatch()
return
}
if (isPopupVisible()) {
seen = true
return
}
if (seen || Date.now() - startedAt > POPUP_OPEN_TIMEOUT_MS) {
stopPopupWatch()
state.value = 'idle'
settlePending(null)
return
}
document.getElementById(buttonId)?.click()
}, POPUP_WATCH_INTERVAL_MS)
}
// 用户点击与程序化触发共用:置 verifying 并启动弹窗监视(幂等,重试 click 不重置计时)
function handleTriggerClick(): void {
if (state.value === 'verified') {
return
}
state.value = 'verifying'
if (popupWatchTimer === null) {
startPopupWatch()
}
}
// 程序化触发验证(OAuth 启动、passkey、未预验证时的表单提交兜底):
// 已通过预验证则直接复用缓存的 captchaVerifyParam;否则弹出验证码等待结果。
// 用户关闭/未能弹出 resolve null;脚本加载失败 reject。
async function verify(): Promise<string | null> {
if (state.value === 'verified' && cachedParam) {
return cachedParam
}
settlePending(null)
await ensureReady()
return new Promise<string | null>((resolve) => {
pending = { resolve }
document.getElementById(buttonId)?.click()
})
}
// 重置为未验证态;captchaVerifyParam 是一次性参数,服务端校验失败后需用户重新验证
function reset(): void {
stopPopupWatch()
settlePending(null)
cachedParam = null
state.value = 'idle'
}
defineExpose({ verify, reset })
onMounted(async () => {
if (!props.sceneId || !props.prefix) {
return
}
document.getElementById(buttonId)?.addEventListener('click', handleTriggerClick)
try {
await ensureReady()
} catch (error) {
console.error('Failed to initialize Aliyun captcha:', error)
emit('error')
}
})
onUnmounted(() => {
document.getElementById(buttonId)?.removeEventListener('click', handleTriggerClick)
stopPopupWatch()
settlePending(null)
// SDK 不会自清理弹窗 DOM,残留会导致下次挂载时回调重复触发
document.getElementById(MASK_ID)?.remove()
document.getElementById(POPUP_ID)?.remove()
})
</script>
<style scoped>
.aliyun-captcha-wrapper {
width: 100%;
}
.aliyun-captcha-button {
display: flex;
width: 100%;
min-height: 44px;
align-items: center;
justify-content: center;
gap: 0.5rem;
border-radius: 0.5rem;
border: 1px solid rgb(209 213 219);
background-color: rgb(249 250 251);
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
color: rgb(55 65 81);
transition:
border-color 0.15s ease,
background-color 0.15s ease,
color 0.15s ease;
}
.aliyun-captcha-button:hover:not(:disabled) {
border-color: rgb(156 163 175);
background-color: rgb(243 244 246);
}
.aliyun-captcha-button--verified {
border-color: rgb(34 197 94);
background-color: rgb(240 253 244);
color: rgb(21 128 61);
}
:root.dark .aliyun-captcha-button,
.dark .aliyun-captcha-button {
border-color: rgb(55 65 81);
background-color: rgb(31 41 55);
color: rgb(209 213 219);
}
:root.dark .aliyun-captcha-button:hover:not(:disabled),
.dark .aliyun-captcha-button:hover:not(:disabled) {
border-color: rgb(75 85 99);
background-color: rgb(55 65 81);
}
:root.dark .aliyun-captcha-button--verified,
.dark .aliyun-captcha-button--verified {
border-color: rgb(34 197 94);
background-color: rgb(20 83 45 / 0.3);
color: rgb(134 239 172);
}
.aliyun-captcha-icon {
height: 1rem;
width: 1rem;
flex-shrink: 0;
}
</style>
+48 -9
View File
@@ -12,13 +12,30 @@
ref="tencentRef"
:app-id="tencentAppId"
/>
<AliyunCaptchaWidget
v-else-if="aliyunEnabled && aliyunSceneId && aliyunPrefix"
ref="aliyunRef"
:scene-id="aliyunSceneId"
:prefix="aliyunPrefix"
:region="aliyunRegion === 'sgp' ? 'sgp' : 'cn'"
@verify="(param: string) => emit('verify', param, '')"
@expire="emit('expire')"
@error="emit('error')"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
import TencentCaptchaGate from '@/components/TencentCaptchaGate.vue'
import type { TencentCaptchaProof } from '@/utils/tencentCaptcha'
import AliyunCaptchaWidget from '@/components/AliyunCaptchaWidget.vue'
// ActionCaptchaResult 动作触发式验证(腾讯/阿里云弹窗)的结果:
// 腾讯 token=ticket、randstr 非空;阿里云 token=captchaVerifyParam、randstr 恒为空。
export interface ActionCaptchaResult {
token: string
randstr: string
}
const props = defineProps<{
siteKey?: string
@@ -26,6 +43,10 @@ const props = defineProps<{
turnstileSiteKey: string
tencentEnabled: boolean
tencentAppId: string
aliyunEnabled?: boolean
aliyunSceneId?: string
aliyunPrefix?: string
aliyunRegion?: string
}>()
const emit = defineEmits<{
@@ -36,21 +57,39 @@ const emit = defineEmits<{
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
const tencentRef = ref<InstanceType<typeof TencentCaptchaGate> | null>(null)
const aliyunRef = ref<InstanceType<typeof AliyunCaptchaWidget> | null>(null)
function reset(): void {
turnstileRef.value?.reset()
tencentRef.value?.reset()
aliyunRef.value?.reset()
}
async function verifyTencent(): Promise<TencentCaptchaProof | null> {
if (!props.tencentEnabled || !props.tencentAppId) return null
try {
return (await tencentRef.value?.verify()) ?? null
} catch {
emit('error')
return null
// verifyAction 弹出当前启用的动作触发式验证码(腾讯/阿里云)并等待结果;
// 用户关闭弹窗返回 null,验证异常 emit('error') 并返回 null。
async function verifyAction(): Promise<ActionCaptchaResult | null> {
if (props.tencentEnabled && props.tencentAppId) {
try {
const proof = (await tencentRef.value?.verify()) ?? null
if (!proof) return null
return { token: proof.ticket, randstr: proof.randstr }
} catch {
emit('error')
return null
}
}
if (props.aliyunEnabled && props.aliyunSceneId && props.aliyunPrefix) {
try {
const param = (await aliyunRef.value?.verify()) ?? null
if (!param) return null
return { token: param, randstr: '' }
} catch {
emit('error')
return null
}
}
return null
}
defineExpose({ reset, verifyTencent })
defineExpose({ reset, verifyAction })
</script>
@@ -0,0 +1,201 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import AliyunCaptchaWidget from '../AliyunCaptchaWidget.vue'
interface CapturedInitOptions {
SceneId: string
prefix: string
mode: string
element: string
button: string
captchaVerifyCallback: (param: string) => { captchaResult: boolean }
language?: string
}
const i18nStub = {
install(app: { config: { globalProperties: Record<string, unknown> } }) {
app.config.globalProperties.$t = (key: string) => key
}
}
vi.mock('vue-i18n', () => ({
useI18n: () => ({ locale: { value: 'zh-CN' }, t: (key: string) => key })
}))
describe('AliyunCaptchaWidget', () => {
let initOptions: CapturedInitOptions | null
beforeEach(() => {
initOptions = null
window.initAliyunCaptcha = vi.fn((options: CapturedInitOptions) => {
initOptions = options
}) as unknown as typeof window.initAliyunCaptcha
})
afterEach(() => {
vi.useRealTimers()
delete window.initAliyunCaptcha
delete window.AliyunCaptchaConfig
document.getElementById('aliyunCaptcha-window-popup')?.remove()
document.getElementById('aliyunCaptcha-mask')?.remove()
})
function mountWidget() {
return mount(AliyunCaptchaWidget, {
props: { sceneId: 'scene-1', prefix: 'prefix-1', region: 'cn' as const },
attachTo: document.body,
global: { plugins: [i18nStub] }
})
}
function createVisiblePopup(): HTMLElement {
const popup = document.createElement('div')
popup.id = 'aliyunCaptcha-window-popup'
popup.style.display = 'block'
document.body.appendChild(popup)
return popup
}
it('渲染可见验证按钮并以 popup 模式初始化,全局配置就位', async () => {
const wrapper = mountWidget()
await Promise.resolve()
await Promise.resolve()
const button = wrapper.get('button')
expect(button.text()).toContain('auth.captchaClickToVerify')
expect(window.AliyunCaptchaConfig).toEqual({ region: 'cn', prefix: 'prefix-1' })
expect(initOptions).not.toBeNull()
expect(initOptions!.mode).toBe('popup')
expect(initOptions!.SceneId).toBe('scene-1')
expect(initOptions!.language).toBe('cn')
wrapper.unmount()
})
it('用户点击按钮进入验证中,验证完成后 emit verify 并置已通过', async () => {
const wrapper = mountWidget()
await Promise.resolve()
await Promise.resolve()
await wrapper.get('button').trigger('click')
expect(wrapper.get('button').text()).toContain('auth.captchaVerifying')
const result = initOptions!.captchaVerifyCallback('captcha-param-1')
expect(result).toEqual({ captchaResult: true })
await wrapper.vm.$nextTick()
expect(wrapper.emitted('verify')).toEqual([['captcha-param-1']])
expect(wrapper.get('button').text()).toContain('auth.captchaVerified')
expect(wrapper.get('button').attributes('disabled')).toBeDefined()
wrapper.unmount()
})
it('verify() 在已预验证时直接复用缓存的 captchaVerifyParam', async () => {
const wrapper = mountWidget()
await Promise.resolve()
await Promise.resolve()
await wrapper.get('button').trigger('click')
initOptions!.captchaVerifyCallback('captcha-param-2')
const vm = wrapper.vm as unknown as { verify: () => Promise<string | null> }
await expect(vm.verify()).resolves.toBe('captcha-param-2')
wrapper.unmount()
})
it('verify() 在未预验证时触发弹窗流程并等待结果', async () => {
const wrapper = mountWidget()
await Promise.resolve()
await Promise.resolve()
const vm = wrapper.vm as unknown as { verify: () => Promise<string | null> }
const pending = vm.verify()
await Promise.resolve()
await Promise.resolve()
await wrapper.vm.$nextTick()
expect(wrapper.get('button').text()).toContain('auth.captchaVerifying')
initOptions!.captchaVerifyCallback('captcha-param-3')
await expect(pending).resolves.toBe('captcha-param-3')
wrapper.unmount()
})
it('弹窗未出现前会按 tick 重试触发按钮(SDK 异步绑定兜底)', async () => {
vi.useFakeTimers()
const wrapper = mountWidget()
await Promise.resolve()
await Promise.resolve()
const vm = wrapper.vm as unknown as { verify: () => Promise<string | null> }
void vm.verify()
await Promise.resolve()
await Promise.resolve()
const button = wrapper.get('button').element as HTMLButtonElement
const clickSpy = vi.fn()
button.addEventListener('click', clickSpy)
await vi.advanceTimersByTimeAsync(1000)
expect(clickSpy.mock.calls.length).toBeGreaterThanOrEqual(3)
button.removeEventListener('click', clickSpy)
wrapper.unmount()
})
it('弹窗出现后被用户关闭时 resolve null 并回到未验证态', async () => {
vi.useFakeTimers()
const wrapper = mountWidget()
await Promise.resolve()
await Promise.resolve()
const vm = wrapper.vm as unknown as { verify: () => Promise<string | null> }
const pending = vm.verify()
await Promise.resolve()
await Promise.resolve()
const popup = createVisiblePopup()
await vi.advanceTimersByTimeAsync(400)
popup.remove()
await vi.advanceTimersByTimeAsync(400)
await expect(pending).resolves.toBeNull()
expect(wrapper.get('button').text()).toContain('auth.captchaClickToVerify')
wrapper.unmount()
})
it('reset() 清空缓存并取消进行中的验证', async () => {
const wrapper = mountWidget()
await Promise.resolve()
await Promise.resolve()
await wrapper.get('button').trigger('click')
initOptions!.captchaVerifyCallback('captcha-param-4')
const vm = wrapper.vm as unknown as {
verify: () => Promise<string | null>
reset: () => void
}
vm.reset()
await wrapper.vm.$nextTick()
expect(wrapper.get('button').text()).toContain('auth.captchaClickToVerify')
// reset 后缓存失效,verify() 重新走弹窗流程
const pending = vm.verify()
await Promise.resolve()
initOptions!.captchaVerifyCallback('captcha-param-5')
await expect(pending).resolves.toBe('captcha-param-5')
wrapper.unmount()
})
})
declare global {
interface Window {
initAliyunCaptcha?: (options: unknown) => void
AliyunCaptchaConfig?: { region: string; prefix: string }
}
}
@@ -24,6 +24,10 @@
:turnstile-site-key="turnstileSiteKey"
:tencent-enabled="tencentCaptchaEnabled"
:tencent-app-id="tencentCaptchaAppId"
:aliyun-enabled="aliyunCaptchaEnabled"
:aliyun-scene-id="aliyunCaptchaSceneId"
:aliyun-prefix="aliyunCaptchaPrefix"
:aliyun-region="aliyunCaptchaRegion"
@verify="onTurnstileVerify"
@expire="onTurnstileExpire"
@error="onTurnstileError"
@@ -137,13 +141,28 @@ const turnstileEnabled = ref(false)
const turnstileSiteKey = ref('')
const tencentCaptchaEnabled = ref(false)
const tencentCaptchaAppId = ref('')
const aliyunCaptchaEnabled = ref(false)
const aliyunCaptchaSceneId = ref('')
const aliyunCaptchaPrefix = ref('')
const aliyunCaptchaRegion = ref('cn')
const turnstileToken = ref('')
const tencentCaptchaRandstr = ref('')
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
const aliyunCaptchaReady = computed(
() =>
aliyunCaptchaEnabled.value &&
Boolean(aliyunCaptchaSceneId.value) &&
Boolean(aliyunCaptchaPrefix.value)
)
// 动作触发式验证码(腾讯/阿里云):发送验证码、提交时弹窗验证
const actionCaptchaEnabled = computed(
() =>
(tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value)) ||
aliyunCaptchaReady.value
)
const captchaEnabled = computed(
() =>
(turnstileEnabled.value && Boolean(turnstileSiteKey.value)) ||
(tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value))
(turnstileEnabled.value && Boolean(turnstileSiteKey.value)) || actionCaptchaEnabled.value
)
let countdownTimer: ReturnType<typeof setInterval> | null = null
@@ -229,13 +248,13 @@ function onTurnstileError() {
sendCodeError.value = t('auth.turnstileFailed')
}
async function acquireTencentProof(): Promise<boolean> {
if (!tencentCaptchaEnabled.value) return true
async function acquireActionProof(): Promise<boolean> {
if (!actionCaptchaEnabled.value) return true
const proof = await turnstileRef.value?.verifyTencent()
const proof = await turnstileRef.value?.verifyAction()
if (!proof) return false
turnstileToken.value = proof.ticket
turnstileToken.value = proof.token
tencentCaptchaRandstr.value = proof.randstr
return true
}
@@ -251,7 +270,7 @@ async function handleSendCode() {
return
}
if (!(await acquireTencentProof())) {
if (!(await acquireActionProof())) {
return
}
@@ -262,7 +281,8 @@ async function handleSendCode() {
try {
const response = await sendPendingOAuthVerifyCode({
email: trimmedEmail,
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
turnstile_token:
turnstileEnabled.value || aliyunCaptchaEnabled.value ? turnstileToken.value : undefined,
tencent_captcha_ticket: tencentCaptchaEnabled.value ? turnstileToken.value : undefined,
tencent_captcha_randstr: tencentCaptchaEnabled.value ? tencentCaptchaRandstr.value : undefined
})
@@ -292,7 +312,7 @@ async function handleSubmit() {
return
}
if (!(await acquireTencentProof())) {
if (!(await acquireActionProof())) {
return
}
@@ -300,7 +320,9 @@ async function handleSubmit() {
email: trimmedEmail,
password: password.value,
verifyCode: emailVerifyEnabled.value ? verifyCode.value.trim() : '',
...(turnstileEnabled.value && turnstileToken.value ? { turnstileToken: turnstileToken.value } : {}),
...((turnstileEnabled.value || aliyunCaptchaEnabled.value) && turnstileToken.value
? { turnstileToken: turnstileToken.value }
: {}),
...(tencentCaptchaEnabled.value && turnstileToken.value
? {
tencentCaptchaTicket: turnstileToken.value,
@@ -310,7 +332,7 @@ async function handleSubmit() {
invitationCode: invitationCode.value.trim() || undefined
})
if (tencentCaptchaEnabled.value) {
if (actionCaptchaEnabled.value) {
resetTurnstile()
}
}
@@ -328,6 +350,10 @@ onMounted(async () => {
turnstileSiteKey.value = settings.turnstile_site_key || ''
tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true
tencentCaptchaAppId.value = settings.tencent_captcha_app_id || ''
aliyunCaptchaEnabled.value = settings.aliyun_captcha_enabled === true
aliyunCaptchaSceneId.value = settings.aliyun_captcha_scene_id || ''
aliyunCaptchaPrefix.value = settings.aliyun_captcha_prefix || ''
aliyunCaptchaRegion.value = settings.aliyun_captcha_region || 'cn'
} catch {
invitationCodeEnabled.value = false
emailVerifyEnabled.value = true
@@ -335,6 +361,10 @@ onMounted(async () => {
turnstileSiteKey.value = ''
tencentCaptchaEnabled.value = false
tencentCaptchaAppId.value = ''
aliyunCaptchaEnabled.value = false
aliyunCaptchaSceneId.value = ''
aliyunCaptchaPrefix.value = ''
aliyunCaptchaRegion.value = 'cn'
}
})
@@ -9,7 +9,7 @@ const sendPendingOAuthVerifyCode = vi.fn()
const getPublicSettings = vi.fn()
const showError = vi.fn()
const turnstileReset = vi.fn()
const verifyTencent = vi.fn()
const verifyAction = vi.fn()
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
@@ -44,7 +44,7 @@ describe('PendingOAuthCreateAccountForm', () => {
getPublicSettings.mockReset()
showError.mockReset()
turnstileReset.mockReset()
verifyTencent.mockReset()
verifyAction.mockReset()
getPublicSettings.mockResolvedValue({
turnstile_enabled: false,
turnstile_site_key: ''
@@ -60,12 +60,12 @@ describe('PendingOAuthCreateAccountForm', () => {
tencent_captcha_app_id: 'tencent-app-id'
})
sendPendingOAuthVerifyCode.mockResolvedValue({ countdown: 0 })
verifyTencent
.mockResolvedValueOnce({ ticket: 'ticket-1', randstr: '@rand-1' })
.mockResolvedValueOnce({ ticket: 'ticket-2', randstr: '@rand-2' })
verifyAction
.mockResolvedValueOnce({ token: 'ticket-1', randstr: '@rand-1' })
.mockResolvedValueOnce({ token: 'ticket-2', randstr: '@rand-2' })
const CaptchaChallengeStub = defineComponent({
setup(_, { expose }) {
expose({ verifyTencent, reset: turnstileReset })
expose({ verifyAction, reset: turnstileReset })
return () => h('div')
}
})
@@ -89,7 +89,7 @@ describe('PendingOAuthCreateAccountForm', () => {
await wrapper.get('[data-testid="oidc-create-account-submit"]').trigger('click')
await flushPromises()
expect(verifyTencent).toHaveBeenCalledTimes(2)
expect(verifyAction).toHaveBeenCalledTimes(2)
expect(sendPendingOAuthVerifyCode).toHaveBeenCalledWith({
email: 'user@example.com',
tencent_captcha_ticket: 'ticket-1',