人机验证增加阿里云验证码 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 committed 2026-08-04 20:57:15 +08:00
1 parent d431c57f2e
commit 26e0a89323
57 files changed
+2445 -302

No files matched your search

@@ -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',