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:
1 parent
d2e95acd75
commit
ff4ec1eb43
45 files changed
+2694
-1435
No files matched your search
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user