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

+6
View File
@@ -3,6 +3,12 @@ export const schemaApiMethods = {
getSchemaPage(domain, section) {
return this.request(`/settings/schema/${this.normalizeDomainKey(domain)}/${encodeURIComponent(section)}`)
},
testCaptcha(provider, token) {
return this.request('/captcha/test', {
method: 'POST',
body: JSON.stringify({ provider, token }),
})
},
saveSchemaPage(domain, section, data, version = '1.0.0') {
return this.request(`/settings/schema/${this.normalizeDomainKey(domain)}/${encodeURIComponent(section)}`, {
method: 'POST',
@@ -40,9 +40,11 @@
v-model:value="model.access_key_secret"
type="password"
show-password-on="click"
autocomplete="current-password"
autocomplete="new-password"
placeholder="阿里云 RAM 用户 AccessKey Secret"
@update:value="onSecretInput('access_key_secret', $event)"
/>
<n-button v-if="model.access_key_secret === '***'" size="tiny" type="error" ghost style="margin-left:8px" @click="clearSecret('access_key_secret')">清除</n-button>
</n-form-item>
<n-alert type="warning" style="margin-bottom: 8px;">
请使用 RAM 子账号密钥并授权 <code>AliyunYundunAFSFullAccess</code> 权限,切勿使用主账号密钥。
@@ -58,6 +60,16 @@
import { computed } from 'vue'
const model = defineModel({ type: Object, default: () => ({}) })
const emit = defineEmits(['clear-secret', 'restore-secret'])
function clearSecret(field) {
emit('clear-secret', field)
model.value = { ...model.value, [field]: '' }
}
function onSecretInput(field, value) {
if (value !== '') emit('restore-secret', field)
}
const isConfigured = computed(() => {
const c = model.value
@@ -5,10 +5,8 @@
<n-input v-model:value="model.site_key" placeholder="Cloudflare Turnstile Site Key" autocomplete="off" />
</n-form-item>
<n-form-item label="Secret Key">
<n-input v-model:value="model.secret_key" type="password" show-password-on="click" autocomplete="current-password" />
</n-form-item>
<n-form-item label="自定义域名">
<n-input v-model:value="model.domain" placeholder="留空使用默认域名" autocomplete="url" />
<n-input v-model:value="model.secret_key" type="password" show-password-on="click" autocomplete="new-password" @update:value="onSecretInput('secret_key', $event)" />
<n-button v-if="model.secret_key === '***'" size="tiny" type="error" ghost style="margin-left:8px" @click="clearSecret('secret_key')">清除</n-button>
</n-form-item>
<n-form-item label="主题">
<n-select v-model:value="model.theme" :options="themeOptions" clearable />
@@ -26,6 +24,16 @@
import { computed } from 'vue'
const model = defineModel({ type: Object, default: () => ({}) })
const emit = defineEmits(['clear-secret', 'restore-secret'])
function clearSecret(field) {
emit('clear-secret', field)
model.value = { ...model.value, [field]: '' }
}
function onSecretInput(field, value) {
if (value !== '') emit('restore-secret', field)
}
const isConfigured = computed(() => {
const c = model.value
@@ -5,7 +5,8 @@
<n-input v-model:value="model.captcha_id" placeholder="极验 GeeTest CaptchaID" autocomplete="off" />
</n-form-item>
<n-form-item label="Captcha Key">
<n-input v-model:value="model.captcha_key" type="password" show-password-on="click" autocomplete="current-password" />
<n-input v-model:value="model.captcha_key" type="password" show-password-on="click" autocomplete="new-password" @update:value="onSecretInput('captcha_key', $event)" />
<n-button v-if="model.captcha_key === '***'" size="tiny" type="error" ghost style="margin-left:8px" @click="clearSecret('captcha_key')">清除</n-button>
</n-form-item>
<n-tag v-if="isConfigured" type="success" size="small">✓ 已完整配置</n-tag>
<n-tag v-else type="warning" size="small">未完成配置</n-tag>
@@ -17,6 +18,16 @@
import { computed } from 'vue'
const model = defineModel({ type: Object, default: () => ({ product: 'bind' }) })
const emit = defineEmits(['clear-secret', 'restore-secret'])
function clearSecret(field) {
emit('clear-secret', field)
model.value = { ...model.value, [field]: '' }
}
function onSecretInput(field, value) {
if (value !== '') emit('restore-secret', field)
}
const isConfigured = computed(() => {
const c = model.value
@@ -9,7 +9,8 @@
<n-input v-model:value="model.secret_id" placeholder="腾讯云 API SecretId" autocomplete="off" />
</n-form-item>
<n-form-item label="SecretKey">
<n-input v-model:value="model.secret_key" type="text" placeholder="腾讯云 API SecretKey" autocomplete="off" show-password-on="click" />
<n-input v-model:value="model.secret_key" type="password" placeholder="腾讯云 API SecretKey" autocomplete="new-password" show-password-on="click" @update:value="onSecretInput('secret_key', $event)" />
<n-button v-if="model.secret_key === '***'" size="tiny" type="error" ghost style="margin-left:8px" @click="clearSecret('secret_key')">清除</n-button>
</n-form-item>
<n-divider title-placement="left">验证码密钥</n-divider>
@@ -17,7 +18,8 @@
<n-input v-model:value="model.app_id" placeholder="从验证码控制台获取 CaptchaAppId" autocomplete="off" />
</n-form-item>
<n-form-item label="AppSecretKey">
<n-input v-model:value="model.app_secret_key" type="text" placeholder="从验证码控制台获取 AppSecretKey" autocomplete="off" show-password-on="click" />
<n-input v-model:value="model.app_secret_key" type="password" placeholder="从验证码控制台获取 AppSecretKey" autocomplete="new-password" show-password-on="click" @update:value="onSecretInput('app_secret_key', $event)" />
<n-button v-if="model.app_secret_key === '***'" size="tiny" type="error" ghost style="margin-left:8px" @click="clearSecret('app_secret_key')">清除</n-button>
<template #feedback>请从「验证管理 → 基础配置」完整复制,注意区分 I/l/1/O/0</template>
</n-form-item>
@@ -34,6 +36,16 @@
import { computed } from 'vue'
const model = defineModel({ type: Object, default: () => ({}) })
const emit = defineEmits(['clear-secret', 'restore-secret'])
function clearSecret(field) {
emit('clear-secret', field)
model.value = { ...model.value, [field]: '' }
}
function onSecretInput(field, value) {
if (value !== '') emit('restore-secret', field)
}
const isConfigured = computed(() => {
const c = model.value
+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:
@@ -15,10 +15,7 @@ export const permissionsSchemas = {
defaults: {
enabled: false,
active_provider: '',
enabled_scenarios: [
'comment', 'login', 'register', 'bind', 'bind_phone',
'lostpassword', 'change_password', 'change_email', 'download', 'signin',
],
enabled_scenarios: [],
tencent: {},
aliyun: {},
cloudflare: {},
@@ -81,10 +78,10 @@ export const permissionsSchemas = {
alertContent: '下方会渲染当前验证码服务商的专属配置面板。保存仍通过统一 schema contract 进行。',
dependsOn: { key: 'enabled', value: true },
},
{ key: 'tencent', label: '腾讯云配置', type: 'custom', dependsOn: { key: 'active_provider', value: 'tencent' } },
{ key: 'aliyun', label: '阿里云配置', type: 'custom', dependsOn: { key: 'active_provider', value: 'aliyun' } },
{ key: 'cloudflare', label: 'Cloudflare 配置', type: 'custom', dependsOn: { key: 'active_provider', value: 'cloudflare' } },
{ key: 'geetest', label: '极验配置', type: 'custom', dependsOn: { key: 'active_provider', value: 'geetest' } },
{ key: 'tencent', label: '腾讯云配置', type: 'custom', default: { secret_id: '', secret_key: '', app_id: '', app_secret_key: '' }, secretFields: ['secret_key', 'app_secret_key'], dependsOn: { key: 'active_provider', value: 'tencent' } },
{ key: 'aliyun', label: '阿里云配置', type: 'custom', default: { region: 'cn', prefix: '', scene_id: '', access_key_id: '', access_key_secret: '' }, secretFields: ['access_key_secret'], dependsOn: { key: 'active_provider', value: 'aliyun' } },
{ key: 'cloudflare', label: 'Cloudflare 配置', type: 'custom', default: { site_key: '', secret_key: '', theme: 'auto', size: 'normal' }, secretFields: ['secret_key'], dependsOn: { key: 'active_provider', value: 'cloudflare' } },
{ key: 'geetest', label: '极验配置', type: 'custom', default: { captcha_id: '', captcha_key: '', product: 'bind' }, secretFields: ['captcha_key'], dependsOn: { key: 'active_provider', value: 'geetest' } },
],
},
],