feat(captcha): 打通人机验证 11 个场景的前后端完整链路

- 统一验证入口 aksrt_require_captcha(),返回 true|WP_Error,启用场景配置缺失时 fail-closed 拒绝
- 前端公共 API 重构为 window.AKSRTCaptcha(verify/collect/reset/run),单飞锁防并发重复验证,一次性 proof 绑定浏览器 cookie+场景+服务商+主机名
- challenge/exchange REST 端点:challenge 原子占用防票据放大厂商调用,增加频率限制
- 新增 OAuth bind pass:服务端签发 5 分钟一次性令牌,绑定用户+服务商+浏览器,微信扫码延迟消费
- 覆盖场景:评论、登录、注册、绑定第三方、绑定手机、找回密码、修改密码、更改邮箱、资料下载、签到、前台投稿
- 移除 Service 层重复验证与 _auth_code_verified/_defer_login 等客户端可伪造标志,删除下载共享 pass 死代码
- 修复腾讯 TC3 签名 scope、Cloudflare action/cdata/hostname 严格校验、阿里云验签错误码映射
- 后台 Schema 保存对 secret 字段做掩码/保留合并;导入导出过滤 aksrt_captcha_config
- 重建 public bundle 与 admin SPA;新增 captcha 运行时/安全/业务契约测试与 harness
This commit is contained in:
kate522 committed 2026-09-02 13:03:24 +08:00
1 parent d2e95acd75
commit ff4ec1eb43
45 files changed
+2694 -1435

No files matched your search

+71 -66
View File
@@ -19,28 +19,28 @@
v-if="isPermissionsLogin"
#field-tencent="{ value, setValue }"
>
<TencentConfig :model-value="value || {}" @update:model-value="setValue" />
<TencentConfig :model-value="value || {}" @update:model-value="setValue" @clear-secret="markCaptchaSecretForClear('tencent', $event)" @restore-secret="unmarkCaptchaSecretForClear('tencent', $event)" />
</template>
<template
v-if="isPermissionsLogin"
#field-aliyun="{ value, setValue }"
>
<AliyunConfig :model-value="value || {}" @update:model-value="setValue" />
<AliyunConfig :model-value="value || {}" @update:model-value="setValue" @clear-secret="markCaptchaSecretForClear('aliyun', $event)" @restore-secret="unmarkCaptchaSecretForClear('aliyun', $event)" />
</template>
<template
v-if="isPermissionsLogin"
#field-cloudflare="{ value, setValue }"
>
<CloudflareConfig :model-value="value || {}" @update:model-value="setValue" />
<CloudflareConfig :model-value="value || {}" @update:model-value="setValue" @clear-secret="markCaptchaSecretForClear('cloudflare', $event)" @restore-secret="unmarkCaptchaSecretForClear('cloudflare', $event)" />
</template>
<template
v-if="isPermissionsLogin"
#field-geetest="{ value, setValue }"
>
<GeetestConfig :model-value="value || {}" @update:model-value="setValue" />
<GeetestConfig :model-value="value || {}" @update:model-value="setValue" @clear-secret="markCaptchaSecretForClear('geetest', $event)" @restore-secret="unmarkCaptchaSecretForClear('geetest', $event)" />
</template>
<template
@@ -131,6 +131,7 @@ const showTestModal = ref(false)
const testLoading = ref(false)
const testResult = ref(null)
const serverSchema = ref(null)
const captchaSecretsToClear = ref(new Set())
/** Resolve domain/section from props or route meta (canonicalDomain + section, with legacy fallback) */
const resolvedDomain = computed(() => normalizeSchemaDomain(props.domain || route.meta?.canonicalDomain || route.meta?.schemaDomain || resolveDomainFromPath(route.path)))
@@ -210,8 +211,12 @@ const {
}
})
if (clearSecrets.length) payload.__clear_secrets = clearSecrets
if (isPermissionsLogin.value && captchaSecretsToClear.value.size) {
payload.__clear_secrets = [...(payload.__clear_secrets || []), ...captchaSecretsToClear.value]
}
const response = await api.saveSchemaPage(resolvedDomain.value, resolvedSection.value, payload)
clearSecrets.forEach((key) => { data[key] = '' })
if (isPermissionsLogin.value) captchaSecretsToClear.value = new Set()
return response
},
defaults: defaults.value,
@@ -230,10 +235,26 @@ async function handleSave() {
function handleReload() {
closeTestModal()
captchaSecretsToClear.value = new Set()
reload()
message.info('已放弃更改')
}
function markCaptchaSecretForClear(provider, field) {
const path = `${provider}.${field}`
const next = new Set(captchaSecretsToClear.value)
next.add(path)
captchaSecretsToClear.value = next
}
function unmarkCaptchaSecretForClear(provider, field) {
const path = `${provider}.${field}`
if (!captchaSecretsToClear.value.has(path)) return
const next = new Set(captchaSecretsToClear.value)
next.delete(path)
captchaSecretsToClear.value = next
}
function getCaptchaProviderConfig(provider) {
if (!formData.value || !provider) return {}
const cfg = formData.value[provider]
@@ -256,9 +277,8 @@ function handleCaptchaTest() {
message.warning(`请先填写必填字段: ${missing.join(', ')}`)
return
}
if (provider === 'tencent') {
loadTencentTest(cfg)
if (dirty.value) {
message.warning('请先保存当前配置,再执行真实服务端验签。')
return
}
@@ -267,52 +287,20 @@ function handleCaptchaTest() {
testLoading.value = true
nextTick(() => {
createTestIframe(provider, cfg)
loadProviderSDK(provider, cfg)
})
}
function loadTencentTest(cfg) {
const sdkSrc = 'https://turing.captcha.qcloud.com/TJCaptcha.js'
if (typeof window.TencentCaptcha !== 'undefined') {
try {
new window.TencentCaptcha(cfg.app_id, function(res) {
if (res.ret === 0) message.success('腾讯云验证码测试成功!配置正确。')
else message.warning('验证已取消。')
}, { userLanguage: 'zh-cn' }).show()
} catch (error) {
message.error('初始化失败: ' + error.message)
}
return
}
const existingScript = document.querySelector('script[src="' + sdkSrc + '"]')
if (existingScript) {
existingScript.addEventListener('load', function() { loadTencentTest(cfg) }, { once: true })
return
}
const script = document.createElement('script')
script.src = sdkSrc
script.onload = function () {
try {
new window.TencentCaptcha(cfg.app_id, function(res) {
if (res.ret === 0) message.success('腾讯云验证码测试成功!')
else message.warning('验证已取消。')
}, { userLanguage: 'zh-cn' }).show()
} catch (error) {
message.error('初始化失败: ' + error.message)
}
}
script.onerror = function () { message.error('加载腾讯验证码SDK失败') }
document.head.appendChild(script)
}
function createTestIframe(provider, cfg) {
setTimeout(() => {
async function submitCaptchaTest(provider, token) {
testLoading.value = true
try {
const response = await api.testCaptcha(provider, token)
testResult.value = { success: true, message: response?.message || '服务端同源验签通过' }
} catch (error) {
testResult.value = { success: false, message: error?.message || '服务端验签失败' }
} finally {
testLoading.value = false
loadProviderSDK(provider, cfg)
}, 100)
}
}
function loadProviderSDK(provider, cfg) {
@@ -331,28 +319,37 @@ function loadProviderSDK(provider, cfg) {
case 'tencent':
script.src = 'https://turing.captcha.qcloud.com/TJCaptcha.js'
script.onload = function () {
testLoading.value = false
try {
new window.TencentCaptcha(cfg.app_id, function(res) {
testResult.value = { success: res.ret === 0, message: res.ret === 0 ? '验证成功!' : '验证已取消。' }
if (res.ret === 0) {
submitCaptchaTest('tencent', {
tencent_ticket: res.ticket || '',
tencent_randstr: res.randstr || '',
})
} else {
testResult.value = { success: false, message: '验证已取消。' }
}
}, { userLanguage: 'zh-cn' }).show()
} catch (error) {
testResult.value = { success: false, message: '初始化失败: ' + error.message }
}
}
script.onerror = function () { testResult.value = { success: false, message: '加载SDK失败' } }
script.onerror = function () { testLoading.value = false; testResult.value = { success: false, message: '加载SDK失败' } }
break
case 'cloudflare':
window.onTurnstileTestLoad = function() {
testLoading.value = false
try {
window.turnstile.render('#captcha-test-box', {
sitekey: cfg.site_key,
theme: 'auto',
theme: cfg.theme || 'auto',
size: cfg.size || 'normal',
callback: function() {
testResult.value = { success: true, message: '验证成功!' }
message.success('Cloudflare Turnstile 测试成功!')
setTimeout(closeTestModal, 1500)
action: 'aksrt-admin-test',
cdata: 'admin-captcha-test',
callback: function(token) {
submitCaptchaTest('cloudflare', { cf_turnstile_response: token })
},
'error-callback': function() {
testResult.value = { success: false, message: '验证失败' }
@@ -363,19 +360,28 @@ function loadProviderSDK(provider, cfg) {
}
}
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileTestLoad'
script.onerror = function () { testResult.value = { success: false, message: '加载SDK失败' } }
script.onerror = function () { testLoading.value = false; testResult.value = { success: false, message: '加载SDK失败' } }
break
case 'geetest':
script.src = 'https://static.geetest.com/v4/gt4.js'
script.onload = function () {
testLoading.value = false
try {
window.initGeetest4({ captchaId: cfg.captcha_id, product: cfg.product || 'float' }, function(captchaObj) {
window.__gtTestCaptcha = captchaObj
const onSuccess = function() {
testResult.value = { success: true, message: '验证成功!' }
message.success('极验 GeeTest 测试成功!')
setTimeout(closeTestModal, 1500)
const result = captchaObj.getValidate ? captchaObj.getValidate() : null
if (!result) {
testResult.value = { success: false, message: '未取得极验验证参数' }
return
}
submitCaptchaTest('geetest', {
geetest_lot_number: result.lot_number || '',
geetest_captcha_output: result.captcha_output || '',
geetest_pass_token: result.pass_token || '',
geetest_gen_time: result.gen_time || '',
})
}
const onError = function(error) {
testResult.value = { success: false, message: '出错: ' + (error.msg || '?') }
@@ -393,22 +399,21 @@ function loadProviderSDK(provider, cfg) {
testResult.value = { success: false, message: '初始化失败: ' + error.message }
}
}
script.onerror = function () { testResult.value = { success: false, message: '加载SDK失败' } }
script.onerror = function () { testLoading.value = false; testResult.value = { success: false, message: '加载SDK失败' } }
break
case 'aliyun':
window.AliyunCaptchaConfig = { region: cfg.region || 'cn', prefix: cfg.prefix }
script.src = 'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js'
script.onload = function () {
testLoading.value = false
try {
window.initAliyunCaptcha({
SceneId: cfg.scene_id,
mode: 'embed',
element: '#captcha-test-box',
success: function() {
testResult.value = { success: true, message: '验证成功!' }
message.success('阿里云测试成功!')
setTimeout(closeTestModal, 1500)
success: function(captchaVerifyParam) {
submitCaptchaTest('aliyun', { aliyun_captcha_verify_param: String(captchaVerifyParam || '') })
},
fail: function(result) {
testResult.value = { success: false, message: String(result) }
@@ -421,7 +426,7 @@ function loadProviderSDK(provider, cfg) {
testResult.value = { success: false, message: '初始化失败: ' + error.message }
}
}
script.onerror = function () { testResult.value = { success: false, message: '加载SDK失败' } }
script.onerror = function () { testLoading.value = false; testResult.value = { success: false, message: '加载SDK失败' } }
break
default: