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:
2026-09-02 13:03:24 +08:00
parent d2e95acd75
commit ff4ec1eb43
45 changed files with 2694 additions and 1435 deletions
+3
View File
@@ -25,6 +25,9 @@
!/tests/widget-system/page-display-save-harness.php
!/tests/widget-system/admin-ui-contract.test.cjs
!/tests/widget-system/auth-source-contract.test.cjs
!/tests/widget-system/captcha-business-contract.test.cjs
!/tests/widget-system/captcha-security-harness.php
!/tests/widget-system/captcha-runtime-harness.test.cjs
!/tests/widget-system/oauth-security-contract.test.cjs
!/tests/widget-system/oauth-security-harness.php
!/tests/widget-system/user-badges-contract.test.cjs
+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' } },
],
},
],
+51 -26
View File
@@ -26,6 +26,7 @@
account_match_failed: '第三方账号匹配失败,请重试',
login_failed: '第三方登录失败,请重试',
bind_failed: '第三方账号绑定失败,请重试',
bind_verification_required: '请先完成人机验证后再绑定',
binding_conflict: '该第三方账号已绑定其他账号',
account_conflict: '该第三方账号无法绑定到当前账号',
already_bound: '该第三方账号已绑定其他账号',
@@ -297,10 +298,12 @@
nodes.box.dataset.loading = '1';
setQrStatus(root, '正在获取二维码');
qrRequest(root, 'qrcode-login', {
var qrPayload = {
action: root.dataset.oauthIntent || 'login',
redirect_to: root.dataset.redirectTo || window.location.href,
}).then(function (result) {
};
if (root.dataset.bindPass) qrPayload.bind_pass = root.dataset.bindPass;
qrRequest(root, 'qrcode-login', qrPayload).then(function (result) {
if (!result || !isRootActive(root)) return;
nodes.box.dataset.loading = '0';
nodes.box.dataset.loaded = '1';
@@ -347,6 +350,7 @@
var root = form.closest('[data-aksrt-auth]');
data.action = root ? (root.dataset.oauthIntent || 'login') : 'login';
data.redirect_to = root ? (root.dataset.redirectTo || window.location.href) : window.location.href;
if (root && root.dataset.bindPass) data.bind_pass = root.dataset.bindPass;
var button = form.querySelector('button[type="submit"]');
if (button) button.disabled = true;
request('weixingzh-code', data).then(function (result) {
@@ -391,23 +395,20 @@
}
function captchaScene(scene) {
return scene === 'resetpassword' ? 'lostpassword' : scene;
return scene === 'resetpassword' || scene === 'reset-password' ? 'lostpassword' : scene;
}
function verifyCaptcha(scene, callback) {
var state = window.AKSRTCaptchaState;
var captcha = state && state.scenes ? state.scenes[captchaScene(scene)] : null;
if (!captcha || typeof captcha.verify !== 'function') {
callback(true);
return;
}
captcha.verify(function (verified) { callback(verified !== false); });
function captchaFormScene(form, fallback) {
var mount = form && form.querySelector ? form.querySelector('[data-aksrt-captcha-config][data-scene]') : null;
return mount ? (mount.getAttribute('data-scene') || captchaScene(fallback)) : captchaScene(fallback);
}
function resetCaptcha(scene) {
var state = window.AKSRTCaptchaState;
var captcha = state && state.scenes ? state.scenes[captchaScene(scene)] : null;
if (captcha && typeof captcha.reset === 'function') captcha.reset();
function captchaScope(form) {
return form || document;
}
function resetCaptcha(scene, scope) {
if (window.AKSRTCaptcha) window.AKSRTCaptcha.reset(captchaScene(scene), captchaScope(scope));
}
function sendCode(button) {
@@ -422,39 +423,63 @@
payload.scene = button.dataset.sendCode;
payload.target = target.value.trim();
payload.channel = (form.querySelector('[name="channel"]') || {}).value || '';
verifyCaptcha(payload.scene, function (verified) {
if (!verified) {
var scene = captchaFormScene(form, payload.scene);
var captcha = window.AKSRTCaptcha;
var ticket = captcha ? captcha.run(captchaScene(scene), captchaScope(form)) : Promise.resolve({});
ticket.then(function (captchaPayload) {
if (captchaPayload === null) {
button.disabled = false;
notify('请先完成人机验证', 'warning');
return;
return null;
}
payload = formData(form);
payload.scene = button.dataset.sendCode;
payload.target = target.value.trim();
payload.channel = (form.querySelector('[name="channel"]') || {}).value || '';
request('code', payload).then(function (result) {
Object.assign(payload, captchaPayload || {});
return request('code', payload).then(function (result) {
notify(result.message || '验证码已发送', 'success');
startCountdown(button, result.countdown || 60);
}).catch(function (error) {
button.disabled = false;
notify(error.message, 'danger');
}).finally(function () {
resetCaptcha(payload.scene);
resetCaptcha(scene, form);
});
}).catch(function (error) {
button.disabled = false;
resetCaptcha(scene, form);
notify(error.message || '人机验证失败,请重试', 'danger');
});
}
function submit(form) {
var action = form.dataset.authForm;
var payload = formData(form);
if (action === 'register' && payload.channel !== 'phone') payload.user_email = payload.target;
var scene = captchaFormScene(form, action);
var captcha = window.AKSRTCaptcha;
setBusy(form, true);
request(action, payload).then(function (result) {
notify(result.message || '操作成功', 'success');
if (result.redirect) window.setTimeout(function () { window.location.assign(result.redirect); }, 350);
(captcha ? captcha.run(scene, captchaScope(form)) : Promise.resolve({})).then(function (captchaPayload) {
if (captchaPayload === null) {
setBusy(form, false);
notify('请先完成人机验证', 'warning');
return null;
}
var payload = formData(form);
if (action === 'register' && payload.channel !== 'phone') payload.user_email = payload.target;
Object.assign(payload, captchaPayload || {});
return request(action, payload).then(function (result) {
notify(result.message || '操作成功', 'success');
if (result.redirect) window.setTimeout(function () { window.location.assign(result.redirect); }, 350);
}).catch(function (error) {
notify(error.message, 'danger');
setBusy(form, false);
}).finally(function () {
resetCaptcha(scene, form);
});
}).catch(function (error) {
notify(error.message, 'danger');
setBusy(form, false);
resetCaptcha(scene, form);
notify(error.message || '人机验证失败,请重试', 'danger');
});
}
+449 -356
View File
@@ -2375,8 +2375,8 @@ tbquire.config({
}
}
// 执行实际的 AJAX 请求 — local insert instead of full-page re-fetch
function performSubmit() {
// 执行实际的 REST 请求 — local insert instead of full-page re-fetch
function performSubmit(captchaPayload) {
setLoading(true);
var formData = collectFormData();
@@ -2388,6 +2388,7 @@ tbquire.config({
// Try REST endpoint for local-insert HTML response
var restBody = {};
formData.forEach(function (v, k) { restBody[k] = v; });
Object.assign(restBody, captchaPayload || {});
if (isEdit) {
restBody.comment_id = parseInt(editId.value, 10);
restBody.content = restBody.comment || restBody.content || '';
@@ -2428,15 +2429,6 @@ tbquire.config({
var replyInfo = document.getElementById('comment-reply-info');
if (replyInfo) replyInfo.style.display = 'none';
// Reset captcha after successful submit
try {
var captchaScene = window.AKSRTCaptchaState &&
window.AKSRTCaptchaState.scenes &&
window.AKSRTCaptchaState.scenes.comment;
if (captchaScene && typeof captchaScene.reset === 'function') {
captchaScene.reset();
}
} catch (e) { /* ignore */ }
// Wait time countdown
if (json.wait_time && json.wait_time > 0) {
@@ -2465,14 +2457,6 @@ tbquire.config({
if (json && json.code) {
notyf(json.message || '评论提交失败', 'danger');
try {
var failScene = window.AKSRTCaptchaState &&
window.AKSRTCaptchaState.scenes &&
window.AKSRTCaptchaState.scenes.comment;
if (failScene && typeof failScene.reset === 'function') {
failScene.reset();
}
} catch (e) { /* ignore */ }
if (json.wait_time) startCountdown(json.wait_time);
else setLoading(false);
return;
@@ -2482,9 +2466,14 @@ tbquire.config({
setLoading(false);
setTimeout(function () { reloadCommentList(); }, 500);
})
.catch(function () {
// Fallback: submit to wp-comments-post.php directly
performLegacySubmit();
.catch(function (error) {
console.error('[AKSRT Comment] 提交失败:', error);
notyf((error && error.message) || '网络连接失败,请检查网络后重试', 'danger');
setLoading(false);
})
.finally(function () {
if (window.AKSRTCaptcha) window.AKSRTCaptcha.reset('comment', form);
if (!countdownTimer && isSubmitting) setLoading(false);
});
}
@@ -2556,44 +2545,6 @@ tbquire.config({
placeholder.remove();
}
}
// Legacy fallback: POST to wp-comments-post.php
function performLegacySubmit() {
var formData = collectFormData();
var actionUrl = form.getAttribute('action') || '/wp-comments-post.php';
fetch(actionUrl, {
method: 'POST',
body: formData,
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function (response) {
return response.text().then(function (text) {
return { status: response.status, text: text, ok: response.ok };
});
})
.then(function (result) {
handleResponse(result.text, result.status);
})
.catch(function (error) {
console.error('[AKSRT Comment] 提交失败:', error);
if (error.message === 'Failed to fetch' || error.name === 'TypeError') {
notyf('网络连接失败,请检查网络后重试', 'danger');
} else {
notyf(error.message || '评论提交失败,请重试', 'danger');
}
setLoading(false);
})
.finally(function () {
// 兜底:避免异常分支漏掉状态恢复导致按钮卡在"发送中"
if (!countdownTimer && isSubmitting) {
setLoading(false);
}
});
}
// 核心表单提交事件处理器
function handleSubmit(e) {
// 如果已经在提交中或倒计时中,防止重复提交
@@ -2606,29 +2557,20 @@ tbquire.config({
// 阻止默认表单提交
e.preventDefault();
e.stopImmediatePropagation();
// 检查验证码状态(如果有验证码组件)
var captchaScene = window.AKSRTCaptchaState &&
window.AKSRTCaptchaState.scenes &&
window.AKSRTCaptchaState.scenes.comment;
if (captchaScene && typeof captchaScene.verify === 'function') {
// 有验证码:先触发验证流程
setLoading(true);
captchaScene.verify(function (verified) {
if (verified !== false) {
// 验证通过,执行 AJAX 提交
performSubmit();
} else {
// 验证失败
notyf('人机验证未通过,请重试', 'warning');
setLoading(false);
}
});
} else {
// 无验证码或已经验证过:直接提交
performSubmit();
}
var captcha = window.AKSRTCaptcha;
setLoading(true);
(captcha ? captcha.run('comment', form) : Promise.resolve({})).then(function (captchaPayload) {
if (captchaPayload === null) {
notyf('人机验证未通过,请重试', 'warning');
setLoading(false);
return;
}
performSubmit(captchaPayload);
}).catch(function () {
notyf('人机验证未通过,请重试', 'warning');
setLoading(false);
if (captcha) captcha.reset('comment', form);
});
return false;
}
@@ -5692,409 +5634,560 @@ jQuery.cookie=function(e,o,t){if(o===void 0){var i=null;if(document.cookie&&docu
/* source: assets/js/captcha-front.js */
/**
* AKSRT Captcha front-end runtime — cloud providers only (tencent / aliyun / cloudflare / geetest).
* AKSRT Captcha front-end runtime — cloud providers only.
*
* PHP renders passive mounts via [data-aksrt-captcha-config] (JSON config attribute);
* this script scans them, registers AKSRTCaptchaState.scenes[scene], loads the provider
* SDK once per page, and writes verified tokens back into the surrounding form.
* Bundled into assets/js/build/public.js by tools/build-public-assets.mjs.
* Mounts are registered internally by uid and resolved by scene plus DOM scope.
* The only public surface is window.AKSRTCaptcha.
*/
(function () {
'use strict';
var TOKEN_FIELDS = [
var TOKEN_FIELDS = Object.freeze([
'captcha_verified',
'aksrt_captcha_challenge',
'aksrt_captcha_proof',
'cf_turnstile_response',
'tencent_ticket', 'tencent_randstr', 'tencent_sid',
'tencent_ticket', 'tencent_randstr',
'geetest_lot_number', 'geetest_captcha_output', 'geetest_pass_token', 'geetest_gen_time',
'aliyun_captcha_verify_param'
];
]);
var loaders = {};
var instances = {};
var scenes = {};
var root = (window.AKSRTCaptchaState = window.AKSRTCaptchaState || { loaders: {}, scenes: {} });
root.rescan = scan;
function asElement(scope) {
if (!scope) return null;
if (scope.nodeType === 1 || scope.nodeType === 9) return scope;
if (typeof scope !== 'string') return null;
if (instances[scope]) return instances[scope].box;
try { return document.querySelector(scope); } catch (_) { return null; }
}
function scan() {
var mounts = document.querySelectorAll('[data-aksrt-captcha-config]');
Array.prototype.forEach.call(mounts, function (el) {
if (el.dataset.aksrtCaptchaBound) {
return;
}
el.dataset.aksrtCaptchaBound = '1';
var config;
function scopeBoundary(box, config) {
if (config.scope) {
try {
config = JSON.parse(el.getAttribute('data-aksrt-captcha-config'));
} catch (e) {
return;
}
if (config && config.scene && config.uid && config.vid) {
initMount(config);
}
var configured = document.querySelector(config.scope);
if (configured) return configured;
} catch (_) {}
}
return box.closest('[data-aksrt-captcha-scope], form, [data-aksrt-download-box], [data-aksrt-auth], .aksrt-modal, [data-signin-captcha], #comments') || box.parentElement;
}
function register(instance) {
instances[instance.uid] = instance;
scenes[instance.scene] = scenes[instance.scene] || [];
if (scenes[instance.scene].indexOf(instance) === -1) scenes[instance.scene].push(instance);
}
function resolve(scene, scope) {
scan();
if (typeof scope === 'string' && instances[scope] && instances[scope].scene === scene) {
return instances[scope];
}
var candidates = scenes[scene] || [];
var element = asElement(scope);
if (element && element.getAttribute) {
var uid = element.getAttribute('data-aksrt-captcha-uid') || element.getAttribute('data-captcha-uid');
if (uid && instances[uid] && instances[uid].scene === scene) return instances[uid];
}
if (!element) return scope ? null : (candidates.length === 1 ? candidates[0] : null);
var matches = candidates.filter(function (instance) {
var boundary = instance.scope;
return element === instance.box || element === instance.mount || element === boundary ||
instance.box.contains(element) || instance.mount.contains(element) ||
(boundary && boundary.contains(element)) ||
(element.contains && (element.contains(instance.box) || (boundary && element.contains(boundary))));
});
if (matches.length === 1) return matches[0];
if (!matches.length) return null;
matches.sort(function (a, b) {
if (a.scope === b.scope) return 0;
if (a.scope && b.scope && a.scope.contains(b.scope)) return 1;
if (a.scope && b.scope && b.scope.contains(a.scope)) return -1;
return 0;
});
return matches[0];
}
function verify(scene, scope) {
var instance = resolve(scene, scope);
if (!instance) return Promise.resolve(true);
return instance.verify();
}
function collect(scene, scope) {
var instance = resolve(scene, scope);
return instance ? instance.collect() : {};
}
function reset(scene, scope) {
var instance = resolve(scene, scope);
if (instance) instance.reset();
}
function releaseRunLock(instance) {
instance.runLocked = false;
var waiters = instance.lockWaiters.slice();
instance.lockWaiters = [];
waiters.forEach(function (resolveWaiter) { resolveWaiter(null); });
}
function run(scene, scope) {
var instance = resolve(scene, scope);
if (!instance) return Promise.resolve({});
if (instance.runLocked) {
return new Promise(function (resolveWaiter) { instance.lockWaiters.push(resolveWaiter); });
}
instance.runLocked = true;
return instance.verify().then(function (passed) {
if (!passed) releaseRunLock(instance);
return passed ? instance.collect() : null;
}).catch(function (error) {
releaseRunLock(instance);
throw error;
});
}
/**
* One mount = one scene instance. Mirrors the scene semantics of the previous
* per-render inline script: the last initialized mount wins scenes[scene].
*/
function initMount(C) {
var root = window.AKSRTCaptchaState;
var box, hField, form = null;
window.AKSRTCaptcha = Object.freeze({
verify: verify,
collect: collect,
reset: reset,
run: run,
TOKEN_FIELDS: TOKEN_FIELDS
});
function scan() {
var mounts = document.querySelectorAll('[data-aksrt-captcha-config]');
Array.prototype.forEach.call(mounts, function (mount) {
if (mount.dataset.aksrtCaptchaBound) return;
var config;
try {
config = JSON.parse(mount.getAttribute('data-aksrt-captcha-config'));
} catch (_) {
return;
}
if (!config || !config.scene || !config.uid || !config.vid) return;
mount.dataset.aksrtCaptchaBound = '1';
initMount(config, mount);
});
}
function initMount(C, mount) {
var box = document.getElementById(C.uid);
var hField = document.getElementById(C.vid);
if (!box || !hField) return;
var container = box.closest('form') || mount;
mount.setAttribute('data-aksrt-captcha-uid', C.uid);
var verified = false;
var pendingAction = null;
var triggerCaptcha = function () { showTip('验证码加载中,请稍候', '#e67e22'); };
var pending = [];
var refreshingChallenge = null;
var providerReset = function () {};
var triggerCaptcha = function () {
showTip('验证码加载中,请稍候', '#e67e22');
finishPending(false);
};
function loadScriptOnce(key, src, readyCheck) {
if (typeof readyCheck === 'function' && readyCheck()) { return Promise.resolve(); }
if (root.loaders[key]) { return root.loaders[key]; }
root.loaders[key] = new Promise(function (resolve, reject) {
if (typeof readyCheck === 'function' && readyCheck()) return Promise.resolve();
if (loaders[key]) return loaders[key];
loaders[key] = new Promise(function (resolveLoad, rejectLoad) {
var existing = document.querySelector('script[data-aksrt-sdk="' + key + '"]');
if (existing) {
if (typeof readyCheck === 'function' && readyCheck()) { resolve(); return; }
existing.addEventListener('load', function () { resolve(); }, { once: true });
existing.addEventListener('error', function () { reject(new Error(key + ' load failed')); }, { once: true });
if (typeof readyCheck === 'function' && readyCheck()) { resolveLoad(); return; }
existing.addEventListener('load', resolveLoad, { once: true });
existing.addEventListener('error', function () { rejectLoad(new Error(key + ' load failed')); }, { once: true });
return;
}
var script = document.createElement('script');
script.src = src;
script.async = true;
script.setAttribute('data-aksrt-sdk', key);
script.onload = function () { resolve(); };
script.onerror = function () { reject(new Error(key + ' load failed')); };
script.onload = resolveLoad;
script.onerror = function () { rejectLoad(new Error(key + ' load failed')); };
document.head.appendChild(script);
});
return root.loaders[key];
}
function findForm() {
form = box.closest('form');
if (!form && C.scene === 'comment') form = document.getElementById('commentform');
if (!form && (C.scene === 'login' || C.scene === 'register')) {
form = box.closest('[data-auth-form]') || document.querySelector('[data-auth-form]');
}
if (!form && C.scene === 'bind_phone') {
form = document.getElementById('aksrt-bind-phone-form')
|| document.querySelector('[data-aksrt-bind-phone-form]')
|| document.querySelector('.auth-form');
}
return form;
}
function bindSubmit() {
if (!form) return;
form.addEventListener('submit', function (e) {
if (verified || hField.value === '1') return;
e.preventDefault(); e.stopImmediatePropagation();
pendingAction = null; triggerCaptcha();
}, true);
return loaders[key];
}
function setField(name, value) {
var container = form || box;
var el = container.querySelector('input[name="' + name + '"]');
if (!el) {
el = document.createElement('input');
el.type = 'hidden';
el.name = name;
container.appendChild(el);
var field = container.querySelector('input[name="' + name + '"]');
if (!field) {
field = document.createElement('input');
field.type = 'hidden';
field.name = name;
container.appendChild(field);
}
el.value = value;
field.value = value == null ? '' : value;
}
function collectFields() {
var payload = {};
TOKEN_FIELDS.forEach(function (name) {
var field = container.querySelector('input[name="' + name + '"]');
if (field && field.value !== '') payload[name] = field.value;
});
return payload;
}
function refreshChallenge() {
if (!C.challenge_url || typeof window.fetch !== 'function') return Promise.resolve();
if (refreshingChallenge) return refreshingChallenge;
refreshingChallenge = window.fetch(C.challenge_url, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scene: C.scene })
}).then(function (response) {
return response.json().catch(function () { return {}; }).then(function (body) {
if (!response.ok || !body || body.success === false) throw new Error(body.message || 'challenge refresh failed');
var data = body.data || body;
if (!data.challenge) throw new Error('challenge missing');
setField('aksrt_captcha_challenge', data.challenge);
});
}).catch(function () {
showTip('无法刷新人机验证会话,请刷新页面重试', '#e74c3c');
}).finally(function () {
refreshingChallenge = null;
});
return refreshingChallenge;
}
function clearProviderTokens() {
TOKEN_FIELDS.forEach(function (name) {
if (name === 'captcha_verified' || name === 'aksrt_captcha_challenge' || name === 'aksrt_captcha_proof') return;
Array.prototype.forEach.call(container.querySelectorAll('input[name="' + name + '"]'), function (field) {
field.remove();
});
});
}
function resetVerificationState() {
verified = false;
releaseRunLock(instance);
hField.value = '0';
var container = form || box;
TOKEN_FIELDS.forEach(function (n) {
var f = container.querySelector('input[name="' + n + '"]');
if (f) f.remove();
});
if (C.scene === 'download') {
syncDownloadSiblings(false);
}
setField('aksrt_captcha_proof', '');
clearProviderTokens();
try { providerReset(); } catch (_) {}
refreshChallenge();
}
function syncDownloadSiblings(passed) {
var myContainer = box.closest('[data-aksrt-download-captcha]');
if (!myContainer) return;
document.querySelectorAll('[data-aksrt-download-captcha]').forEach(function (target) {
if (target === myContainer) return;
var tgtH = target.querySelector('input[name="captcha_verified"]');
if (tgtH) tgtH.value = passed ? '1' : '0';
TOKEN_FIELDS.forEach(function (name) {
var f = target.querySelector('input[name="' + name + '"]');
if (f) f.remove();
if (passed) {
var srcEl = myContainer.querySelector('input[name="' + name + '"]');
if (srcEl && srcEl.value !== '') {
var tgtEl = document.createElement('input');
tgtEl.type = 'hidden';
tgtEl.name = name;
target.appendChild(tgtEl);
tgtEl.value = srcEl.value;
}
}
});
});
}
function submitNativeForm() {
if (!form) return;
if (form.requestSubmit) { form.requestSubmit(); }
else { HTMLFormElement.prototype.submit.call(form); }
}
function submitForm() {
verified = true; hField.value = '1';
if (C.scene === 'download') syncDownloadSiblings(true);
if (typeof pendingAction === 'function') {
finishPending(true);
return;
}
setTimeout(resetVerificationState, 3000);
submitNativeForm();
}
function showTip(msg, color) {
var t = box.querySelector('.captcha-tip');
if (!t) {
t = document.createElement('div');
t.className = 'captcha-tip';
t.style.cssText = 'font-size:12px;margin-top:4px;text-align:left;';
box.appendChild(t);
}
t.style.color = color || '#e74c3c';
t.textContent = msg;
setTimeout(function () { if (t.parentNode) t.remove(); }, 4000);
}
function showLoading() { box.innerHTML = '<div style="text-align:center;padding:20px;color:#9ca3af;">验证码加载中...</div>'; }
function clearLoading() { box.innerHTML = ''; }
function finishPending(ok) {
var action = pendingAction;
pendingAction = null;
if (typeof action === 'function') { action(!!ok); }
var callbacks = pending.slice();
pending = [];
callbacks.forEach(function (callback) { callback(!!ok); });
}
function cancelPending(msg) {
if (msg) showTip(msg, '#e67e22');
function cancelPending(message) {
if (message) showTip(message, '#e67e22');
finishPending(false);
}
root.scenes[C.scene] = {
verify: function (callback) {
if (verified || hField.value === '1') {
if (typeof callback === 'function') callback(true);
return;
}
pendingAction = (typeof callback === 'function') ? function (ok) { callback(ok !== false); } : null;
triggerCaptcha();
},
reset: resetVerificationState
function providerPayload() {
var payload = collectFields();
payload.scene = C.scene;
return payload;
}
function exchangeVerification() {
if (!C.exchange_url || typeof window.fetch !== 'function') {
cancelPending('人机验证服务不可用,请刷新页面重试');
return;
}
window.fetch(C.exchange_url, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(providerPayload())
}).then(function (response) {
return response.json().catch(function () { return {}; }).then(function (body) {
if (!response.ok || !body || body.success === false) throw new Error(body.message || '人机验证失败');
var data = body.data || body;
if (!data.proof) throw new Error('人机验证凭证缺失');
setField('aksrt_captcha_proof', data.proof);
clearProviderTokens();
completeVerification();
});
}).catch(function (error) {
cancelPending(error.message || '人机验证失败,请重试');
resetVerificationState();
});
}
function completeProviderVerification() {
hField.value = '1';
exchangeVerification();
}
function completeVerification() {
verified = true;
hField.value = '1';
finishPending(true);
}
function showTip(message, color) {
var tip = box.querySelector('.captcha-tip');
if (!tip) {
tip = document.createElement('div');
tip.className = 'captcha-tip';
tip.style.cssText = 'font-size:12px;margin-top:4px;text-align:left;';
box.appendChild(tip);
}
tip.style.color = color || '#e74c3c';
tip.textContent = message;
setTimeout(function () { if (tip.parentNode) tip.remove(); }, 4000);
}
function showLoading() {
box.innerHTML = '<div style="text-align:center;padding:20px;color:#9ca3af;">验证码加载中...</div>';
}
function clearLoading() {
box.innerHTML = '';
}
var instance = {
uid: C.uid,
scene: C.scene,
box: box,
mount: mount,
scope: scopeBoundary(box, C),
runLocked: false,
lockWaiters: [],
collect: collectFields,
reset: resetVerificationState,
verify: function () {
var challengeField = container.querySelector('input[name="aksrt_captcha_challenge"]');
var preparation = challengeField && challengeField.value ? Promise.resolve() : refreshChallenge();
return preparation.then(function () {
challengeField = container.querySelector('input[name="aksrt_captcha_challenge"]');
if (!challengeField || !challengeField.value) {
showTip('人机验证会话未就绪,请稍后重试', '#e74c3c');
return false;
}
if (verified || hField.value === '1') return true;
return new Promise(function (resolveVerify) {
pending.push(resolveVerify);
if (pending.length === 1) triggerCaptcha();
});
});
}
};
register(instance);
refreshingChallenge = refreshChallenge();
// ============ 腾讯云 Captcha 2.0 ============
function loadTencent() {
showLoading();
loadScriptOnce('tencent', 'https://turing.captcha.qcloud.com/TJCaptcha.js', function () {
return typeof TencentCaptcha !== 'undefined';
return typeof window.TencentCaptcha !== 'undefined';
}).then(function () {
clearLoading();
var capIns = null;
var captchaInstance = null;
triggerCaptcha = function () {
if (!capIns) {
capIns = new TencentCaptcha(C.tc.app_id, function (res) {
if (res.ret === 2) { cancelPending('已取消验证'); return; }
if (res.ret === 0 && res.ticket) {
if (res.ticket.indexOf('trerror_') === 0) {
showTip('验证码服务异常,请刷新页面重试', '#e67e22');
if (!captchaInstance) {
captchaInstance = new window.TencentCaptcha(C.tc.app_id, function (result) {
if (result.ret === 2) { cancelPending('已取消验证'); return; }
if (result.ret === 0 && result.ticket) {
if (result.ticket.indexOf('trerror_') === 0) {
cancelPending('验证码服务异常,请刷新页面重试');
return;
}
setField('tencent_ticket', res.ticket);
setField('tencent_randstr', res.randstr);
setField('tencent_sid', res.sid || '');
submitForm();
setField('tencent_ticket', result.ticket);
setField('tencent_randstr', result.randstr);
completeProviderVerification();
return;
}
if (res.errorCode) {
var errorMap = { 1001: '验证码组件加载失败', 1002: '验证码超时', 1006: '验证码配置拉取失败', 1008: '验证码页面加载异常', 1012: '验证码刷新次数过多', 1013: '验证码网络异常' };
showTip(errorMap[res.errorCode] || ('验证码异常(errorCode:' + res.errorCode + ')'), '#e74c3c');
}
var errorMap = { 1001: '验证码组件加载失败', 1002: '验证码超时', 1006: '验证码配置拉取失败', 1008: '验证码页面加载异常', 1012: '验证码刷新次数过多', 1013: '验证码网络异常' };
cancelPending(errorMap[result.errorCode] || '验证码异常,请重试');
}, { userLanguage: 'zh-cn' });
}
capIns.show();
captchaInstance.show();
};
}).catch(function () { clearLoading(); showTip('验证码组件加载失败,请刷新重试', '#e74c3c'); });
}).catch(function () {
clearLoading();
cancelPending('验证码组件加载失败,请刷新重试');
});
}
// ============ Cloudflare Turnstile(弹窗) ============
function loadCloudflare() {
var cfWidgetId = null, cfVerifying = false, cfModal = null;
var widgetId = null;
var verifying = false;
var modal = null;
if (typeof turnstile === 'undefined') {
window['onTurnstileLoad_' + C.uid] = function () {};
loadScriptOnce('cloudflare', 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad_' + C.uid + '&render=explicit', function () {
return typeof turnstile !== 'undefined';
});
function closeModal() {
if (widgetId !== null && typeof window.turnstile !== 'undefined') {
try { window.turnstile.remove(widgetId); } catch (_) {}
}
widgetId = null;
if (modal && modal.parentNode) modal.parentNode.removeChild(modal);
modal = null;
}
function renderWidget() {
if (cfWidgetId) { try { turnstile.remove(cfWidgetId); } catch (e) {} cfWidgetId = null; }
if (typeof turnstile === 'undefined') { setTimeout(renderWidget, 200); return; }
cfWidgetId = turnstile.render('#modalWidget_' + C.uid, {
sitekey: C.cf.site_key, theme: C.cf.theme, size: C.cf.size, action: 'aksrt-verify', cdata: C.scene,
if (typeof window.turnstile === 'undefined') { setTimeout(renderWidget, 200); return; }
widgetId = window.turnstile.render('#modalWidget_' + C.uid, {
sitekey: C.cf.site_key,
theme: C.cf.theme,
size: C.cf.size,
action: 'aksrt-verify',
cdata: C.scene,
callback: function (token) {
if (!cfVerifying) return;
cfVerifying = false;
if (!verifying) return;
verifying = false;
setField('cf_turnstile_response', token);
closeModal();
submitForm();
completeProviderVerification();
},
'error-callback': function () { showTip('验证码失败,请重试', '#e74c3c'); cfWidgetId = null; setTimeout(renderWidget, 500); },
'expired-callback': function () { showTip('验证码已过期,请重新验证', '#e67e22'); cfWidgetId = null; setTimeout(renderWidget, 500); }
'error-callback': function () {
verifying = false;
closeModal();
cancelPending('验证码失败,请重试');
},
'expired-callback': function () {
verifying = false;
closeModal();
cancelPending('验证码已过期,请重新验证');
}
});
}
function createModal() {
cfModal = document.createElement('div');
cfModal.className = 'aksrt-captcha-modal-overlay';
cfModal.innerHTML = '<div class="aksrt-captcha-modal-backdrop"></div>' +
modal = document.createElement('div');
modal.className = 'aksrt-captcha-modal-overlay';
modal.innerHTML = '<div class="aksrt-captcha-modal-backdrop"></div>' +
'<div class="aksrt-captcha-modal-box"><div class="aksrt-captcha-modal-header">' +
'<span class="aksrt-captcha-modal-title">人机验证</span>' +
'<button type="button" class="aksrt-captcha-modal-close">&times;</button></div>' +
'<div class="aksrt-captcha-modal-body"><p class="aksrt-captcha-modal-hint">请完成验证后继续操作</p>' +
'<div id="modalWidget_' + C.uid + '" class="aksrt-captcha-modal-widget"></div></div></div></div>';
document.body.appendChild(cfModal);
var cancelFn = function () { cfVerifying = false; closeModal(); cancelPending('已取消验证'); };
cfModal.querySelector('.aksrt-captcha-modal-backdrop').addEventListener('click', cancelFn);
cfModal.querySelector('.aksrt-captcha-modal-close').addEventListener('click', cancelFn);
document.body.appendChild(modal);
var cancel = function () {
verifying = false;
closeModal();
cancelPending('已取消验证');
};
modal.querySelector('.aksrt-captcha-modal-backdrop').addEventListener('click', cancel);
modal.querySelector('.aksrt-captcha-modal-close').addEventListener('click', cancel);
renderWidget();
}
function closeModal() {
if (cfWidgetId) { try { turnstile.remove(cfWidgetId); } catch (e) {} cfWidgetId = null; }
if (cfModal && cfModal.parentNode) { cfModal.parentNode.removeChild(cfModal); }
cfModal = null;
window['onTurnstileLoad_' + C.uid] = function () {};
if (typeof window.turnstile === 'undefined') {
loadScriptOnce('cloudflare', 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad_' + C.uid + '&render=explicit', function () {
return typeof window.turnstile !== 'undefined';
}).catch(function () { cancelPending('验证码组件加载失败,请刷新重试'); });
}
triggerCaptcha = function () {
if (cfVerifying) { showTip('验证码进行中,请稍候', '#e67e22'); return; }
cfVerifying = true;
if (verifying) return;
verifying = true;
createModal();
};
providerReset = closeModal;
}
// ============ 极验 GeeTest v4 ============
function loadGeetest() {
showLoading();
var captchaId = (C.gt && C.gt.captcha_id) ? String(C.gt.captcha_id).trim() : '';
var product = (C.gt && C.gt.product) ? String(C.gt.product) : 'bind';
var captchaId = C.gt && C.gt.captcha_id ? String(C.gt.captcha_id).trim() : '';
var product = C.gt && C.gt.product ? String(C.gt.product) : 'bind';
if (!captchaId) {
clearLoading();
showTip('极验 Captcha ID 未配置,请到后台「功能&权限 → 人机验证」填写并保存', '#e74c3c');
cancelPending('极验 Captcha ID 未配置');
return;
}
function render() {
clearLoading();
initGeetest4({ captchaId: captchaId, product: product, hideSuccess: true }, function (capIns) {
window['__gt_' + C.uid] = capIns;
window.initGeetest4({ captchaId: captchaId, product: product, hideSuccess: true }, function (captchaInstance) {
function onSuccess() {
var result = capIns.getValidate();
if (!result) return;
var result = captchaInstance.getValidate();
if (!result) { cancelPending('验证码失败,请重试'); return; }
setField('geetest_lot_number', result.lot_number);
setField('geetest_captcha_output', result.captcha_output);
setField('geetest_pass_token', result.pass_token);
setField('geetest_gen_time', result.gen_time);
submitForm();
}
if (product === 'bind' || product === 'popup') {
capIns.onReady(function () {}).onSuccess(onSuccess).onClose(function () { cancelPending('已取消验证'); });
} else {
capIns.appendTo('#' + C.uid).onSuccess(onSuccess).onClose(function () { cancelPending('已取消验证'); });
completeProviderVerification();
}
captchaInstance.onReady(function () {}).onSuccess(onSuccess).onClose(function () { cancelPending('已取消验证'); });
if (product !== 'bind' && product !== 'popup') captchaInstance.appendTo('#' + C.uid);
triggerCaptcha = function () {
if (product === 'bind' || product === 'popup') captchaInstance.showCaptcha();
else showTip('请先完成人机验证', '#e67e22');
};
providerReset = function () {
if (typeof captchaInstance.reset === 'function') captchaInstance.reset();
};
});
triggerCaptcha = function () {
var instance = window['__gt_' + C.uid];
if (!instance) { showTip('验证码加载中,请稍候再试', '#e67e22'); return; }
if (product === 'bind' || product === 'popup') { instance.showCaptcha(); return; }
showTip('请先完成人机验证', '#e67e22');
};
}
if (typeof initGeetest4 === 'undefined') {
if (typeof window.initGeetest4 === 'undefined') {
loadScriptOnce('geetest', 'https://static.geetest.com/v4/gt4.js', function () {
return typeof initGeetest4 !== 'undefined';
}).then(render).catch(function () { clearLoading(); showTip('极验组件加载失败', '#e74c3c'); });
return;
return typeof window.initGeetest4 !== 'undefined';
}).then(render).catch(function () {
clearLoading();
cancelPending('极验组件加载失败');
});
} else {
render();
}
render();
}
// ============ 阿里云 CAPTCHA 2.0 V3 ============
function loadAliyun() {
showLoading();
window.AliyunCaptchaConfig = { region: C.al.region || 'cn', prefix: C.al.prefix };
function render() {
clearLoading();
var captchaIns = null;
var btn = box.closest('form') ? box.closest('form').querySelector('button[type="submit"], input[type="submit"]') : null;
var captchaInstance = null;
window.initAliyunCaptcha({
SceneId: C.al.scene_id, mode: 'popup', element: '#' + C.uid,
button: btn ? '#' + (btn.id || (function () { btn.id = 'aksrt-captcha-btn-' + C.scene; return btn.id; })()) : null,
success: function (captchaVerifyParam) {
setField('aliyun_captcha_verify_param', captchaVerifyParam);
submitForm();
SceneId: C.al.scene_id,
mode: 'popup',
element: '#' + C.uid,
success: function (verifyParam) {
setField('aliyun_captcha_verify_param', verifyParam);
completeProviderVerification();
},
fail: function (result) { showTip('验证码失败: ' + (result || '请重试'), '#e74c3c'); captchaIns = null; },
getInstance: function (instance) { captchaIns = instance; },
slideStyle: { width: 360, height: 40 }, language: 'cn'
fail: function () { cancelPending('验证码失败,请重试'); },
getInstance: function (value) { captchaInstance = value; },
slideStyle: { width: 360, height: 40 },
language: 'cn'
});
triggerCaptcha = function () {
if (captchaIns && captchaIns.show) { captchaIns.show(); return; }
showTip('验证码组件加载中,请稍候再试', '#e67e22');
if (captchaInstance && captchaInstance.show) captchaInstance.show();
else cancelPending('验证码组件加载中,请稍候再试');
};
providerReset = function () {
if (captchaInstance && typeof captchaInstance.reset === 'function') captchaInstance.reset();
};
}
if (typeof window.initAliyunCaptcha === 'undefined') {
loadScriptOnce('aliyun', 'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js', function () {
return typeof window.initAliyunCaptcha !== 'undefined';
}).then(render).catch(function () { clearLoading(); showTip('验证码组件加载失败,请刷新重试', '#e74c3c'); });
return;
}
render();
}
function loadProvider() {
switch (C.provider) {
case 'tencent': loadTencent(); break;
case 'cloudflare': loadCloudflare(); break;
case 'geetest': loadGeetest(); break;
case 'aliyun': loadAliyun(); break;
}).then(render).catch(function () {
clearLoading();
cancelPending('验证码组件加载失败,请刷新重试');
});
} else {
render();
}
}
function init() {
box = document.getElementById(C.uid);
hField = document.getElementById(C.vid);
if (!box || !hField) return;
var tries = 0;
(function retryFind() {
findForm();
if (!form && ++tries < 10) { setTimeout(retryFind, 150); }
else {
if (form) bindSubmit();
setTimeout(loadProvider, 0);
}
})();
switch (C.provider) {
case 'tencent': loadTencent(); break;
case 'cloudflare': loadCloudflare(); break;
case 'geetest': loadGeetest(); break;
case 'aliyun': loadAliyun(); break;
}
init();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', scan);
} else {
setTimeout(scan, 50);
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', scan);
else setTimeout(scan, 0);
if (window.MutationObserver) {
new MutationObserver(function (mutations) {
var shouldScan = mutations.some(function (mutation) { return mutation.addedNodes && mutation.addedNodes.length; });
if (shouldScan) scan();
}).observe(document.documentElement, { childList: true, subtree: true });
}
})();
;
+424 -273
View File
@@ -1,406 +1,557 @@
/**
* AKSRT Captcha front-end runtime — cloud providers only (tencent / aliyun / cloudflare / geetest).
* AKSRT Captcha front-end runtime — cloud providers only.
*
* PHP renders passive mounts via [data-aksrt-captcha-config] (JSON config attribute);
* this script scans them, registers AKSRTCaptchaState.scenes[scene], loads the provider
* SDK once per page, and writes verified tokens back into the surrounding form.
* Bundled into assets/js/build/public.js by tools/build-public-assets.mjs.
* Mounts are registered internally by uid and resolved by scene plus DOM scope.
* The only public surface is window.AKSRTCaptcha.
*/
(function () {
'use strict';
var TOKEN_FIELDS = [
var TOKEN_FIELDS = Object.freeze([
'captcha_verified',
'aksrt_captcha_challenge',
'aksrt_captcha_proof',
'cf_turnstile_response',
'tencent_ticket', 'tencent_randstr', 'tencent_sid',
'tencent_ticket', 'tencent_randstr',
'geetest_lot_number', 'geetest_captcha_output', 'geetest_pass_token', 'geetest_gen_time',
'aliyun_captcha_verify_param'
];
]);
var loaders = {};
var instances = {};
var scenes = {};
var root = (window.AKSRTCaptchaState = window.AKSRTCaptchaState || { loaders: {}, scenes: {} });
root.rescan = scan;
function asElement(scope) {
if (!scope) return null;
if (scope.nodeType === 1 || scope.nodeType === 9) return scope;
if (typeof scope !== 'string') return null;
if (instances[scope]) return instances[scope].box;
try { return document.querySelector(scope); } catch (_) { return null; }
}
function scan() {
var mounts = document.querySelectorAll('[data-aksrt-captcha-config]');
Array.prototype.forEach.call(mounts, function (el) {
if (el.dataset.aksrtCaptchaBound) {
return;
}
el.dataset.aksrtCaptchaBound = '1';
var config;
function scopeBoundary(box, config) {
if (config.scope) {
try {
config = JSON.parse(el.getAttribute('data-aksrt-captcha-config'));
} catch (e) {
return;
}
if (config && config.scene && config.uid && config.vid) {
initMount(config);
}
var configured = document.querySelector(config.scope);
if (configured) return configured;
} catch (_) {}
}
return box.closest('[data-aksrt-captcha-scope], form, [data-aksrt-download-box], [data-aksrt-auth], .aksrt-modal, [data-signin-captcha], #comments') || box.parentElement;
}
function register(instance) {
instances[instance.uid] = instance;
scenes[instance.scene] = scenes[instance.scene] || [];
if (scenes[instance.scene].indexOf(instance) === -1) scenes[instance.scene].push(instance);
}
function resolve(scene, scope) {
scan();
if (typeof scope === 'string' && instances[scope] && instances[scope].scene === scene) {
return instances[scope];
}
var candidates = scenes[scene] || [];
var element = asElement(scope);
if (element && element.getAttribute) {
var uid = element.getAttribute('data-aksrt-captcha-uid') || element.getAttribute('data-captcha-uid');
if (uid && instances[uid] && instances[uid].scene === scene) return instances[uid];
}
if (!element) return scope ? null : (candidates.length === 1 ? candidates[0] : null);
var matches = candidates.filter(function (instance) {
var boundary = instance.scope;
return element === instance.box || element === instance.mount || element === boundary ||
instance.box.contains(element) || instance.mount.contains(element) ||
(boundary && boundary.contains(element)) ||
(element.contains && (element.contains(instance.box) || (boundary && element.contains(boundary))));
});
if (matches.length === 1) return matches[0];
if (!matches.length) return null;
matches.sort(function (a, b) {
if (a.scope === b.scope) return 0;
if (a.scope && b.scope && a.scope.contains(b.scope)) return 1;
if (a.scope && b.scope && b.scope.contains(a.scope)) return -1;
return 0;
});
return matches[0];
}
function verify(scene, scope) {
var instance = resolve(scene, scope);
if (!instance) return Promise.resolve(true);
return instance.verify();
}
function collect(scene, scope) {
var instance = resolve(scene, scope);
return instance ? instance.collect() : {};
}
function reset(scene, scope) {
var instance = resolve(scene, scope);
if (instance) instance.reset();
}
function releaseRunLock(instance) {
instance.runLocked = false;
var waiters = instance.lockWaiters.slice();
instance.lockWaiters = [];
waiters.forEach(function (resolveWaiter) { resolveWaiter(null); });
}
function run(scene, scope) {
var instance = resolve(scene, scope);
if (!instance) return Promise.resolve({});
if (instance.runLocked) {
return new Promise(function (resolveWaiter) { instance.lockWaiters.push(resolveWaiter); });
}
instance.runLocked = true;
return instance.verify().then(function (passed) {
if (!passed) releaseRunLock(instance);
return passed ? instance.collect() : null;
}).catch(function (error) {
releaseRunLock(instance);
throw error;
});
}
/**
* One mount = one scene instance. Mirrors the scene semantics of the previous
* per-render inline script: the last initialized mount wins scenes[scene].
*/
function initMount(C) {
var root = window.AKSRTCaptchaState;
var box, hField, form = null;
window.AKSRTCaptcha = Object.freeze({
verify: verify,
collect: collect,
reset: reset,
run: run,
TOKEN_FIELDS: TOKEN_FIELDS
});
function scan() {
var mounts = document.querySelectorAll('[data-aksrt-captcha-config]');
Array.prototype.forEach.call(mounts, function (mount) {
if (mount.dataset.aksrtCaptchaBound) return;
var config;
try {
config = JSON.parse(mount.getAttribute('data-aksrt-captcha-config'));
} catch (_) {
return;
}
if (!config || !config.scene || !config.uid || !config.vid) return;
mount.dataset.aksrtCaptchaBound = '1';
initMount(config, mount);
});
}
function initMount(C, mount) {
var box = document.getElementById(C.uid);
var hField = document.getElementById(C.vid);
if (!box || !hField) return;
var container = box.closest('form') || mount;
mount.setAttribute('data-aksrt-captcha-uid', C.uid);
var verified = false;
var pendingAction = null;
var triggerCaptcha = function () { showTip('验证码加载中,请稍候', '#e67e22'); };
var pending = [];
var refreshingChallenge = null;
var providerReset = function () {};
var triggerCaptcha = function () {
showTip('验证码加载中,请稍候', '#e67e22');
finishPending(false);
};
function loadScriptOnce(key, src, readyCheck) {
if (typeof readyCheck === 'function' && readyCheck()) { return Promise.resolve(); }
if (root.loaders[key]) { return root.loaders[key]; }
root.loaders[key] = new Promise(function (resolve, reject) {
if (typeof readyCheck === 'function' && readyCheck()) return Promise.resolve();
if (loaders[key]) return loaders[key];
loaders[key] = new Promise(function (resolveLoad, rejectLoad) {
var existing = document.querySelector('script[data-aksrt-sdk="' + key + '"]');
if (existing) {
if (typeof readyCheck === 'function' && readyCheck()) { resolve(); return; }
existing.addEventListener('load', function () { resolve(); }, { once: true });
existing.addEventListener('error', function () { reject(new Error(key + ' load failed')); }, { once: true });
if (typeof readyCheck === 'function' && readyCheck()) { resolveLoad(); return; }
existing.addEventListener('load', resolveLoad, { once: true });
existing.addEventListener('error', function () { rejectLoad(new Error(key + ' load failed')); }, { once: true });
return;
}
var script = document.createElement('script');
script.src = src;
script.async = true;
script.setAttribute('data-aksrt-sdk', key);
script.onload = function () { resolve(); };
script.onerror = function () { reject(new Error(key + ' load failed')); };
script.onload = resolveLoad;
script.onerror = function () { rejectLoad(new Error(key + ' load failed')); };
document.head.appendChild(script);
});
return root.loaders[key];
}
function findForm() {
form = box.closest('form');
if (!form && C.scene === 'comment') form = document.getElementById('commentform');
if (!form && (C.scene === 'login' || C.scene === 'register')) {
form = box.closest('[data-auth-form]') || document.querySelector('[data-auth-form]');
}
if (!form && C.scene === 'bind_phone') {
form = document.getElementById('aksrt-bind-phone-form')
|| document.querySelector('[data-aksrt-bind-phone-form]')
|| document.querySelector('.auth-form');
}
return form;
}
function bindSubmit() {
if (!form) return;
form.addEventListener('submit', function (e) {
if (verified || hField.value === '1') return;
e.preventDefault(); e.stopImmediatePropagation();
pendingAction = null; triggerCaptcha();
}, true);
return loaders[key];
}
function setField(name, value) {
var container = form || box;
var el = container.querySelector('input[name="' + name + '"]');
if (!el) {
el = document.createElement('input');
el.type = 'hidden';
el.name = name;
container.appendChild(el);
var field = container.querySelector('input[name="' + name + '"]');
if (!field) {
field = document.createElement('input');
field.type = 'hidden';
field.name = name;
container.appendChild(field);
}
el.value = value;
field.value = value == null ? '' : value;
}
function collectFields() {
var payload = {};
TOKEN_FIELDS.forEach(function (name) {
var field = container.querySelector('input[name="' + name + '"]');
if (field && field.value !== '') payload[name] = field.value;
});
return payload;
}
function refreshChallenge() {
if (!C.challenge_url || typeof window.fetch !== 'function') return Promise.resolve();
if (refreshingChallenge) return refreshingChallenge;
refreshingChallenge = window.fetch(C.challenge_url, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scene: C.scene })
}).then(function (response) {
return response.json().catch(function () { return {}; }).then(function (body) {
if (!response.ok || !body || body.success === false) throw new Error(body.message || 'challenge refresh failed');
var data = body.data || body;
if (!data.challenge) throw new Error('challenge missing');
setField('aksrt_captcha_challenge', data.challenge);
});
}).catch(function () {
showTip('无法刷新人机验证会话,请刷新页面重试', '#e74c3c');
}).finally(function () {
refreshingChallenge = null;
});
return refreshingChallenge;
}
function clearProviderTokens() {
TOKEN_FIELDS.forEach(function (name) {
if (name === 'captcha_verified' || name === 'aksrt_captcha_challenge' || name === 'aksrt_captcha_proof') return;
Array.prototype.forEach.call(container.querySelectorAll('input[name="' + name + '"]'), function (field) {
field.remove();
});
});
}
function resetVerificationState() {
verified = false;
releaseRunLock(instance);
hField.value = '0';
var container = form || box;
TOKEN_FIELDS.forEach(function (n) {
var f = container.querySelector('input[name="' + n + '"]');
if (f) f.remove();
});
if (C.scene === 'download') {
syncDownloadSiblings(false);
}
setField('aksrt_captcha_proof', '');
clearProviderTokens();
try { providerReset(); } catch (_) {}
refreshChallenge();
}
function syncDownloadSiblings(passed) {
var myContainer = box.closest('[data-aksrt-download-captcha]');
if (!myContainer) return;
document.querySelectorAll('[data-aksrt-download-captcha]').forEach(function (target) {
if (target === myContainer) return;
var tgtH = target.querySelector('input[name="captcha_verified"]');
if (tgtH) tgtH.value = passed ? '1' : '0';
TOKEN_FIELDS.forEach(function (name) {
var f = target.querySelector('input[name="' + name + '"]');
if (f) f.remove();
if (passed) {
var srcEl = myContainer.querySelector('input[name="' + name + '"]');
if (srcEl && srcEl.value !== '') {
var tgtEl = document.createElement('input');
tgtEl.type = 'hidden';
tgtEl.name = name;
target.appendChild(tgtEl);
tgtEl.value = srcEl.value;
}
}
});
});
}
function submitNativeForm() {
if (!form) return;
if (form.requestSubmit) { form.requestSubmit(); }
else { HTMLFormElement.prototype.submit.call(form); }
}
function submitForm() {
verified = true; hField.value = '1';
if (C.scene === 'download') syncDownloadSiblings(true);
if (typeof pendingAction === 'function') {
finishPending(true);
return;
}
setTimeout(resetVerificationState, 3000);
submitNativeForm();
}
function showTip(msg, color) {
var t = box.querySelector('.captcha-tip');
if (!t) {
t = document.createElement('div');
t.className = 'captcha-tip';
t.style.cssText = 'font-size:12px;margin-top:4px;text-align:left;';
box.appendChild(t);
}
t.style.color = color || '#e74c3c';
t.textContent = msg;
setTimeout(function () { if (t.parentNode) t.remove(); }, 4000);
}
function showLoading() { box.innerHTML = '<div style="text-align:center;padding:20px;color:#9ca3af;">验证码加载中...</div>'; }
function clearLoading() { box.innerHTML = ''; }
function finishPending(ok) {
var action = pendingAction;
pendingAction = null;
if (typeof action === 'function') { action(!!ok); }
var callbacks = pending.slice();
pending = [];
callbacks.forEach(function (callback) { callback(!!ok); });
}
function cancelPending(msg) {
if (msg) showTip(msg, '#e67e22');
function cancelPending(message) {
if (message) showTip(message, '#e67e22');
finishPending(false);
}
root.scenes[C.scene] = {
verify: function (callback) {
if (verified || hField.value === '1') {
if (typeof callback === 'function') callback(true);
return;
}
pendingAction = (typeof callback === 'function') ? function (ok) { callback(ok !== false); } : null;
triggerCaptcha();
},
reset: resetVerificationState
function providerPayload() {
var payload = collectFields();
payload.scene = C.scene;
return payload;
}
function exchangeVerification() {
if (!C.exchange_url || typeof window.fetch !== 'function') {
cancelPending('人机验证服务不可用,请刷新页面重试');
return;
}
window.fetch(C.exchange_url, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(providerPayload())
}).then(function (response) {
return response.json().catch(function () { return {}; }).then(function (body) {
if (!response.ok || !body || body.success === false) throw new Error(body.message || '人机验证失败');
var data = body.data || body;
if (!data.proof) throw new Error('人机验证凭证缺失');
setField('aksrt_captcha_proof', data.proof);
clearProviderTokens();
completeVerification();
});
}).catch(function (error) {
cancelPending(error.message || '人机验证失败,请重试');
resetVerificationState();
});
}
function completeProviderVerification() {
hField.value = '1';
exchangeVerification();
}
function completeVerification() {
verified = true;
hField.value = '1';
finishPending(true);
}
function showTip(message, color) {
var tip = box.querySelector('.captcha-tip');
if (!tip) {
tip = document.createElement('div');
tip.className = 'captcha-tip';
tip.style.cssText = 'font-size:12px;margin-top:4px;text-align:left;';
box.appendChild(tip);
}
tip.style.color = color || '#e74c3c';
tip.textContent = message;
setTimeout(function () { if (tip.parentNode) tip.remove(); }, 4000);
}
function showLoading() {
box.innerHTML = '<div style="text-align:center;padding:20px;color:#9ca3af;">验证码加载中...</div>';
}
function clearLoading() {
box.innerHTML = '';
}
var instance = {
uid: C.uid,
scene: C.scene,
box: box,
mount: mount,
scope: scopeBoundary(box, C),
runLocked: false,
lockWaiters: [],
collect: collectFields,
reset: resetVerificationState,
verify: function () {
var challengeField = container.querySelector('input[name="aksrt_captcha_challenge"]');
var preparation = challengeField && challengeField.value ? Promise.resolve() : refreshChallenge();
return preparation.then(function () {
challengeField = container.querySelector('input[name="aksrt_captcha_challenge"]');
if (!challengeField || !challengeField.value) {
showTip('人机验证会话未就绪,请稍后重试', '#e74c3c');
return false;
}
if (verified || hField.value === '1') return true;
return new Promise(function (resolveVerify) {
pending.push(resolveVerify);
if (pending.length === 1) triggerCaptcha();
});
});
}
};
register(instance);
refreshingChallenge = refreshChallenge();
// ============ 腾讯云 Captcha 2.0 ============
function loadTencent() {
showLoading();
loadScriptOnce('tencent', 'https://turing.captcha.qcloud.com/TJCaptcha.js', function () {
return typeof TencentCaptcha !== 'undefined';
return typeof window.TencentCaptcha !== 'undefined';
}).then(function () {
clearLoading();
var capIns = null;
var captchaInstance = null;
triggerCaptcha = function () {
if (!capIns) {
capIns = new TencentCaptcha(C.tc.app_id, function (res) {
if (res.ret === 2) { cancelPending('已取消验证'); return; }
if (res.ret === 0 && res.ticket) {
if (res.ticket.indexOf('trerror_') === 0) {
showTip('验证码服务异常,请刷新页面重试', '#e67e22');
if (!captchaInstance) {
captchaInstance = new window.TencentCaptcha(C.tc.app_id, function (result) {
if (result.ret === 2) { cancelPending('已取消验证'); return; }
if (result.ret === 0 && result.ticket) {
if (result.ticket.indexOf('trerror_') === 0) {
cancelPending('验证码服务异常,请刷新页面重试');
return;
}
setField('tencent_ticket', res.ticket);
setField('tencent_randstr', res.randstr);
setField('tencent_sid', res.sid || '');
submitForm();
setField('tencent_ticket', result.ticket);
setField('tencent_randstr', result.randstr);
completeProviderVerification();
return;
}
if (res.errorCode) {
var errorMap = { 1001: '验证码组件加载失败', 1002: '验证码超时', 1006: '验证码配置拉取失败', 1008: '验证码页面加载异常', 1012: '验证码刷新次数过多', 1013: '验证码网络异常' };
showTip(errorMap[res.errorCode] || ('验证码异常(errorCode:' + res.errorCode + ')'), '#e74c3c');
}
var errorMap = { 1001: '验证码组件加载失败', 1002: '验证码超时', 1006: '验证码配置拉取失败', 1008: '验证码页面加载异常', 1012: '验证码刷新次数过多', 1013: '验证码网络异常' };
cancelPending(errorMap[result.errorCode] || '验证码异常,请重试');
}, { userLanguage: 'zh-cn' });
}
capIns.show();
captchaInstance.show();
};
}).catch(function () { clearLoading(); showTip('验证码组件加载失败,请刷新重试', '#e74c3c'); });
}).catch(function () {
clearLoading();
cancelPending('验证码组件加载失败,请刷新重试');
});
}
// ============ Cloudflare Turnstile(弹窗) ============
function loadCloudflare() {
var cfWidgetId = null, cfVerifying = false, cfModal = null;
var widgetId = null;
var verifying = false;
var modal = null;
if (typeof turnstile === 'undefined') {
window['onTurnstileLoad_' + C.uid] = function () {};
loadScriptOnce('cloudflare', 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad_' + C.uid + '&render=explicit', function () {
return typeof turnstile !== 'undefined';
});
function closeModal() {
if (widgetId !== null && typeof window.turnstile !== 'undefined') {
try { window.turnstile.remove(widgetId); } catch (_) {}
}
widgetId = null;
if (modal && modal.parentNode) modal.parentNode.removeChild(modal);
modal = null;
}
function renderWidget() {
if (cfWidgetId) { try { turnstile.remove(cfWidgetId); } catch (e) {} cfWidgetId = null; }
if (typeof turnstile === 'undefined') { setTimeout(renderWidget, 200); return; }
cfWidgetId = turnstile.render('#modalWidget_' + C.uid, {
sitekey: C.cf.site_key, theme: C.cf.theme, size: C.cf.size, action: 'aksrt-verify', cdata: C.scene,
if (typeof window.turnstile === 'undefined') { setTimeout(renderWidget, 200); return; }
widgetId = window.turnstile.render('#modalWidget_' + C.uid, {
sitekey: C.cf.site_key,
theme: C.cf.theme,
size: C.cf.size,
action: 'aksrt-verify',
cdata: C.scene,
callback: function (token) {
if (!cfVerifying) return;
cfVerifying = false;
if (!verifying) return;
verifying = false;
setField('cf_turnstile_response', token);
closeModal();
submitForm();
completeProviderVerification();
},
'error-callback': function () { showTip('验证码失败,请重试', '#e74c3c'); cfWidgetId = null; setTimeout(renderWidget, 500); },
'expired-callback': function () { showTip('验证码已过期,请重新验证', '#e67e22'); cfWidgetId = null; setTimeout(renderWidget, 500); }
'error-callback': function () {
verifying = false;
closeModal();
cancelPending('验证码失败,请重试');
},
'expired-callback': function () {
verifying = false;
closeModal();
cancelPending('验证码已过期,请重新验证');
}
});
}
function createModal() {
cfModal = document.createElement('div');
cfModal.className = 'aksrt-captcha-modal-overlay';
cfModal.innerHTML = '<div class="aksrt-captcha-modal-backdrop"></div>' +
modal = document.createElement('div');
modal.className = 'aksrt-captcha-modal-overlay';
modal.innerHTML = '<div class="aksrt-captcha-modal-backdrop"></div>' +
'<div class="aksrt-captcha-modal-box"><div class="aksrt-captcha-modal-header">' +
'<span class="aksrt-captcha-modal-title">人机验证</span>' +
'<button type="button" class="aksrt-captcha-modal-close">&times;</button></div>' +
'<div class="aksrt-captcha-modal-body"><p class="aksrt-captcha-modal-hint">请完成验证后继续操作</p>' +
'<div id="modalWidget_' + C.uid + '" class="aksrt-captcha-modal-widget"></div></div></div></div>';
document.body.appendChild(cfModal);
var cancelFn = function () { cfVerifying = false; closeModal(); cancelPending('已取消验证'); };
cfModal.querySelector('.aksrt-captcha-modal-backdrop').addEventListener('click', cancelFn);
cfModal.querySelector('.aksrt-captcha-modal-close').addEventListener('click', cancelFn);
document.body.appendChild(modal);
var cancel = function () {
verifying = false;
closeModal();
cancelPending('已取消验证');
};
modal.querySelector('.aksrt-captcha-modal-backdrop').addEventListener('click', cancel);
modal.querySelector('.aksrt-captcha-modal-close').addEventListener('click', cancel);
renderWidget();
}
function closeModal() {
if (cfWidgetId) { try { turnstile.remove(cfWidgetId); } catch (e) {} cfWidgetId = null; }
if (cfModal && cfModal.parentNode) { cfModal.parentNode.removeChild(cfModal); }
cfModal = null;
window['onTurnstileLoad_' + C.uid] = function () {};
if (typeof window.turnstile === 'undefined') {
loadScriptOnce('cloudflare', 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad_' + C.uid + '&render=explicit', function () {
return typeof window.turnstile !== 'undefined';
}).catch(function () { cancelPending('验证码组件加载失败,请刷新重试'); });
}
triggerCaptcha = function () {
if (cfVerifying) { showTip('验证码进行中,请稍候', '#e67e22'); return; }
cfVerifying = true;
if (verifying) return;
verifying = true;
createModal();
};
providerReset = closeModal;
}
// ============ 极验 GeeTest v4 ============
function loadGeetest() {
showLoading();
var captchaId = (C.gt && C.gt.captcha_id) ? String(C.gt.captcha_id).trim() : '';
var product = (C.gt && C.gt.product) ? String(C.gt.product) : 'bind';
var captchaId = C.gt && C.gt.captcha_id ? String(C.gt.captcha_id).trim() : '';
var product = C.gt && C.gt.product ? String(C.gt.product) : 'bind';
if (!captchaId) {
clearLoading();
showTip('极验 Captcha ID 未配置,请到后台「功能&权限 → 人机验证」填写并保存', '#e74c3c');
cancelPending('极验 Captcha ID 未配置');
return;
}
function render() {
clearLoading();
initGeetest4({ captchaId: captchaId, product: product, hideSuccess: true }, function (capIns) {
window['__gt_' + C.uid] = capIns;
window.initGeetest4({ captchaId: captchaId, product: product, hideSuccess: true }, function (captchaInstance) {
function onSuccess() {
var result = capIns.getValidate();
if (!result) return;
var result = captchaInstance.getValidate();
if (!result) { cancelPending('验证码失败,请重试'); return; }
setField('geetest_lot_number', result.lot_number);
setField('geetest_captcha_output', result.captcha_output);
setField('geetest_pass_token', result.pass_token);
setField('geetest_gen_time', result.gen_time);
submitForm();
}
if (product === 'bind' || product === 'popup') {
capIns.onReady(function () {}).onSuccess(onSuccess).onClose(function () { cancelPending('已取消验证'); });
} else {
capIns.appendTo('#' + C.uid).onSuccess(onSuccess).onClose(function () { cancelPending('已取消验证'); });
completeProviderVerification();
}
captchaInstance.onReady(function () {}).onSuccess(onSuccess).onClose(function () { cancelPending('已取消验证'); });
if (product !== 'bind' && product !== 'popup') captchaInstance.appendTo('#' + C.uid);
triggerCaptcha = function () {
if (product === 'bind' || product === 'popup') captchaInstance.showCaptcha();
else showTip('请先完成人机验证', '#e67e22');
};
providerReset = function () {
if (typeof captchaInstance.reset === 'function') captchaInstance.reset();
};
});
triggerCaptcha = function () {
var instance = window['__gt_' + C.uid];
if (!instance) { showTip('验证码加载中,请稍候再试', '#e67e22'); return; }
if (product === 'bind' || product === 'popup') { instance.showCaptcha(); return; }
showTip('请先完成人机验证', '#e67e22');
};
}
if (typeof initGeetest4 === 'undefined') {
if (typeof window.initGeetest4 === 'undefined') {
loadScriptOnce('geetest', 'https://static.geetest.com/v4/gt4.js', function () {
return typeof initGeetest4 !== 'undefined';
}).then(render).catch(function () { clearLoading(); showTip('极验组件加载失败', '#e74c3c'); });
return;
return typeof window.initGeetest4 !== 'undefined';
}).then(render).catch(function () {
clearLoading();
cancelPending('极验组件加载失败');
});
} else {
render();
}
render();
}
// ============ 阿里云 CAPTCHA 2.0 V3 ============
function loadAliyun() {
showLoading();
window.AliyunCaptchaConfig = { region: C.al.region || 'cn', prefix: C.al.prefix };
function render() {
clearLoading();
var captchaIns = null;
var btn = box.closest('form') ? box.closest('form').querySelector('button[type="submit"], input[type="submit"]') : null;
var captchaInstance = null;
window.initAliyunCaptcha({
SceneId: C.al.scene_id, mode: 'popup', element: '#' + C.uid,
button: btn ? '#' + (btn.id || (function () { btn.id = 'aksrt-captcha-btn-' + C.scene; return btn.id; })()) : null,
success: function (captchaVerifyParam) {
setField('aliyun_captcha_verify_param', captchaVerifyParam);
submitForm();
SceneId: C.al.scene_id,
mode: 'popup',
element: '#' + C.uid,
success: function (verifyParam) {
setField('aliyun_captcha_verify_param', verifyParam);
completeProviderVerification();
},
fail: function (result) { showTip('验证码失败: ' + (result || '请重试'), '#e74c3c'); captchaIns = null; },
getInstance: function (instance) { captchaIns = instance; },
slideStyle: { width: 360, height: 40 }, language: 'cn'
fail: function () { cancelPending('验证码失败,请重试'); },
getInstance: function (value) { captchaInstance = value; },
slideStyle: { width: 360, height: 40 },
language: 'cn'
});
triggerCaptcha = function () {
if (captchaIns && captchaIns.show) { captchaIns.show(); return; }
showTip('验证码组件加载中,请稍候再试', '#e67e22');
if (captchaInstance && captchaInstance.show) captchaInstance.show();
else cancelPending('验证码组件加载中,请稍候再试');
};
providerReset = function () {
if (captchaInstance && typeof captchaInstance.reset === 'function') captchaInstance.reset();
};
}
if (typeof window.initAliyunCaptcha === 'undefined') {
loadScriptOnce('aliyun', 'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js', function () {
return typeof window.initAliyunCaptcha !== 'undefined';
}).then(render).catch(function () { clearLoading(); showTip('验证码组件加载失败,请刷新重试', '#e74c3c'); });
return;
}
render();
}
function loadProvider() {
switch (C.provider) {
case 'tencent': loadTencent(); break;
case 'cloudflare': loadCloudflare(); break;
case 'geetest': loadGeetest(); break;
case 'aliyun': loadAliyun(); break;
}).then(render).catch(function () {
clearLoading();
cancelPending('验证码组件加载失败,请刷新重试');
});
} else {
render();
}
}
function init() {
box = document.getElementById(C.uid);
hField = document.getElementById(C.vid);
if (!box || !hField) return;
var tries = 0;
(function retryFind() {
findForm();
if (!form && ++tries < 10) { setTimeout(retryFind, 150); }
else {
if (form) bindSubmit();
setTimeout(loadProvider, 0);
}
})();
switch (C.provider) {
case 'tencent': loadTencent(); break;
case 'cloudflare': loadCloudflare(); break;
case 'geetest': loadGeetest(); break;
case 'aliyun': loadAliyun(); break;
}
init();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', scan);
} else {
setTimeout(scan, 50);
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', scan);
else setTimeout(scan, 0);
if (window.MutationObserver) {
new MutationObserver(function (mutations) {
var shouldScan = mutations.some(function (mutation) { return mutation.addedNodes && mutation.addedNodes.length; });
if (shouldScan) scan();
}).observe(document.documentElement, { childList: true, subtree: true });
}
})();
+35 -54
View File
@@ -326,26 +326,12 @@
}
// ===== 主流程 =====
function collectCaptchaPayload(sourceEl) {
const box = sourceEl ? sourceEl.closest('[data-aksrt-download-box]') : null;
const container = box ? box.querySelector('[data-aksrt-download-captcha]') : null;
if (!container) return {};
const out = {};
const keys = [
'captcha_verified',
'cf_turnstile_response',
'tencent_ticket', 'tencent_randstr', 'tencent_sid',
'geetest_lot_number', 'geetest_captcha_output', 'geetest_pass_token', 'geetest_gen_time',
'aliyun_captcha_verify_param',
];
keys.forEach(function (k) {
const el = container.querySelector('input[name="' + k + '"]');
if (el && el.value !== '') out[k] = el.value;
});
return out;
}
function captchaScope(sourceEl) {
const box = sourceEl ? sourceEl.closest('[data-aksrt-download-box]') : null;
return box ? (box.querySelector('[data-aksrt-download-captcha]') || box) : sourceEl;
}
function isCaptchaActive(sourceEl) {
function isCaptchaActive(sourceEl) {
const card = sourceEl ? sourceEl.closest('[data-aksrt-download-box]') : null;
if (!card) return false;
return card.getAttribute('data-captcha') === '1';
@@ -370,22 +356,20 @@
);
}
function ensureCaptchaThen(sourceEl, callback) {
if (!isCaptchaActive(sourceEl)) { callback(); return; }
const state = window.AKSRTCaptchaState;
const scene = state && state.scenes && state.scenes.download;
if (!scene || typeof scene.verify !== 'function') {
// captcha 组件未就绪:不直发,避免绕过服务端门槛
if (window.console && console.warn) {
console.warn('[AKSRT] download captcha not ready');
function ensureCaptchaThen(sourceEl, callback) {
if (!isCaptchaActive(sourceEl)) { callback({}); return; }
const captcha = window.AKSRTCaptcha;
if (!captcha) {
if (window.console && console.warn) console.warn('[AKSRT] download captcha not ready');
alert('验证码组件加载中,请稍候再试');
return;
}
alert('验证码组件加载中,请稍候再试');
return;
captcha.run('download', captchaScope(sourceEl)).then(function (payload) {
if (payload !== null) callback(payload);
});
}
scene.verify(function () { callback(); });
}
function performDownloadRequest(button) {
function performDownloadRequest(button, captchaPayload) {
const card = button.closest('[data-aksrt-download-box]');
if (!card) return;
const postId = parseInt(card.getAttribute('data-post-id'), 10);
@@ -396,10 +380,9 @@
const original = button.innerHTML;
button.innerHTML = '<i class="fa fas fa-spinner fa-spin" aria-hidden="true"></i> 处理中…';
const restNonce = window.aksrtData.restNonce || '';
const captchaPayload = collectCaptchaPayload(button);
const restNonce = window.aksrtData.restNonce || '';
fetch(REST_BASE + '/download/request', {
fetch(REST_BASE + '/download/request', {
method: 'POST',
credentials: 'same-origin',
headers: {
@@ -416,28 +399,26 @@
.then(function (r) {
button.disabled = false;
button.innerHTML = original;
if (!r.ok || (r.json && r.json.code)) {
renderError(r.json.message || '请稍后重试', r.json.data || {}, button, r.json.code);
// 验证码已被消费,重置以便用户重试
try {
const scene = window.AKSRTCaptchaState && window.AKSRTCaptchaState.scenes && window.AKSRTCaptchaState.scenes.download;
if (scene && typeof scene.reset === 'function') scene.reset();
} catch (_) { /* ignore */ }
return;
}
if (!r.ok || (r.json && r.json.code)) {
renderError(r.json.message || '请稍后重试', r.json.data || {}, button, r.json.code);
return;
}
renderSuccess(r.json, button);
})
.catch(function () {
button.disabled = false;
button.innerHTML = original;
renderError('网络错误,请稍后重试', {}, button);
});
}
.catch(function () {
button.disabled = false;
button.innerHTML = original;
renderError('网络错误,请稍后重试', {}, button);
})
.finally(function () {
if (window.AKSRTCaptcha) window.AKSRTCaptcha.reset('download', captchaScope(button));
});
}
function requestDownload(button) {
ensureCaptchaThen(button, function () {
performDownloadRequest(button);
});
function requestDownload(button) {
ensureCaptchaThen(button, function (captchaPayload) {
performDownloadRequest(button, captchaPayload);
});
}
function pollOrderPaid(orderNumber, returnFocusEl) {
+25 -83
View File
@@ -192,8 +192,8 @@
}
}
// 执行实际的 AJAX 请求 — local insert instead of full-page re-fetch
function performSubmit() {
// 执行实际的 REST 请求 — local insert instead of full-page re-fetch
function performSubmit(captchaPayload) {
setLoading(true);
var formData = collectFormData();
@@ -205,6 +205,7 @@
// Try REST endpoint for local-insert HTML response
var restBody = {};
formData.forEach(function (v, k) { restBody[k] = v; });
Object.assign(restBody, captchaPayload || {});
if (isEdit) {
restBody.comment_id = parseInt(editId.value, 10);
restBody.content = restBody.comment || restBody.content || '';
@@ -245,15 +246,6 @@
var replyInfo = document.getElementById('comment-reply-info');
if (replyInfo) replyInfo.style.display = 'none';
// Reset captcha after successful submit
try {
var captchaScene = window.AKSRTCaptchaState &&
window.AKSRTCaptchaState.scenes &&
window.AKSRTCaptchaState.scenes.comment;
if (captchaScene && typeof captchaScene.reset === 'function') {
captchaScene.reset();
}
} catch (e) { /* ignore */ }
// Wait time countdown
if (json.wait_time && json.wait_time > 0) {
@@ -282,14 +274,6 @@
if (json && json.code) {
notyf(json.message || '评论提交失败', 'danger');
try {
var failScene = window.AKSRTCaptchaState &&
window.AKSRTCaptchaState.scenes &&
window.AKSRTCaptchaState.scenes.comment;
if (failScene && typeof failScene.reset === 'function') {
failScene.reset();
}
} catch (e) { /* ignore */ }
if (json.wait_time) startCountdown(json.wait_time);
else setLoading(false);
return;
@@ -299,9 +283,14 @@
setLoading(false);
setTimeout(function () { reloadCommentList(); }, 500);
})
.catch(function () {
// Fallback: submit to wp-comments-post.php directly
performLegacySubmit();
.catch(function (error) {
console.error('[AKSRT Comment] 提交失败:', error);
notyf((error && error.message) || '网络连接失败,请检查网络后重试', 'danger');
setLoading(false);
})
.finally(function () {
if (window.AKSRTCaptcha) window.AKSRTCaptcha.reset('comment', form);
if (!countdownTimer && isSubmitting) setLoading(false);
});
}
@@ -373,44 +362,6 @@
placeholder.remove();
}
}
// Legacy fallback: POST to wp-comments-post.php
function performLegacySubmit() {
var formData = collectFormData();
var actionUrl = form.getAttribute('action') || '/wp-comments-post.php';
fetch(actionUrl, {
method: 'POST',
body: formData,
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function (response) {
return response.text().then(function (text) {
return { status: response.status, text: text, ok: response.ok };
});
})
.then(function (result) {
handleResponse(result.text, result.status);
})
.catch(function (error) {
console.error('[AKSRT Comment] 提交失败:', error);
if (error.message === 'Failed to fetch' || error.name === 'TypeError') {
notyf('网络连接失败,请检查网络后重试', 'danger');
} else {
notyf(error.message || '评论提交失败,请重试', 'danger');
}
setLoading(false);
})
.finally(function () {
// 兜底:避免异常分支漏掉状态恢复导致按钮卡在"发送中"
if (!countdownTimer && isSubmitting) {
setLoading(false);
}
});
}
// 核心表单提交事件处理器
function handleSubmit(e) {
// 如果已经在提交中或倒计时中,防止重复提交
@@ -423,29 +374,20 @@
// 阻止默认表单提交
e.preventDefault();
e.stopImmediatePropagation();
// 检查验证码状态(如果有验证码组件)
var captchaScene = window.AKSRTCaptchaState &&
window.AKSRTCaptchaState.scenes &&
window.AKSRTCaptchaState.scenes.comment;
if (captchaScene && typeof captchaScene.verify === 'function') {
// 有验证码:先触发验证流程
setLoading(true);
captchaScene.verify(function (verified) {
if (verified !== false) {
// 验证通过,执行 AJAX 提交
performSubmit();
} else {
// 验证失败
notyf('人机验证未通过,请重试', 'warning');
setLoading(false);
}
});
} else {
// 无验证码或已经验证过:直接提交
performSubmit();
}
var captcha = window.AKSRTCaptcha;
setLoading(true);
(captcha ? captcha.run('comment', form) : Promise.resolve({})).then(function (captchaPayload) {
if (captchaPayload === null) {
notyf('人机验证未通过,请重试', 'warning');
setLoading(false);
return;
}
performSubmit(captchaPayload);
}).catch(function () {
notyf('人机验证未通过,请重试', 'warning');
setLoading(false);
if (captcha) captcha.reset('comment', form);
});
return false;
}
+19 -23
View File
@@ -196,10 +196,20 @@
submit: function (btn, action) {
var self = this;
if (action !== 'posts_draft' && !this.requestCaptcha(function () {
self.submit(btn, action);
})) {
return;
if (action !== 'posts_draft') {
var captcha = window.AKSRTCaptcha;
if (captcha && !btn.dataset.aksrtCaptchaReady) {
captcha.run('front_submit', this.form).then(function (payload) {
if (payload === null) return;
btn.dataset.aksrtCaptchaReady = '1';
self.submit(btn, action);
}).catch(function () {
captcha.reset('front_submit', self.form);
notyf(i18n.error || '人机验证失败,请重试', 'danger');
});
return;
}
delete btn.dataset.aksrtCaptchaReady;
}
this.isSubmitting = true;
var toastId = action === 'posts_draft'
@@ -292,28 +302,14 @@
btn.disabled = false;
btn.innerHTML = original;
self.isSubmitting = false;
})
.finally(function () {
if (action !== 'posts_draft' && window.AKSRTCaptcha) {
window.AKSRTCaptcha.reset('front_submit', self.form);
}
});
},
requestCaptcha: function (onVerified) {
var verified = this.form.querySelector('input[name="captcha_verified"]');
if (verified && verified.value === '1') {
return true;
}
var captcha = window.AKSRTCaptchaState &&
window.AKSRTCaptchaState.scenes &&
window.AKSRTCaptchaState.scenes.front_submit;
if (!captcha || typeof captcha.verify !== 'function') {
return true;
}
captcha.verify(function (passed) {
if (passed) {
onVerified();
}
});
return false;
},
bindDraftDelete: function () {
document.addEventListener('click', function (e) {
var btn = e.target.closest('.aksrt-submit-drafts__delete');
+151 -90
View File
@@ -22,6 +22,24 @@
var tabAliases = cfg.tabAliases || {};
var tabUrls = cfg.tabUrls || {};
function captchaAvailable(scene, scope) {
var captcha = window.AKSRTCaptcha;
if (!captcha) return false;
var element = scope && scope.nodeType ? scope : null;
if (element && element.matches && element.matches('[data-aksrt-captcha-config][data-scene="' + scene + '"]')) return true;
if (element && element.querySelector && element.querySelector('[data-aksrt-captcha-config][data-scene="' + scene + '"]')) return true;
var mount = element && element.closest ? element.closest('[data-aksrt-captcha-config][data-scene="' + scene + '"]') : null;
return !!mount;
}
function runCaptcha(scene, scope) {
return window.AKSRTCaptcha ? window.AKSRTCaptcha.run(scene, scope) : Promise.resolve({});
}
function resetCaptcha(scene, scope) {
if (window.AKSRTCaptcha) window.AKSRTCaptcha.reset(scene, scope);
}
/* ==================== Dashboard 导航 ==================== */
function initDashboardNavigation() {
var tabContainer = document.querySelector('.uc-mobile-tabs');
@@ -653,7 +671,6 @@
var f = m.querySelector('form');
var sceneMap = { 'pwd-modal': 'change_password', 'email-modal': 'change_email', 'phone-modal': 'bind_phone' };
var scene = m.id && sceneMap[m.id] ? sceneMap[m.id] : '';
var store = window.AKSRTCaptchaState && window.AKSRTCaptchaState.scenes ? window.AKSRTCaptchaState.scenes : null;
if (f) {
f.reset();
var bar = document.getElementById('pwd-strength-bar');
@@ -661,9 +678,7 @@
if (bar && bar.firstChild) { bar.style.width = '0'; bar.firstChild.className = 'pwd-strength__fill'; }
if (txt) { txt.textContent = ''; txt.className = 'pwd-strength__text'; }
}
if (scene && store && store[scene] && typeof store[scene].reset === 'function') {
store[scene].reset();
}
if (scene) resetCaptcha(scene, f || m);
}
root.querySelectorAll('.security-trigger').forEach(function(btn) {
@@ -725,16 +740,6 @@
}
// 验证码发送
function triggerSceneCaptcha(scene, callback) {
var store = window.AKSRTCaptchaState && window.AKSRTCaptchaState.scenes ? window.AKSRTCaptchaState.scenes : null;
var instance = store && store[scene] ? store[scene] : null;
if (instance && typeof instance.verify === 'function') {
instance.verify(callback);
return;
}
callback();
}
function getSendBtnIdleText(btnId) {
if (btnId === 'pwd-send-code') return i18n.sendToCurrentEmail || '发送到当前邮箱';
if (btnId === 'email-send-code') return i18n.sendToNewEmail || '发送到新邮箱';
@@ -753,20 +758,22 @@
var toastId = scene === 'change_password'
? 'aksrt-settings-password-code-toast'
: 'aksrt-settings-email-code-toast';
triggerSceneCaptcha(scene, function() {
self.disabled = true;
runCaptcha(scene, form).then(function(captchaPayload) {
if (captchaPayload === null) return;
self.disabled = true;
self.textContent = i18n.sending || '发送中...';
notyf(i18n.sending || '正在发送验证码…', 'load', '', toastId);
var data = new URLSearchParams();
data.set('action', 'aksrt_send_email_code');
data.set('_settings_nonce', settingsNonce);
data.set('scene', scene || '');
if (form) {
new FormData(form).forEach(function(value, key) {
if (data.has(key) || value === '' || value == null) return;
data.set(key, value);
});
}
if (form) {
new FormData(form).forEach(function(value, key) {
if (data.has(key) || value === '' || value == null) return;
data.set(key, value);
});
}
Object.keys(captchaPayload || {}).forEach(function(key) { data.set(key, captchaPayload[key]); });
fetch(ajaxUrl, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: data.toString() })
.then(function(r) { return r.json(); }).then(function(res) {
if (res.success) {
@@ -791,12 +798,18 @@
self.textContent = getSendBtnIdleText(btnId);
}
})
.catch(function() {
notyf(i18n.networkError || '网络错误', 'danger', 5, toastId);
self.disabled = false;
self.textContent = getSendBtnIdleText(btnId);
.catch(function() {
notyf(i18n.networkError || '网络错误', 'danger', 5, toastId);
self.disabled = false;
self.textContent = getSendBtnIdleText(btnId);
})
.finally(function() {
resetCaptcha(scene, form);
});
}).catch(function() {
resetCaptcha(scene, form);
notyf('人机验证失败,请重试', 'danger', 5, toastId);
});
});
});
}
@@ -829,15 +842,24 @@
return;
}
}
if (btn) { btn.disabled = true; btn.textContent = i18n.submitting || '提交中...'; }
notyf(i18n.submitting || '正在提交…', 'load', '', toastId);
var d = new FormData(fm);
d.set('action', act);
d.set('_settings_nonce', settingsNonce);
fetch(ajaxUrl, { method: 'POST', credentials: 'same-origin', body: d })
.then(function(r) { return r.json(); })
.then(function(r) {
if (btn) { btn.disabled = false; btn.textContent = orig; }
if (btn) { btn.disabled = true; btn.textContent = i18n.submitting || '提交中...'; }
notyf(i18n.submitting || '正在提交…', 'load', '', toastId);
var scene = fid === 'pwd-form' ? 'change_password' : 'change_email';
runCaptcha(scene, fm).then(function(captchaPayload) {
if (captchaPayload === null) {
if (btn) { btn.disabled = false; btn.textContent = orig; }
return null;
}
var d = new FormData(fm);
d.set('action', act);
d.set('_settings_nonce', settingsNonce);
Object.keys(captchaPayload || {}).forEach(function(key) { d.set(key, captchaPayload[key]); });
return fetch(ajaxUrl, { method: 'POST', credentials: 'same-origin', body: d });
})
.then(function(r) { return r ? r.json() : null; })
.then(function(r) {
if (!r) return;
if (btn) { btn.disabled = false; btn.textContent = orig; }
if (r.success) {
notyf((r.data && r.data.message) || '操作成功', 'success', 5, toastId);
closeModal(fm.closest('.aksrt-modal'));
@@ -846,17 +868,64 @@
notyf((r.data && r.data.message) || i18n.operationFailed || '操作失败', 'danger', 5, toastId);
}
})
.catch(function() {
if (btn) { btn.disabled = false; btn.textContent = orig; }
notyf(i18n.networkError || '网络错误', 'danger', 5, toastId);
});
.catch(function() {
if (btn) { btn.disabled = false; btn.textContent = orig; }
notyf(i18n.networkError || '网络错误', 'danger', 5, toastId);
})
.finally(function() {
resetCaptcha(scene, fm);
});
});
}
submitForm('pwd-form', 'aksrt_change_password');
submitForm('email-form', 'aksrt_change_email');
// 解绑
root.querySelectorAll('.binding-item--linkable').forEach(function(link) {
if (link.dataset.aksrtBindCaptchaInitialized === 'true') return;
link.dataset.aksrtBindCaptchaInitialized = 'true';
link.addEventListener('click', function(event) {
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
var startUrl = link.getAttribute('data-bind-start-url') || link.getAttribute('href') || '';
var provider = link.getAttribute('data-provider') || link.getAttribute('data-bind-provider') || '';
var scope = link.closest('[data-aksrt-captcha-scope]') || root;
if (!startUrl || !captchaAvailable('bind', scope)) return;
event.preventDefault();
var restUrl = String(cfg.restUrl || (window.aksrtData && (aksrtData.restUrl || aksrtData.apiUrl)) || '').replace(/\/$/, '');
var nonce = cfg.restNonce || (window.aksrtData && (aksrtData.restNonce || aksrtData.nonce)) || '';
var redirectTo = (cfg.tabUrls && cfg.tabUrls.settings) || window.location.href;
if (!restUrl) { window.location.assign(startUrl); return; }
link.classList.add('is-loading');
runCaptcha('bind', scope).then(function(captchaPayload) {
if (captchaPayload === null) return null;
return fetch(restUrl + '/oauth/bind-pass', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': nonce },
body: JSON.stringify(Object.assign({
provider: provider,
redirect_to: redirectTo
}, captchaPayload || {}))
});
}).then(function(response) {
if (!response) return null;
return response.json().catch(function() { return {}; }).then(function(payload) {
if (!response.ok || !payload || payload.success === false) throw new Error(payload.message || '绑定验证失败');
return payload.data || payload;
});
}).then(function(payload) {
if (payload && payload.url) window.location.assign(payload.url);
}).catch(function(error) {
notyf(error.message || '绑定验证失败,请重试', 'danger');
}).finally(function() {
link.classList.remove('is-loading');
resetCaptcha('bind', scope);
});
});
});
// 解绑
root.querySelectorAll('.binding-unbind-btn').forEach(function(b) {
if (b.dataset.aksrtSettingsInitialized === 'true') return;
b.dataset.aksrtSettingsInitialized = 'true';
@@ -939,8 +1008,9 @@
provider_error: '第三方授权失败,请稍后重试',
callback_failed: '第三方授权失败,请稍后重试',
login_failed: '第三方登录失败,请重试',
bind_failed: '第三方账号绑定失败,请重试',
account_conflict: '该第三方账号无法绑定到当前账号',
bind_failed: '第三方账号绑定失败,请重试',
bind_verification_required: '请先完成人机验证后再绑定',
account_conflict: '该第三方账号无法绑定到当前账号',
already_bound: '该第三方账号已绑定其他账号',
timeout: '第三方授权已超时,请重试'
};
@@ -1016,16 +1086,24 @@
var nonce = (window.aksrtData && aksrtData.restNonce) || '';
sendButton.disabled = true;
notyf('正在发送验证码…', 'load', '', toastId);
fetch(String(restRoot).replace(/\/$/, '') + '/user/phone-code', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': nonce },
body: JSON.stringify({ phone: phone })
}).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, json: j }; }); }).then(function (res) {
runCaptcha('bind_phone', form).then(function(captchaPayload) {
if (captchaPayload === null) {
sendButton.disabled = false;
return null;
}
return fetch(String(restRoot).replace(/\/$/, '') + '/user/phone-code', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': nonce },
body: JSON.stringify(Object.assign({ phone: phone }, captchaPayload || {}))
});
}).then(function (r) { return r ? r.json().then(function (j) { return { ok: r.ok, json: j }; }) : null; }).then(function (res) {
if (!res) return;
if (!res.ok || !res.json.success) throw new Error(res.json.message || '发送失败');
notyf(res.json.message || '验证码已发送', 'success', 5, toastId);
var left = Number((res.json.data && res.json.data.countdown) || 60);
var timer = setInterval(function () { left -= 1; sendButton.textContent = left + 's'; if (left <= 0) { clearInterval(timer); sendButton.disabled = false; sendButton.textContent = '获取验证码'; } }, 1000);
}).catch(function (error) { sendButton.disabled = false; notyf(error.message || '发送失败', 'danger', 5, toastId); });
}).catch(function (error) { sendButton.disabled = false; notyf(error.message || '发送失败', 'danger', 5, toastId); })
.finally(function () { resetCaptcha('bind_phone', form); });
});
form.addEventListener('submit', function (e) {
e.preventDefault();
@@ -1046,33 +1124,12 @@
submitBtn.disabled = true;
submitBtn.textContent = '绑定中...';
}
function collectCaptcha(formEl) {
var out = {};
[
'captcha_verified', 'tencent_ticket', 'tencent_randstr', 'tencent_sid',
'cf_turnstile_response', 'geetest_lot_number', 'geetest_captcha_output',
'geetest_pass_token', 'geetest_gen_time', 'aliyun_captcha_verify_param'
].forEach(function (name) {
var el = formEl.querySelector('[name="' + name + '"]');
if (el && el.value) out[name] = el.value;
});
return out;
}
function resetCaptcha() {
try {
var store = window.AKSRTCaptchaState && window.AKSRTCaptchaState.scenes;
if (store && store.bind_phone && typeof store.bind_phone.reset === 'function') {
store.bind_phone.reset();
}
} catch (err) {}
}
function doBind() {
var toastId = 'aksrt-settings-phone-bind-toast';
notyf('正在绑定手机…', 'load', '', toastId);
var captcha = collectCaptcha(form);
var restRoot = (window.aksrtData && aksrtData.apiUrl) || '/wp-json/aksrt/v1';
var nonce = (window.aksrtData && aksrtData.restNonce) || '';
fetch(String(restRoot).replace(/\/$/, '') + '/user/phone', {
function doBind(captcha) {
var toastId = 'aksrt-settings-phone-bind-toast';
notyf('正在绑定手机…', 'load', '', toastId);
var restRoot = (window.aksrtData && aksrtData.apiUrl) || '/wp-json/aksrt/v1';
var nonce = (window.aksrtData && aksrtData.restNonce) || '';
return fetch(String(restRoot).replace(/\/$/, '') + '/user/phone', {
method: 'POST',
credentials: 'same-origin',
headers: {
@@ -1088,28 +1145,32 @@
setTimeout(function () { window.location.reload(); }, 600);
return;
}
resetCaptcha();
notyf((res.json && (res.json.message || res.json.error)) || '绑定失败', 'danger', 5, toastId);
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = '确认绑定';
}
})
.catch(function () {
resetCaptcha();
notyf('网络错误', 'danger', 5, toastId);
}
})
.catch(function () {
notyf('网络错误', 'danger', 5, toastId);
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = '确认绑定';
}
});
}
var store = window.AKSRTCaptchaState && window.AKSRTCaptchaState.scenes;
if (store && store.bind_phone && typeof store.bind_phone.verify === 'function') {
store.bind_phone.verify(doBind);
} else {
doBind();
}
});
}
runCaptcha('bind_phone', form).then(function(captchaPayload) {
if (captchaPayload === null) {
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = '确认绑定';
}
return null;
}
return doBind(captchaPayload);
}).finally(function() {
resetCaptcha('bind_phone', form);
});
});
}
+12 -51
View File
@@ -101,60 +101,14 @@
return document.querySelector('[data-signin-captcha]');
}
function collectCaptcha() {
var mount = captchaMount();
if (!mount) return {};
var payload = {};
var keys = [
'captcha_verified', 'cf_turnstile_response',
'tencent_ticket', 'tencent_randstr', 'tencent_sid',
'geetest_lot_number', 'geetest_captcha_output', 'geetest_pass_token', 'geetest_gen_time',
'aliyun_captcha_verify_param'
];
keys.forEach(function (key) {
var input = mount.querySelector('input[name="' + key + '"]');
if (input && input.value !== '') payload[key] = input.value;
});
return payload;
}
function resetCaptcha() {
var state = window.AKSRTCaptchaState;
var scene = state && state.scenes && state.scenes.signin;
if (scene && typeof scene.reset === 'function') scene.reset();
}
function triggerCaptcha(onSuccess, onFail) {
var mount = captchaMount();
var state = window.AKSRTCaptchaState;
var scene = state && state.scenes && state.scenes.signin;
if (scene && typeof scene.verify === 'function') {
scene.verify(function (ok) {
if (ok === false) {
onFail('已取消验证');
return;
}
onSuccess();
});
return;
}
if (mount) {
onFail('人机验证加载中,请稍候再试');
return;
}
onSuccess();
}
function doSignin() {
function doSignin(captchaPayload) {
notyf('正在签到…', 'load', '', 'aksrt-balance-signin-toast');
api('/signin', {
method: 'POST',
body: JSON.stringify({ captcha: collectCaptcha() })
body: JSON.stringify({ captcha: captchaPayload || {} })
}).then(function (result) {
signinBusy = false;
if (!result.ok) {
resetCaptcha();
notyf((result.json && result.json.message) || '签到失败', 'danger', 5, 'aksrt-balance-signin-toast');
setButtonState('idle');
return;
@@ -166,6 +120,8 @@
});
setButtonState('done');
refresh();
}).finally(function () {
if (window.AKSRTCaptcha) window.AKSRTCaptcha.reset('signin', captchaMount());
});
}
@@ -176,10 +132,15 @@
signinBusy = true;
setButtonState('busy');
triggerCaptcha(doSignin, function (message) {
var captcha = window.AKSRTCaptcha;
var mount = captchaMount();
(captcha ? captcha.run('signin', mount) : Promise.resolve({})).then(function (payload) {
if (payload === null) throw new Error('已取消验证');
doSignin(payload);
}).catch(function (error) {
signinBusy = false;
resetCaptcha();
if (message) notyf(message, 'danger');
if (captcha) captcha.reset('signin', mount);
notyf((error && error.message) || '人机验证加载中,请稍候再试', 'danger');
setButtonState('idle');
});
});
@@ -48,7 +48,7 @@ foreach ((array) ($oauth_data['available'] ?? []) as $binding) {
];
}
?>
<div class="settings-section">
<div class="settings-section" data-aksrt-captcha-scope>
<div class="settings-section__header">
<span class="settings-icon"><i class="fa fas fa-link" aria-hidden="true"></i></span>
<h3 class="widget-title settings-section__title"><?php esc_html_e('社交账号绑定', 'aksrt'); ?></h3>
@@ -56,6 +56,9 @@ foreach ((array) ($oauth_data['available'] ?? []) as $binding) {
<?php if (!empty($binding_rows)) : ?>
<p class="settings-hint settings-hint--top"><?php esc_html_e('点击下方按钮管理第三方账号绑定状态:', 'aksrt'); ?></p>
<?php if (function_exists('aksrt_render_captcha')) : ?>
<div class="binding-captcha" data-aksrt-oauth-bind-captcha><?php aksrt_render_captcha('bind'); ?></div>
<?php endif; ?>
<div class="binding-list">
<?php foreach ($binding_rows as $binding) : ?>
<?php if (!empty($binding['is_bound'])) : ?>
@@ -73,7 +76,7 @@ foreach ((array) ($oauth_data['available'] ?? []) as $binding) {
</button>
</div>
<?php else : ?>
<a href="<?php echo esc_url($binding['url']); ?>" class="binding-item binding-item--linkable" target="_self" data-provider="<?php echo esc_attr($binding['key']); ?>">
<a href="<?php echo esc_url($binding['url']); ?>" class="binding-item binding-item--linkable" target="_self" data-provider="<?php echo esc_attr($binding['key']); ?>" data-bind-start-url="<?php echo esc_url($binding['url']); ?>">
<span class="binding-item__icon"><?php echo $binding['icon']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
<span class="binding-item__meta">
<span class="binding-status binding-status--idle"><?php esc_html_e('未绑定', 'aksrt'); ?></span>
+467 -158
View File
@@ -6,7 +6,7 @@
* 闭环契约:
* aksrt_get_captcha_config() -> aksrt_is_captcha_enabled_for(scene)
* -> aksrt_render_captcha(scene) -> [data-aksrt-captcha-config] 挂载点
* -> captcha-front.js(public bundle)-> 客户端 AKSRTCaptchaState + token
* -> captcha-front.js(public bundle)-> 客户端 AKSRTCaptcha + token
* -> 业务入口 aksrt_require_captcha(scene, $payload) -> proceed/fail。
*
* 仅支持云厂商:tencent / aliyun / cloudflare / geetest。
@@ -17,11 +17,345 @@
defined('ABSPATH') || exit;
const AKSRT_CAPTCHA_BROWSER_COOKIE = 'aksrt_captcha_browser';
const AKSRT_CAPTCHA_CHALLENGE_PREFIX = 'aksrt_captcha_challenge_';
const AKSRT_CAPTCHA_PROOF_PREFIX = 'aksrt_captcha_proof_';
const AKSRT_CAPTCHA_CHALLENGE_TTL = 10 * MINUTE_IN_SECONDS;
const AKSRT_CAPTCHA_PROOF_TTL = 5 * MINUTE_IN_SECONDS;
function aksrt_captcha_browser_id(): string {
$value = isset($_COOKIE[AKSRT_CAPTCHA_BROWSER_COOKIE])
? sanitize_text_field(wp_unslash((string) $_COOKIE[AKSRT_CAPTCHA_BROWSER_COOKIE]))
: '';
if (preg_match('/^[A-Za-z0-9_-]{32,80}$/', $value) === 1) {
return $value;
}
$value = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
if (!headers_sent()) {
setcookie(AKSRT_CAPTCHA_BROWSER_COOKIE, $value, [
'expires' => time() + MONTH_IN_SECONDS,
'path' => defined('COOKIEPATH') && COOKIEPATH ? COOKIEPATH : '/',
'domain' => defined('COOKIE_DOMAIN') ? COOKIE_DOMAIN : '',
'secure' => is_ssl(),
'httponly' => true,
'samesite' => 'Lax',
]);
}
$_COOKIE[AKSRT_CAPTCHA_BROWSER_COOKIE] = $value;
return $value;
}
function aksrt_captcha_challenge_key(string $token): string {
return AKSRT_CAPTCHA_CHALLENGE_PREFIX . hash('sha256', trim($token));
}
function aksrt_captcha_challenge_material(string $scene, string $provider, string $nonce, int $timestamp): string {
$host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST));
return sanitize_key($scene) . '|' . sanitize_key($provider) . '|'
. hash('sha256', aksrt_captcha_browser_id()) . '|' . $host . '|' . $timestamp . '|' . $nonce;
}
function aksrt_captcha_issue_challenge(string $scene, string $provider): string {
$nonce = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
$timestamp = time();
$material = aksrt_captcha_challenge_material($scene, $provider, $nonce, $timestamp);
$signature = hash_hmac('sha256', $material, wp_salt('auth'));
return $nonce . '.' . $timestamp . '.' . $signature;
}
function aksrt_captcha_challenge_valid(string $token, string $scene, string $provider): bool {
$parts = explode('.', trim($token));
if (count($parts) !== 3) {
return false;
}
[$nonce, $raw_timestamp, $signature] = $parts;
if (preg_match('/^[A-Za-z0-9_-]{32,80}$/', $nonce) !== 1 || preg_match('/^\d{10}$/', $raw_timestamp) !== 1 || preg_match('/^[a-f0-9]{64}$/', $signature) !== 1) {
return false;
}
$timestamp = (int) $raw_timestamp;
if ($timestamp > time() + 60 || $timestamp < time() - AKSRT_CAPTCHA_CHALLENGE_TTL) {
return false;
}
$expected = hash_hmac(
'sha256',
aksrt_captcha_challenge_material($scene, $provider, $nonce, $timestamp),
wp_salt('auth')
);
return hash_equals($expected, $signature);
}
function aksrt_captcha_validate_challenge(string $token, string $scene, string $provider, bool $claim = false): bool {
if (!aksrt_captcha_challenge_valid($token, $scene, $provider)) {
return false;
}
$key = aksrt_captcha_challenge_key($token);
if (!$claim) {
$current = get_option($key, null);
if (is_numeric($current) && (int) $current < time()) {
delete_option($key);
return true;
}
return $current === null;
}
$current = get_option($key, null);
if (is_numeric($current) && (int) $current < time()) {
delete_option($key);
}
return add_option($key, time() + AKSRT_CAPTCHA_CHALLENGE_TTL, '', false);
}
function aksrt_captcha_challenge_release(string $token): void {
delete_option(aksrt_captcha_challenge_key($token));
}
function aksrt_captcha_rate_limited(string $scope, string $subject, int $limit = 30, int $ttl = 60): bool {
$key = 'aksrt_captcha_rl_' . md5($scope . '|' . strtolower($subject));
$count = (int) get_transient($key);
if ($count >= $limit) {
return true;
}
set_transient($key, $count + 1, $ttl);
return false;
}
function aksrt_captcha_proof_key(string $token): string {
return AKSRT_CAPTCHA_PROOF_PREFIX . hash('sha256', trim($token));
}
function aksrt_captcha_issue_proof(string $scene, string $provider): string {
$token = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
$row = [
'scene' => sanitize_key($scene),
'provider' => sanitize_key($provider),
'browser_hash' => hash('sha256', aksrt_captcha_browser_id()),
'host' => strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST)),
'expires_at' => time() + AKSRT_CAPTCHA_PROOF_TTL,
];
if (!add_option(aksrt_captcha_proof_key($token), $row, '', false)) {
throw new \RuntimeException('captcha_proof_issue_failed');
}
return $token;
}
function aksrt_captcha_consume_proof(string $token, string $scene, string $provider): bool {
if ($token === '') {
return false;
}
$key = aksrt_captcha_proof_key($token);
$row = get_option($key, null);
$host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST));
$valid = is_array($row)
&& (int) ($row['expires_at'] ?? 0) >= time()
&& hash_equals(sanitize_key($scene), (string) ($row['scene'] ?? ''))
&& hash_equals(sanitize_key($provider), (string) ($row['provider'] ?? ''))
&& hash_equals(hash('sha256', aksrt_captcha_browser_id()), (string) ($row['browser_hash'] ?? ''))
&& $host !== ''
&& hash_equals($host, (string) ($row['host'] ?? ''));
if (!$valid) {
if (is_array($row) && (int) ($row['expires_at'] ?? 0) < time()) {
delete_option($key);
}
return false;
}
global $wpdb;
$deleted = $wpdb->query($wpdb->prepare(
"DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s",
$key,
maybe_serialize($row)
));
if ($deleted !== 1) {
return false;
}
wp_cache_delete($key, 'options');
return true;
}
function aksrt_captcha_cleanup_records(int $limit = 200): int {
global $wpdb;
$prefixes = [AKSRT_CAPTCHA_CHALLENGE_PREFIX, AKSRT_CAPTCHA_PROOF_PREFIX];
$removed = 0;
foreach ($prefixes as $prefix) {
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s LIMIT %d",
$wpdb->esc_like($prefix) . '%',
max(1, min(1000, $limit))
), ARRAY_A);
foreach (is_array($rows) ? $rows : [] as $row) {
$value = maybe_unserialize($row['option_value'] ?? '');
$expires_at = is_array($value) ? (int) ($value['expires_at'] ?? 0) : (int) $value;
if ($expires_at > 0 && $expires_at < time() && delete_option((string) $row['option_name'])) {
$removed++;
}
}
}
return $removed;
}
add_action('init', static function (): void {
if (wp_rand(1, 100) === 1) {
aksrt_captcha_cleanup_records();
}
}, 30);
add_action('rest_api_init', static function (): void {
register_rest_route('aksrt/v1', '/captcha/challenge', [
'methods' => 'POST',
'permission_callback' => '__return_true',
'callback' => static function (\WP_REST_Request $request) {
$data = $request->get_json_params();
$scene = sanitize_key((string) (is_array($data) ? ($data['scene'] ?? '') : ''));
if (!aksrt_is_captcha_enabled_for($scene)) {
return new \WP_Error('aksrt_captcha_scene', __('人机验证场景未启用', 'aksrt'), ['status' => 400]);
}
$cfg = aksrt_get_captcha_config();
$provider = $cfg['active_provider'];
if ($provider === '' || !aksrt_captcha_provider_is_configured($provider, $cfg[$provider] ?? [])) {
return new \WP_Error('aksrt_captcha_config', __('人机验证配置不完整', 'aksrt'), ['status' => 503]);
}
if (aksrt_captcha_rate_limited('challenge:' . $scene, aksrt_get_client_ip(), 20, MINUTE_IN_SECONDS)) {
return new \WP_Error('aksrt_captcha_rate', __('请求过于频繁,请稍后再试', 'aksrt'), ['status' => 429]);
}
return new \WP_REST_Response([
'success' => true,
'data' => ['challenge' => aksrt_captcha_issue_challenge($scene, $provider)],
], 200);
},
]);
register_rest_route('aksrt/v1', '/captcha/exchange', [
'methods' => 'POST',
'permission_callback' => '__return_true',
'callback' => static function (\WP_REST_Request $request) {
$data = $request->get_json_params();
$data = is_array($data) ? $data : [];
$scene = sanitize_key((string) ($data['scene'] ?? ''));
if (!aksrt_is_captcha_enabled_for($scene)) {
return new \WP_Error('aksrt_captcha_scene', __('人机验证场景未启用', 'aksrt'), ['status' => 400]);
}
$cfg = aksrt_get_captcha_config();
$provider = $cfg['active_provider'];
$pcfg = $cfg[$provider] ?? [];
if ($provider === '' || !aksrt_captcha_provider_is_configured($provider, $pcfg)) {
return new \WP_Error('aksrt_captcha_config', __('人机验证配置不完整', 'aksrt'), ['status' => 503]);
}
$challenge = sanitize_text_field((string) ($data['aksrt_captcha_challenge'] ?? ''));
if (!aksrt_captcha_validate_challenge($challenge, $scene, $provider, false)) {
return new \WP_Error('aksrt_captcha_challenge', __('人机验证会话无效或已过期,请重试', 'aksrt'), ['status' => 400]);
}
// 原子占用 challenge:并发请求只有首个能进入厂商验签,其余直接 409,避免同一票据放大厂商调用。
if (!aksrt_captcha_validate_challenge($challenge, $scene, $provider, true)) {
return new \WP_Error('aksrt_captcha_replayed', __('人机验证会话已在验证中,请重试', 'aksrt'), ['status' => 409]);
}
$result = aksrt_verify_captcha_provider($provider, $pcfg, $data, aksrt_get_client_ip(), $scene);
if (is_wp_error($result)) {
aksrt_captcha_challenge_release($challenge);
return $result;
}
try {
$proof = aksrt_captcha_issue_proof($scene, $provider);
} catch (\Throwable $e) {
aksrt_captcha_challenge_release($challenge);
return new \WP_Error('aksrt_captcha_proof', __('无法创建人机验证凭证,请重试', 'aksrt'), ['status' => 500]);
}
return new \WP_REST_Response([
'success' => true,
'data' => ['proof' => $proof],
], 200);
},
]);
});
function aksrt_captcha_provider_metadata(): array {
return [
'tencent' => [
'label' => __('腾讯云 Captcha', 'aksrt'),
'fields' => [
'secret_id' => ['default' => '', 'required' => true],
'secret_key' => ['default' => '', 'required' => true, 'secret' => true],
'app_id' => ['default' => '', 'required' => true],
'app_secret_key' => ['default' => '', 'required' => true, 'secret' => true],
],
],
'aliyun' => [
'label' => __('阿里云 CAPTCHA 2.0', 'aksrt'),
'fields' => [
'region' => ['default' => 'cn', 'required' => true, 'values' => ['cn', 'sgp']],
'prefix' => ['default' => '', 'required' => true],
'scene_id' => ['default' => '', 'required' => true],
'access_key_id' => ['default' => '', 'required' => true],
'access_key_secret' => ['default' => '', 'required' => true, 'secret' => true],
],
],
'cloudflare' => [
'label' => __('Cloudflare Turnstile', 'aksrt'),
'fields' => [
'site_key' => ['default' => '', 'required' => true],
'secret_key' => ['default' => '', 'required' => true, 'secret' => true],
'theme' => ['default' => 'auto', 'values' => ['auto', 'light', 'dark']],
'size' => ['default' => 'normal', 'values' => ['normal', 'compact']],
],
],
'geetest' => [
'label' => __('极验 GeeTest', 'aksrt'),
'fields' => [
'captcha_id' => ['default' => '', 'required' => true],
'captcha_key' => ['default' => '', 'required' => true, 'secret' => true],
'product' => ['default' => 'bind', 'values' => ['bind', 'float', 'popup']],
],
],
];
}
/**
* 支持的验证服务商。
*/
function aksrt_captcha_providers(): array {
return ['tencent', 'aliyun', 'cloudflare', 'geetest'];
return array_keys(aksrt_captcha_provider_metadata());
}
/**
* 服务商字段白名单与默认值。
*/
function aksrt_captcha_provider_defaults(string $provider): array {
$fields = aksrt_captcha_provider_metadata()[$provider]['fields'] ?? [];
$defaults = [];
foreach ($fields as $key => $meta) {
$defaults[$key] = $meta['default'] ?? '';
}
return $defaults;
}
/**
* 服务商 secret 字段。
*/
function aksrt_captcha_provider_secret_fields(string $provider): array {
$fields = aksrt_captcha_provider_metadata()[$provider]['fields'] ?? [];
return array_values(array_keys(array_filter($fields, static fn(array $meta): bool => !empty($meta['secret']))));
}
/**
* 按集中元数据规范化服务商配置,丢弃未知键。
*/
function aksrt_captcha_sanitize_provider_config(string $provider, $raw): array {
$meta = aksrt_captcha_provider_metadata()[$provider]['fields'] ?? [];
if ($meta === []) {
return [];
}
$raw = is_array($raw) ? $raw : [];
$clean = aksrt_captcha_provider_defaults($provider);
foreach ($meta as $key => $field) {
if (!array_key_exists($key, $raw) || is_array($raw[$key]) || is_object($raw[$key])) {
continue;
}
$value = sanitize_text_field((string) $raw[$key]);
if (!empty($field['values']) && !in_array($value, $field['values'], true)) {
continue;
}
$clean[$key] = $value;
}
return $clean;
}
/**
@@ -47,17 +381,15 @@ function aksrt_captcha_scenes(): array {
* 默认配置结构。
*/
function aksrt_captcha_default_config(): array {
$default_scenes = array_values(array_diff(array_keys(aksrt_captcha_scenes()), ['front_submit']));
return [
'enabled' => false,
'active_provider' => '',
// Positive list: checked scenes require captcha. Front submissions stay opt-in.
'enabled_scenarios' => $default_scenes,
'tencent' => [],
'aliyun' => [],
'cloudflare' => [],
'geetest' => [],
$config = [
'enabled' => false,
'active_provider' => '',
'enabled_scenarios' => [],
];
foreach (aksrt_captcha_providers() as $provider) {
$config[$provider] = aksrt_captcha_provider_defaults($provider);
}
return $config;
}
/**
@@ -107,7 +439,7 @@ function aksrt_get_captcha_config(): array {
'enabled_scenarios' => $enabled,
];
foreach (aksrt_captcha_providers() as $p) {
$result[$p] = isset($cfg[$p]) && is_array($cfg[$p]) ? $cfg[$p] : [];
$result[$p] = aksrt_captcha_sanitize_provider_config($p, $cfg[$p] ?? []);
}
$cache = $result;
@@ -125,13 +457,8 @@ function aksrt_captcha_config_reset(): void {
* 各服务商必填字段。
*/
function aksrt_captcha_required_fields(string $provider): array {
$map = [
'tencent' => ['secret_id', 'secret_key', 'app_id', 'app_secret_key'],
'aliyun' => ['region', 'prefix', 'scene_id', 'access_key_id', 'access_key_secret'],
'cloudflare' => ['site_key', 'secret_key'],
'geetest' => ['captcha_id', 'captcha_key'],
];
return $map[$provider] ?? [];
$fields = aksrt_captcha_provider_metadata()[$provider]['fields'] ?? [];
return array_values(array_keys(array_filter($fields, static fn(array $meta): bool => !empty($meta['required']))));
}
/**
@@ -166,13 +493,7 @@ function aksrt_is_captcha_enabled_for($scene): bool {
}
$cfg = aksrt_get_captcha_config();
if (empty($cfg['enabled']) || $cfg['active_provider'] === '') {
return false;
}
$provider = $cfg['active_provider'];
$pcfg = $cfg[$provider] ?? [];
if (!aksrt_captcha_provider_is_configured($provider, $pcfg)) {
if (empty($cfg['enabled'])) {
return false;
}
@@ -180,46 +501,28 @@ function aksrt_is_captcha_enabled_for($scene): bool {
}
/**
* 场景验证通过后的服务端放行时长(秒),0 表示不启用。
*
* 下载页多个卡片共享一次验证结果(前端会把 token 同步到兄弟挂载点),
* 而厂商 token 是一次性的,因此验证通过后由服务端记录短时放行,
* 后续下载请求直接命中放行,避免票据被判定重复使用。
*/
function aksrt_captcha_pass_ttl(string $scene): int {
return $scene === 'download' ? 10 * MINUTE_IN_SECONDS : 0;
}
/**
* 服务端放行记录的 transient key:场景 + 用户 + IP + UA。
*/
function aksrt_captcha_pass_key(string $scene): string {
$ua = isset($_SERVER['HTTP_USER_AGENT']) ? (string) $_SERVER['HTTP_USER_AGENT'] : '';
return 'aksrt_captcha_pass_' . md5($scene . '|' . get_current_user_id() . '|' . aksrt_get_client_ip() . '|' . md5($ua));
}
/**
* 业务入口统一调用:未启用放行;启用则校验。
* 业务入口统一调用:仅显式关闭或未勾选场景放行;启用场景 fail-closed。
*
* @param string $scene 场景
* @param array $payload 客户端提交的验证字段
* @return true|string 成功返回 true,失败返回错误信息字符串
* @return true|\WP_Error 成功返回 true,失败返回 WP_Error
*/
function aksrt_require_captcha(string $scene, array $payload = []) {
if (!aksrt_is_captcha_enabled_for($scene)) {
return true;
}
$ttl = aksrt_captcha_pass_ttl($scene);
if ($ttl > 0 && get_transient(aksrt_captcha_pass_key($scene))) {
return true;
$cfg = aksrt_get_captcha_config();
$provider = $cfg['active_provider'];
if ($provider === '' || !aksrt_captcha_provider_is_configured($provider, $cfg[$provider] ?? [])) {
return aksrt_captcha_error('aksrt_captcha_config', __('人机验证配置不完整,请联系站点管理员', 'aksrt'));
}
$result = aksrt_verify_captcha($payload, $scene);
if ($result === true && $ttl > 0) {
set_transient(aksrt_captcha_pass_key($scene), time(), $ttl);
$proof = sanitize_text_field((string) ($payload['aksrt_captcha_proof'] ?? ''));
if (!aksrt_captcha_consume_proof($proof, $scene, $provider)) {
return aksrt_captcha_error('aksrt_captcha_proof', __('人机验证凭证无效、已过期或已使用,请重试', 'aksrt'));
}
return $result;
return true;
}
/**
@@ -241,7 +544,7 @@ function aksrt_get_captcha_status_summary(): array {
}
/**
* 凭据未配齐时的后台警告(前台保持放行,验证码不是准入门槛)。
* 凭据未配齐时的后台警告(已启用场景会 fail-closed,必须补全配置)。
*/
add_action('admin_notices', static function () {
if (!current_user_can('manage_options')) {
@@ -257,7 +560,7 @@ add_action('admin_notices', static function () {
}
echo '<div class="notice notice-error"><p>' . esc_html(sprintf(
/* translators: %s: captcha provider key */
__('人机验证服务商「%s」已启用但凭据未配齐,前台人机验证未生效。请到「功能&权限 → 人机验证」补全配置。', 'aksrt'),
__('人机验证服务商「%s」已启用但凭据未配齐,已启用场景将拒绝请求。请到「功能&权限 → 人机验证」补全配置。', 'aksrt'),
$provider
)) . '</p></div>';
});
@@ -321,7 +624,7 @@ function aksrt_tencent_tc3_sign($secret_id, $secret_key, $service, $timestamp, $
$secret_date = hash_hmac('sha256', $date, 'TC3' . $secret_key, true);
$secret_service = hash_hmac('sha256', $service, $secret_date, true);
$secret_signing = hash_hmac('sha256', 'tc3_request', $secret_signing, true);
$secret_signing = hash_hmac('sha256', 'tc3_request', $secret_service, true);
$signature = hash_hmac('sha256', $string_to_sign, $secret_signing);
$authorization = 'TC3-HMAC-SHA256 '
@@ -364,67 +667,79 @@ function aksrt_captcha_log(string $provider, string $detail): void {
error_log('[AKSRT Captcha] ' . $provider . ': ' . $detail);
}
function aksrt_captcha_error(string $code, string $message, array $data = []): \WP_Error {
return new \WP_Error($code, $message, $data);
}
/**
* 校验客户端提交的人机验证字段。
* Require a successful 2xx JSON response from a captcha provider.
*
* @param array $post_data 客户端字段(captcha_verified + 厂商 token)
* @param string $scene 触发场景(用于 Cloudflare cdata 绑定校验)
* @return true|string 成功返回 true,失败返回错误信息
* @return array|\WP_Error
*/
function aksrt_verify_captcha($post_data, string $scene = '') {
$cfg = aksrt_get_captcha_config();
if (empty($cfg['enabled']) || $cfg['active_provider'] === '') {
return true;
function aksrt_captcha_remote_json(string $provider, $response) {
if (is_wp_error($response)) {
aksrt_captcha_log($provider, $response->get_error_message());
return aksrt_captcha_error('aksrt_captcha_transport', __('人机验证服务暂不可用,请稍后重试', 'aksrt'));
}
$status = (int) wp_remote_retrieve_response_code($response);
if ($status < 200 || $status >= 300) {
aksrt_captcha_log($provider, 'HTTP ' . $status . ': ' . substr((string) wp_remote_retrieve_body($response), 0, 500));
return aksrt_captcha_error('aksrt_captcha_http', __('人机验证服务暂不可用,请稍后重试', 'aksrt'), ['status' => $status]);
}
$body = json_decode(wp_remote_retrieve_body($response), true);
if (!is_array($body)) {
aksrt_captcha_log($provider, 'unparsable response');
return aksrt_captcha_error('aksrt_captcha_response', __('人机验证服务暂不可用,请稍后重试', 'aksrt'));
}
return $body;
}
$provider = $cfg['active_provider'];
$pcfg = $cfg[$provider] ?? [];
$client_ip = aksrt_get_client_ip();
// 客户端验证字段(前端写入 captcha_verified=1 表示用户已完成交互)
if (($post_data['captcha_verified'] ?? '') !== '1') {
return __('人机验证失败', 'aksrt');
/**
* Execute one provider verification with an explicit configuration.
*
* @return true|\WP_Error
*/
function aksrt_verify_captcha_provider(string $provider, array $pcfg, array $post_data, string $client_ip, string $scene = '', string $action = 'aksrt-verify') {
if (!in_array($provider, aksrt_captcha_providers(), true)) {
return aksrt_captcha_error('aksrt_captcha_provider', __('未知的人机验证服务商', 'aksrt'));
}
if (!aksrt_captcha_provider_is_configured($provider, $pcfg)) {
return aksrt_captcha_error('aksrt_captcha_config', __('验证码配置不完整', 'aksrt'));
}
switch ($provider) {
case 'tencent':
$result = aksrt_verify_tencent($pcfg, $post_data, $client_ip);
break;
return aksrt_verify_tencent($pcfg, $post_data, $client_ip);
case 'cloudflare':
$result = aksrt_verify_cloudflare($pcfg, $post_data, $client_ip, $scene);
break;
return aksrt_verify_cloudflare($pcfg, $post_data, $client_ip, $scene, $action);
case 'geetest':
$result = aksrt_verify_geetest($pcfg, $post_data);
break;
return aksrt_verify_geetest($pcfg, $post_data);
case 'aliyun':
$result = aksrt_verify_aliyun($pcfg, $post_data);
break;
default:
return true;
return aksrt_verify_aliyun($pcfg, $post_data);
}
return $result === true ? true : ($result ?: __('人机验证失败', 'aksrt'));
return aksrt_captcha_error('aksrt_captcha_provider', __('未知的人机验证服务商', 'aksrt'));
}
/**
* 腾讯云 Captcha 2.0(DescribeCaptchaResult + TC3-HMAC-SHA256)。
*
* @return true|string
* @return true|\WP_Error
*/
function aksrt_verify_tencent(array $pcfg, array $post_data, string $client_ip) {
$ticket = (string) ($post_data['tencent_ticket'] ?? '');
$randstr = (string) ($post_data['tencent_randstr'] ?? '');
if ($ticket === '' || $randstr === '') {
return __('缺少验证参数', 'aksrt');
return aksrt_captcha_error('aksrt_captcha_missing', __('缺少验证参数', 'aksrt'));
}
if (str_starts_with($ticket, 'trerror_')) {
return __('验证码 SDK 异常,请刷新页面重试(容灾票据)', 'aksrt');
return aksrt_captcha_error('aksrt_captcha_disaster_ticket', __('验证码 SDK 异常,请刷新页面重试(容灾票据)', 'aksrt'));
}
$secret_id = (string) ($pcfg['secret_id'] ?? '');
$secret_key = (string) ($pcfg['secret_key'] ?? '');
if ($secret_id === '' || $secret_key === '') {
return __('验证码配置不完整:缺少云 API 密钥 (SecretId/SecretKey)', 'aksrt');
return aksrt_captcha_error('aksrt_captcha_config', __('验证码配置不完整:缺少云 API 密钥 (SecretId/SecretKey)', 'aksrt'));
}
$timestamp = time();
@@ -445,20 +760,14 @@ function aksrt_verify_tencent(array $pcfg, array $post_data, string $client_ip)
'headers' => $headers,
'body' => $payload,
]);
if (is_wp_error($resp)) {
aksrt_captcha_log('tencent', $resp->get_error_message());
return __('人机验证服务暂不可用,请稍后重试', 'aksrt');
}
$body = json_decode(wp_remote_retrieve_body($resp), true);
if (!is_array($body)) {
aksrt_captcha_log('tencent', 'unparsable response');
return __('人机验证服务暂不可用,请稍后重试', 'aksrt');
$body = aksrt_captcha_remote_json('tencent', $resp);
if (is_wp_error($body)) {
return $body;
}
if (isset($body['Response']['Error'])) {
$err = $body['Response']['Error'];
aksrt_captcha_log('tencent', ($err['Code'] ?? 'unknown') . ': ' . ($err['Message'] ?? ''));
return __('人机验证服务暂不可用,请稍后重试', 'aksrt');
return aksrt_captcha_error('aksrt_captcha_provider_error', __('人机验证服务暂不可用,请稍后重试', 'aksrt'));
}
$resp_data = $body['Response'] ?? [];
@@ -477,60 +786,62 @@ function aksrt_verify_tencent(array $pcfg, array $post_data, string $client_ip)
21 => '票据校验异常(风控拦截)',
100 => '参数校验失败(AppId/SecretKey/Ticket 不匹配)',
];
$msg = $resp_data['CaptchaMsg'] ?? ($messages[$captcha_code] ?? '未知错误');
return '腾讯验证失败: ' . $msg . " (CaptchaCode: {$captcha_code})";
$msg = (string) ($resp_data['CaptchaMsg'] ?? ($messages[$captcha_code] ?? '未知错误'));
aksrt_captcha_log('tencent', $msg . " (CaptchaCode: {$captcha_code})");
return aksrt_captcha_error('aksrt_captcha_rejected', __('人机验证未通过,请重试', 'aksrt'));
}
/**
* Cloudflare Turnstile siteverify。校验 action 与 cdata(场景)绑定,防 token 转用。
*
* @return true|string
* @return true|\WP_Error
*/
function aksrt_verify_cloudflare(array $pcfg, array $post_data, string $client_ip, string $scene = '') {
function aksrt_verify_cloudflare(array $pcfg, array $post_data, string $client_ip, string $scene = '', string $action = 'aksrt-verify') {
$token = (string) ($post_data['cf_turnstile_response'] ?? '');
if ($token === '' || strlen($token) < 10) {
return __('缺少验证参数', 'aksrt');
return aksrt_captcha_error('aksrt_captcha_missing', __('缺少验证参数', 'aksrt'));
}
$secret = (string) ($pcfg['secret_key'] ?? '');
$bodies = [
['secret' => $secret, 'response' => $token],
['secret' => $secret, 'response' => $token, 'remoteip' => $client_ip],
];
$body = [];
foreach ($bodies as $attempt) {
$resp = wp_remote_post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
'timeout' => 5,
'body' => $attempt,
]);
if (is_wp_error($resp)) {
continue;
}
$body = json_decode(wp_remote_retrieve_body($resp), true);
if (!empty($body['success'])) {
if (isset($body['action']) && (string) $body['action'] !== 'aksrt-verify') {
aksrt_captcha_log('cloudflare', 'action mismatch: ' . (string) $body['action']);
return __('人机验证未通过,请重试', 'aksrt');
}
if ($scene !== '' && isset($body['cdata']) && (string) $body['cdata'] !== $scene) {
aksrt_captcha_log('cloudflare', 'cdata mismatch: ' . (string) $body['cdata'] . ' != ' . $scene);
return __('人机验证未通过,请重试', 'aksrt');
}
return true;
}
$resp = wp_remote_post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
'timeout' => 5,
'body' => [
'secret' => (string) ($pcfg['secret_key'] ?? ''),
'response' => $token,
'remoteip' => $client_ip,
],
]);
$body = aksrt_captcha_remote_json('cloudflare', $resp);
if (is_wp_error($body)) {
return $body;
}
if (empty($body['success'])) {
aksrt_captcha_log('cloudflare', 'siteverify failed: ' . implode(', ', (array) ($body['error-codes'] ?? [])));
return aksrt_captcha_error('aksrt_captcha_rejected', __('人机验证未通过,请重试', 'aksrt'));
}
if (is_array($body) && isset($body['error-codes'])) {
aksrt_captcha_log('cloudflare', 'siteverify failed: ' . implode(', ', (array) $body['error-codes']));
if (!array_key_exists('action', $body) || (string) $body['action'] !== $action) {
aksrt_captcha_log('cloudflare', 'action mismatch: ' . (string) ($body['action'] ?? '<missing>') . ' != ' . $action);
return aksrt_captcha_error('aksrt_captcha_action', __('人机验证未通过,请重试', 'aksrt'));
}
return __('人机验证未通过,请重试', 'aksrt');
if (!array_key_exists('cdata', $body) || (string) $body['cdata'] !== $scene) {
aksrt_captcha_log('cloudflare', 'cdata mismatch: ' . (string) ($body['cdata'] ?? '<missing>') . ' != ' . $scene);
return aksrt_captcha_error('aksrt_captcha_cdata', __('人机验证未通过,请重试', 'aksrt'));
}
$expected_host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST));
$response_host = strtolower(trim((string) ($body['hostname'] ?? '')));
if ($expected_host === '' || $response_host === '' || !hash_equals($expected_host, $response_host)) {
aksrt_captcha_log('cloudflare', 'hostname mismatch: ' . ($response_host ?: '<missing>') . ' != ' . ($expected_host ?: '<missing>'));
return aksrt_captcha_error('aksrt_captcha_hostname', __('人机验证未通过,请重试', 'aksrt'));
}
return true;
}
/**
* 极验 GeeTest v4 validate。
*
* @return true|string
* @return true|\WP_Error
*/
function aksrt_verify_geetest(array $pcfg, array $post_data) {
$lot_number = (string) ($post_data['geetest_lot_number'] ?? '');
@@ -538,7 +849,7 @@ function aksrt_verify_geetest(array $pcfg, array $post_data) {
$pass_token = (string) ($post_data['geetest_pass_token'] ?? '');
$gen_time = (string) ($post_data['geetest_gen_time'] ?? '');
if ($lot_number === '' || $captcha_output === '' || $pass_token === '') {
return __('缺少验证参数', 'aksrt');
return aksrt_captcha_error('aksrt_captcha_missing', __('缺少验证参数', 'aksrt'));
}
$captcha_key = (string) ($pcfg['captcha_key'] ?? '');
@@ -556,28 +867,26 @@ function aksrt_verify_geetest(array $pcfg, array $post_data) {
],
]);
if (is_wp_error($resp)) {
aksrt_captcha_log('geetest', $resp->get_error_message());
return __('人机验证服务暂不可用,请稍后重试', 'aksrt');
$body = aksrt_captcha_remote_json('geetest', $resp);
if (is_wp_error($body)) {
return $body;
}
$body = json_decode(wp_remote_retrieve_body($resp), true);
if (is_array($body) && !empty($body['result']) && $body['result'] === 'success') {
if (($body['result'] ?? '') === 'success') {
return true;
}
aksrt_captcha_log('geetest', 'validate failed: ' . (string) ($body['reason'] ?? ($body['msg'] ?? 'unknown')));
return __('人机验证未通过,请重试', 'aksrt');
return aksrt_captcha_error('aksrt_captcha_rejected', __('人机验证未通过,请重试', 'aksrt'));
}
/**
* 阿里云 CAPTCHA 2.0 V3 (VerifyIntelligentCaptcha)。
*
* @return true|string
* @return true|\WP_Error
*/
function aksrt_verify_aliyun(array $pcfg, array $post_data) {
$captcha_verify_param = (string) ($post_data['aliyun_captcha_verify_param'] ?? '');
if ($captcha_verify_param === '') {
return __('缺少验证参数 (captchaVerifyParam)', 'aksrt');
return aksrt_captcha_error('aksrt_captcha_missing', __('缺少验证参数 (captchaVerifyParam)', 'aksrt'));
}
$region = (string) ($pcfg['region'] ?? 'cn');
@@ -606,15 +915,9 @@ function aksrt_verify_aliyun(array $pcfg, array $post_data) {
'body' => $params,
]);
if (is_wp_error($resp)) {
aksrt_captcha_log('aliyun', $resp->get_error_message());
return __('人机验证服务暂不可用,请稍后重试', 'aksrt');
}
$body = json_decode(wp_remote_retrieve_body($resp), true);
if (!is_array($body)) {
aksrt_captcha_log('aliyun', 'unparsable response');
return __('人机验证服务暂不可用,请稍后重试', 'aksrt');
$body = aksrt_captcha_remote_json('aliyun', $resp);
if (is_wp_error($body)) {
return $body;
}
if (!empty($body['Success'])) {
$verify_result = $body['Result']['VerifyResult'] ?? false;
@@ -640,11 +943,13 @@ function aksrt_verify_aliyun(array $pcfg, array $post_data) {
'F020' => 'CaptchaVerifyParam 与场景 ID 不匹配',
];
$verify_code = (string) ($body['Result']['VerifyCode'] ?? '');
return '阿里云 V3 验证失败: ' . ($code_messages[$verify_code] ?? '未知错误 (' . ($verify_code ?: 'N/A') . ')');
$detail = $code_messages[$verify_code] ?? '未知错误 (' . ($verify_code ?: 'N/A') . ')';
aksrt_captcha_log('aliyun', 'verify rejected: ' . $detail);
return aksrt_captcha_error('aksrt_captcha_rejected', __('人机验证未通过,请重试', 'aksrt'));
}
aksrt_captcha_log('aliyun', 'api failed: ' . (string) ($body['Message'] ?? 'unknown'));
return __('人机验证服务暂不可用,请稍后重试', 'aksrt');
return aksrt_captcha_error('aksrt_captcha_provider_error', __('人机验证服务暂不可用,请稍后重试', 'aksrt'));
}
// ============================================================
@@ -671,7 +976,7 @@ function aksrt_render_captcha($scene = 'comment') {
echo '<div class="aksrt-captcha-misconfig" style="font-size:12px;color:#d03050;margin:8px 0;">';
echo esc_html(sprintf(
/* translators: %s: captcha provider key */
__('人机验证服务商「%s」已启用但凭据未配齐,前台已跳过渲染。请到「功能&权限 → 人机验证」补全配置。', 'aksrt'),
__('人机验证服务商「%s」已启用但凭据未配齐,当前场景将拒绝提交。请到「功能&权限 → 人机验证」补全配置。', 'aksrt'),
$provider
));
echo '</div>';
@@ -686,7 +991,9 @@ function aksrt_render_captcha($scene = 'comment') {
'provider' => $provider,
'scene' => $scene,
'uid' => $uid,
'vid' => $vid,
'vid' => $vid,
'challenge_url' => rest_url('aksrt/v1/captcha/challenge'),
'exchange_url' => rest_url('aksrt/v1/captcha/exchange'),
'tc' => ['app_id' => (string) ($pcfg['app_id'] ?? '')],
'cf' => [
'site_key' => (string) ($pcfg['site_key'] ?? ''),
@@ -706,7 +1013,9 @@ function aksrt_render_captcha($scene = 'comment') {
?>
<div class="aksrt-captcha-wrap" data-provider="<?php echo esc_attr($provider); ?>" data-scene="<?php echo esc_attr($scene); ?>" data-aksrt-captcha-config="<?php echo esc_attr(wp_json_encode($js_config, JSON_UNESCAPED_UNICODE)); ?>">
<div id="<?php echo esc_attr($uid); ?>" class="aksrt-captcha-box" style="display:none"></div>
<input type="hidden" name="captcha_verified" id="<?php echo esc_attr($vid); ?>" value="0">
<input type="hidden" name="captcha_verified" id="<?php echo esc_attr($vid); ?>" value="0">
<input type="hidden" name="aksrt_captcha_challenge" value="">
<input type="hidden" name="aksrt_captcha_proof" value="">
</div>
<?php
}
+18 -3
View File
@@ -7,6 +7,21 @@
defined('ABSPATH') || exit;
if (!function_exists('aksrt_account_captcha_error_data')) :
function aksrt_account_captcha_error_data($result): array {
if (is_wp_error($result)) {
return [
'code' => $result->get_error_code() ?: 'aksrt_captcha_failed',
'message' => $result->get_error_message() ?: __('人机验证失败,请重试', 'aksrt'),
];
}
return [
'code' => 'aksrt_captcha_failed',
'message' => __('人机验证失败,请重试', 'aksrt'),
];
}
endif;
// ============================================================
add_action('wp_ajax_aksrt_save_profile', function () {
if (!isset($_POST['_profile_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_profile_nonce'])), 'aksrt_profile_save')) {
@@ -90,7 +105,7 @@ add_action('wp_ajax_aksrt_send_email_code', function () {
if ( function_exists( 'aksrt_require_captcha' ) ) {
$captcha_result = aksrt_require_captcha( $scene, wp_unslash( $_POST ) );
if ( $captcha_result !== true ) {
wp_send_json_error( array( 'message' => $captcha_result ) );
wp_send_json_error( aksrt_account_captcha_error_data( $captcha_result ) );
}
}
@@ -199,7 +214,7 @@ add_action('wp_ajax_aksrt_change_password', function () {
if ( function_exists( 'aksrt_require_captcha' ) ) {
$captcha_result = aksrt_require_captcha( 'change_password', wp_unslash( $_POST ) );
if ( $captcha_result !== true ) {
wp_send_json_error( array( 'message' => $captcha_result ) );
wp_send_json_error( aksrt_account_captcha_error_data( $captcha_result ) );
}
}
@@ -264,7 +279,7 @@ add_action('wp_ajax_aksrt_change_email', function () {
if ( function_exists( 'aksrt_require_captcha' ) ) {
$captcha_result = aksrt_require_captcha( 'change_email', wp_unslash( $_POST ) );
if ( $captcha_result !== true ) {
wp_send_json_error( array( 'message' => $captcha_result ) );
wp_send_json_error( aksrt_account_captcha_error_data( $captcha_result ) );
}
}
+50 -4
View File
@@ -87,6 +87,12 @@ class ImportExportController extends ControllerBase {
'permission_callback' => [$this, 'check_permission'],
]);
register_rest_route(self::API_NAMESPACE, '/captcha/test', [
'methods' => 'POST',
'callback' => [$this, 'rest_test_captcha'],
'permission_callback' => [$this, 'check_permission'],
]);
// Backup snapshots
register_rest_route(self::API_NAMESPACE, '/settings/backups', [
'methods' => 'GET',
@@ -118,6 +124,42 @@ class ImportExportController extends ControllerBase {
/* Handlers */
/* ------------------------------------------------------------------ */
public function rest_test_captcha(\WP_REST_Request $request): \WP_REST_Response {
$params = $this->get_json_params($request);
$provider = sanitize_key((string) ($params['provider'] ?? ''));
$token = isset($params['token']) && is_array($params['token']) ? $params['token'] : [];
$allowed_token_fields = [
'tencent_ticket', 'tencent_randstr',
'cf_turnstile_response',
'geetest_lot_number', 'geetest_captcha_output', 'geetest_pass_token', 'geetest_gen_time',
'aliyun_captcha_verify_param',
];
$token = array_intersect_key($token, array_flip($allowed_token_fields));
foreach ($token as $key => $value) {
$token[$key] = is_scalar($value) ? sanitize_text_field((string) $value) : '';
}
$config = function_exists('aksrt_get_captcha_config') ? aksrt_get_captcha_config() : [];
$stored_provider = (string) ($config['active_provider'] ?? '');
if ($provider === '' || $provider !== $stored_provider || !in_array($provider, aksrt_captcha_providers(), true)) {
return $this->error(__('请先保存并启用要测试的人机验证服务商', 'aksrt'), 422);
}
$provider_config = isset($config[$provider]) && is_array($config[$provider]) ? $config[$provider] : [];
if (!aksrt_captcha_provider_is_configured($provider, $provider_config)) {
return $this->error(__('当前人机验证服务商配置不完整', 'aksrt'), 422);
}
$scene = 'admin-captcha-test';
$action = 'aksrt-admin-test';
$result = aksrt_verify_captcha_provider($provider, $provider_config, $token, aksrt_get_client_ip(), $scene, $action);
if (is_wp_error($result)) {
return $this->error($result->get_error_message(), 422);
}
return $this->success(['message' => __('服务端同源验签通过', 'aksrt')]);
}
/**
* Export all AKSRT settings as a JSON-serializable array.
*/
@@ -139,7 +181,7 @@ class ImportExportController extends ControllerBase {
}
// Also export standalone options from the registry
$standalone_keys = \AKSRT\Settings\Settings::STANDALONE_OPTION_KEYS;
$standalone_keys = \AKSRT\Settings\Settings::standalone_export_keys();
$export['standalone'] = [];
foreach ($standalone_keys as $key) {
@@ -180,21 +222,25 @@ class ImportExportController extends ControllerBase {
// Import domain settings
if (!empty($params['domains']) && is_array($params['domains'])) {
foreach ($params['domains'] as $domain => $payload) {
if (!in_array($domain, $schema_provider->get_domain_keys(), true)) {
$results[$domain] = ['status' => 'skipped', 'message' => 'Unknown domain'];
continue;
}
$data = $payload['data'] ?? $payload;
if (!is_array($data)) {
$results[$domain] = ['status' => 'error', 'message' => 'Invalid data format'];
continue;
}
// Validate
$validation = $schema_provider->validate($domain, $data);
$clean = $schema_provider->sanitize_payload((string) $domain, $data);
$validation = $schema_provider->validate((string) $domain, $clean);
if (!$validation['valid']) {
$results[$domain] = ['status' => 'error', 'errors' => $validation['errors']];
continue;
}
if (!$dry_run) {
$schema_provider->write_settings($domain, $data);
$schema_provider->write_settings((string) $domain, $clean);
$results[$domain] = ['status' => 'imported'];
} else {
$results[$domain] = ['status' => 'valid'];
+10 -4
View File
@@ -90,18 +90,24 @@ trait ImportExportControllerPart1 {
$payload = $target['payload'];
$domains = $payload['domains'] ?? [];
$standalone = $payload['standalone'] ?? [];
$standalone = \AKSRT\Settings\Settings::filter_sensitive_import(
is_array($payload['standalone'] ?? null) ? $payload['standalone'] : []
);
$provider = SchemaProvider::get_instance();
foreach ($domains as $domain => $domainPayload) {
$data = $domainPayload['data'] ?? $domainPayload;
if (is_array($data)) {
$provider->write_settings((string) $domain, $data);
if (is_array($data) && in_array((string) $domain, $provider->get_domain_keys(), true)) {
$clean = $provider->sanitize_payload((string) $domain, $data);
$validation = $provider->validate((string) $domain, $clean);
if ($validation['valid']) {
$provider->write_settings((string) $domain, $clean);
}
}
}
foreach ($standalone as $key => $value) {
if (is_string($key) && preg_match('/^aksrt_/', $key)) {
if (is_string($key) && in_array($key, \AKSRT\Settings\Settings::standalone_export_keys(), true)) {
update_option($key, $value);
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ trait ImportExportControllerPart2 {
];
}
foreach (\AKSRT\Settings\Settings::STANDALONE_OPTION_KEYS as $key) {
foreach (\AKSRT\Settings\Settings::standalone_export_keys() as $key) {
$value = get_option($key);
if ($value !== false) {
$payload['standalone'][$key] = $value;
+20 -14
View File
@@ -10,6 +10,23 @@ defined('ABSPATH') || exit;
class SchemaPermissions {
public static function build(): array {
$provider_meta = function_exists('aksrt_captcha_provider_metadata') ? aksrt_captcha_provider_metadata() : [];
$provider_options = [['label' => '关闭', 'value' => '']];
$provider_fields = [];
foreach ($provider_meta as $provider => $meta) {
$provider_options[] = [
'label' => (string) ($meta['label'] ?? $provider),
'value' => $provider,
];
$provider_fields[] = [
'key' => $provider,
'label' => (string) ($meta['label'] ?? $provider) . ' 配置',
'type' => 'custom',
'default' => function_exists('aksrt_captcha_provider_defaults') ? aksrt_captcha_provider_defaults($provider) : [],
'captcha_provider' => $provider,
];
}
return [
'domain' => 'permissions',
'title' => '功能与权限',
@@ -22,14 +39,8 @@ class SchemaPermissions {
'groups' => [
['key' => 'basic', 'title' => '基础开关', 'fields' => [
['key' => 'enabled', 'label' => '启用人机验证', 'type' => 'switch', 'default' => false],
['key' => 'active_provider', 'label' => '当前服务商', 'type' => 'radio', 'default' => '', 'dependsOn' => ['key' => 'enabled', 'value' => true], 'options' => [
['label' => '关闭', 'value' => ''],
['label' => '腾讯云 Captcha', 'value' => 'tencent'],
['label' => '阿里云 CAPTCHA 2.0', 'value' => 'aliyun'],
['label' => 'Cloudflare Turnstile', 'value' => 'cloudflare'],
['label' => '极验 GeeTest', 'value' => 'geetest'],
]],
['key' => 'enabled_scenarios', 'label' => '启用场景', 'type' => 'checkbox', 'default' => ['comment', 'login', 'register', 'bind', 'bind_phone', 'lostpassword', 'change_password', 'change_email', 'download', 'signin'], 'dependsOn' => ['key' => 'enabled', 'value' => true], 'options' => [
['key' => 'active_provider', 'label' => '当前服务商', 'type' => 'radio', 'default' => '', 'dependsOn' => ['key' => 'enabled', 'value' => true], 'options' => $provider_options],
['key' => 'enabled_scenarios', 'label' => '启用场景', 'type' => 'checkbox', 'default' => [], 'dependsOn' => ['key' => 'enabled', 'value' => true], 'options' => [
['label' => '发表评论', 'value' => 'comment'],
['label' => '用户登录', 'value' => 'login'],
['label' => '用户注册', 'value' => 'register'],
@@ -43,12 +54,7 @@ class SchemaPermissions {
['label' => '前台投稿', 'value' => 'front_submit'],
]],
]],
['key' => 'providers', 'title' => '服务商配置', 'fields' => [
['key' => 'tencent', 'label' => '腾讯云配置', 'type' => 'custom', 'default' => [], 'sanitize_type' => 'array'],
['key' => 'aliyun', 'label' => '阿里云配置', 'type' => 'custom', 'default' => [], 'sanitize_type' => 'array'],
['key' => 'cloudflare', 'label' => 'Cloudflare 配置', 'type' => 'custom', 'default' => [], 'sanitize_type' => 'array'],
['key' => 'geetest', 'label' => '极验配置', 'type' => 'custom', 'default' => [], 'sanitize_type' => 'array'],
]],
['key' => 'providers', 'title' => '服务商配置', 'fields' => $provider_fields],
],
],
'attachment' => [
+104 -22
View File
@@ -140,10 +140,8 @@ class SchemaProvider {
}
}
if (!empty($data['__clear_secrets']) && is_array($data['__clear_secrets'])) {
$clean['__clear_secrets'] = array_values(array_intersect(
array_map('sanitize_key', $data['__clear_secrets']),
$this->secret_field_keys($schema)
));
$requested = array_values(array_filter(array_map([$this, 'normalize_secret_path'], $data['__clear_secrets'])));
$clean['__clear_secrets'] = array_values(array_intersect($requested, $this->secret_field_paths($schema)));
}
return $clean;
@@ -505,14 +503,14 @@ class SchemaProvider {
if ($section !== '') {
$defaults = $this->get_defaults($domain, $section);
$schema = $this->get_section_schema($domain, $section);
$defaults['__clear_secrets'] = $schema ? $this->secret_field_keys($schema) : [];
$defaults['__clear_secrets'] = $schema ? $this->secret_field_paths($schema) : [];
return $this->write_settings($domain, $defaults, $section);
}
$result = true;
foreach ($this->get_sections($domain) as $section_schema) {
$defaults = $this->collect_defaults($section_schema);
$defaults['__clear_secrets'] = $this->secret_field_keys($section_schema);
$defaults['__clear_secrets'] = $this->secret_field_paths($section_schema);
$result = $this->write_section_settings($section_schema, $defaults) && $result;
}
return $result;
@@ -541,24 +539,33 @@ class SchemaProvider {
}
$data = $this->collect_defaults($schema);
foreach ($this->get_all_fields($schema) as $field) {
if (array_key_exists($field['key'], $stored)) {
$data[$field['key']] = $stored[$field['key']];
}
foreach ($this->get_all_fields($schema) as $field) {
if (array_key_exists($field['key'], $stored)) {
$data[$field['key']] = $stored[$field['key']];
}
$stored_value = array_key_exists($field['key'], $stored) ? $stored[$field['key']] : ($data[$field['key']] ?? '');
if (!empty($field['secret'])) {
$data[$field['key']] = trim((string) $stored_value) !== '' ? '***' : '';
}
}
if (!empty($field['captcha_provider'])) {
$data[$field['key']] = $this->mask_captcha_provider_secrets(
(string) $field['captcha_provider'],
is_array($stored_value) ? $stored_value : []
);
}
}
// Captcha: always surface the normalized positive scene list.
if ($option_key === 'aksrt_captcha_config' && function_exists('aksrt_get_captcha_config')) {
$normalized = aksrt_get_captcha_config();
foreach (['enabled', 'active_provider', 'enabled_scenarios', 'tencent', 'aliyun', 'cloudflare', 'geetest'] as $key) {
if (array_key_exists($key, $normalized)) {
$data[$key] = $normalized[$key];
}
}
foreach (['enabled', 'active_provider', 'enabled_scenarios'] as $key) {
if (array_key_exists($key, $normalized)) {
$data[$key] = $normalized[$key];
}
}
foreach (function_exists('aksrt_captcha_providers') ? aksrt_captcha_providers() : [] as $provider) {
$data[$provider] = $this->mask_captcha_provider_secrets($provider, (array) ($normalized[$provider] ?? []));
}
}
return $data;
@@ -576,13 +583,20 @@ class SchemaProvider {
$fields[(string) $field['key']] = $field;
}
$clear_secrets = isset($data['__clear_secrets']) && is_array($data['__clear_secrets'])
? array_values(array_map('sanitize_key', $data['__clear_secrets']))
? array_values(array_filter(array_map([$this, 'normalize_secret_path'], $data['__clear_secrets'])))
: [];
foreach ($fields as $key => $field) {
if (!empty($field['secret']) && in_array($key, $clear_secrets, true)) {
unset($stored[$key]);
continue;
}
if (!empty($field['captcha_provider'])) {
$provider = (string) $field['captcha_provider'];
$current = isset($stored[$key]) && is_array($stored[$key]) ? $stored[$key] : [];
$incoming = array_key_exists($key, $data) && is_array($data[$key]) ? $data[$key] : [];
$stored[$key] = $this->merge_captcha_provider_config($provider, $current, $incoming, $clear_secrets);
continue;
}
if (!array_key_exists($key, $data)) {
continue;
}
@@ -599,9 +613,17 @@ class SchemaProvider {
// Captcha stores a single positive scene list.
if ($option_key === 'aksrt_captcha_config' && function_exists('aksrt_captcha_scenes')) {
$stored['enabled'] = !empty($stored['enabled']);
$active_provider = sanitize_key((string) ($stored['active_provider'] ?? ''));
$stored['active_provider'] = in_array($active_provider, aksrt_captcha_providers(), true) ? $active_provider : '';
$stored['enabled_scenarios'] = function_exists('aksrt_captcha_sanitize_scene_list')
? aksrt_captcha_sanitize_scene_list($stored['enabled_scenarios'] ?? [])
: (array) ($stored['enabled_scenarios'] ?? []);
foreach (aksrt_captcha_providers() as $provider) {
$stored[$provider] = function_exists('aksrt_captcha_sanitize_provider_config')
? aksrt_captcha_sanitize_provider_config($provider, $stored[$provider] ?? [])
: (array) ($stored[$provider] ?? []);
}
}
$updated = update_option($option_key, $stored);
@@ -617,14 +639,69 @@ class SchemaProvider {
return $updated || true;
}
private function secret_field_keys(array $schema): array {
$keys = [];
private function normalize_secret_path($path): string {
$parts = array_values(array_filter(explode('.', strtolower(trim((string) $path))), static fn(string $part): bool => $part !== ''));
$parts = array_map('sanitize_key', $parts);
return in_array('', $parts, true) ? '' : implode('.', $parts);
}
private function secret_field_paths(array $schema): array {
$paths = [];
foreach ($this->get_all_fields($schema) as $field) {
if (!empty($field['secret']) && !empty($field['key'])) {
$keys[] = sanitize_key((string) $field['key']);
$key = sanitize_key((string) ($field['key'] ?? ''));
if ($key === '') {
continue;
}
if (!empty($field['secret'])) {
$paths[] = $key;
}
if (!empty($field['captcha_provider']) && function_exists('aksrt_captcha_provider_secret_fields')) {
foreach (aksrt_captcha_provider_secret_fields((string) $field['captcha_provider']) as $secret) {
$paths[] = $key . '.' . sanitize_key((string) $secret);
}
}
}
return array_values(array_unique($keys));
return array_values(array_unique($paths));
}
private function mask_captcha_provider_secrets(string $provider, array $config): array {
$clean = function_exists('aksrt_captcha_sanitize_provider_config')
? aksrt_captcha_sanitize_provider_config($provider, $config)
: $config;
foreach (function_exists('aksrt_captcha_provider_secret_fields') ? aksrt_captcha_provider_secret_fields($provider) : [] as $secret) {
$clean[$secret] = trim((string) ($config[$secret] ?? '')) !== '' ? '***' : '';
}
return $clean;
}
private function merge_captcha_provider_config(string $provider, array $stored, array $incoming, array $clear_paths): array {
$current = function_exists('aksrt_captcha_sanitize_provider_config')
? aksrt_captcha_sanitize_provider_config($provider, $stored)
: $stored;
$defaults = function_exists('aksrt_captcha_provider_defaults') ? aksrt_captcha_provider_defaults($provider) : [];
$secret_fields = function_exists('aksrt_captcha_provider_secret_fields') ? aksrt_captcha_provider_secret_fields($provider) : [];
foreach (array_keys($defaults) as $key) {
$path = $provider . '.' . $key;
if (in_array($path, $clear_paths, true)) {
$current[$key] = '';
continue;
}
if (!array_key_exists($key, $incoming) || is_array($incoming[$key]) || is_object($incoming[$key])) {
continue;
}
$value = trim((string) $incoming[$key]);
if (in_array($key, $secret_fields, true) && ($value === '' || $value === '***')) {
continue;
}
$current[$key] = $value;
}
return function_exists('aksrt_captcha_sanitize_provider_config')
? aksrt_captcha_sanitize_provider_config($provider, $current)
: $current;
}
private function secret_field_keys(array $schema): array {
return array_values(array_filter($this->secret_field_paths($schema), static fn(string $path): bool => !str_contains($path, '.')));
}
private function collect_defaults(array $schema): array {
@@ -721,6 +798,11 @@ class SchemaProvider {
}
private function sanitize_field_value($value, array $field) {
if (!empty($field['captcha_provider'])) {
return function_exists('aksrt_captcha_sanitize_provider_config')
? aksrt_captcha_sanitize_provider_config((string) $field['captcha_provider'], $value)
: [];
}
$type = (string) ($field['type'] ?? 'text');
$sanitize_type = (string) ($field['sanitize_type'] ?? '');
if ($sanitize_type === 'business_prefix') {
+51 -36
View File
@@ -29,6 +29,30 @@ trait UserControllerAuthPhoneOperations {
return $nonce !== '' && (bool) wp_verify_nonce($nonce, 'wp_rest');
}
private function auth_require_captcha(string $scene, array $payload): ?\WP_REST_Response {
$result = aksrt_require_captcha($scene, $payload);
if ($result === true) {
return null;
}
if (is_wp_error($result)) {
$data = $result->get_error_data();
$status = is_array($data) ? (int) ($data['status'] ?? 400) : 400;
$message = $result->get_error_message() ?: __('人机验证失败', 'aksrt');
return new \WP_REST_Response([
'success' => false,
'code' => $result->get_error_code() ?: 'aksrt_captcha_failed',
'message' => $message,
], $status > 0 ? $status : 400);
}
return new \WP_REST_Response([
'success' => false,
'code' => 'aksrt_captcha_failed',
'message' => __('人机验证失败', 'aksrt'),
], 400);
}
private function auth_rate_key(string $action, string $subject = ''): string {
$ip = sanitize_text_field((string) ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'));
return 'aksrt_auth_rate_' . md5($action . '|' . strtolower($subject) . '|' . $ip);
@@ -144,12 +168,10 @@ trait UserControllerAuthPhoneOperations {
if ($this->auth_rate_limited('code:' . $scene, $target, 5, HOUR_IN_SECONDS)) {
return $this->error(__('请求过于频繁,请稍后再试', 'aksrt'), 429);
}
if (function_exists('aksrt_require_captcha')) {
$captcha_scene = $scene === 'resetpassword' ? 'lostpassword' : $scene;
$captcha = aksrt_require_captcha($captcha_scene, $payload);
if ($captcha !== true) {
return $this->error((string) $captcha);
}
$captcha_scene = $scene === 'resetpassword' ? 'lostpassword' : $scene;
$captcha_error = $this->auth_require_captcha($captcha_scene, $payload);
if ($captcha_error) {
return $captcha_error;
}
$generic = __('如果信息有效,验证码将发送到该账号', 'aksrt');
@@ -207,11 +229,9 @@ trait UserControllerAuthPhoneOperations {
if ($this->auth_rate_limited('login', $target)) {
return $this->error(__('请求过于频繁,请稍后再试', 'aksrt'), 429);
}
if (function_exists('aksrt_require_captcha')) {
$captcha = aksrt_require_captcha($oauth_data ? 'bind' : 'login', $payload);
if ($captcha !== true) {
return $this->error((string) $captcha);
}
$captcha_error = $this->auth_require_captcha($oauth_data ? 'bind' : 'login', $payload);
if ($captcha_error) {
return $captcha_error;
}
$redirect = $this->auth_safe_redirect((string) ($payload['redirect_to'] ?? ''), home_url('/'));
@@ -271,7 +291,7 @@ trait UserControllerAuthPhoneOperations {
$redirect = function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url() : $redirect;
return $this->success(['message' => __('账号绑定成功', 'aksrt'), 'redirect' => $redirect]);
}
$result = \AKSRT\Auth\Service::login_user($payload, 'login');
$result = \AKSRT\Auth\Service::login_user($payload);
if (empty($result['success'])) {
return $this->error(__('账号或密码错误', 'aksrt'), 401);
}
@@ -309,25 +329,23 @@ trait UserControllerAuthPhoneOperations {
if (($channel === 'email' && !is_email($target)) || ($channel === 'phone' && !preg_match('/^1[3-9]\d{9}$/', $target))) {
return $this->error(__('请输入有效的邮箱或手机号', 'aksrt'));
}
if (function_exists('aksrt_require_captcha')) {
$captcha = aksrt_require_captcha('register', $payload);
if ($captcha !== true) {
return $this->error((string) $captcha);
}
$captcha_error = $this->auth_require_captcha('register', $payload);
if ($captcha_error) {
return $captcha_error;
}
$verification_completed = false;
if ($signup_verification) {
if (!$this->auth_consume_code('register', $target, sanitize_text_field((string) ($payload['code'] ?? '')), $channel)) {
return $this->error(__('验证码错误或已过期', 'aksrt'));
}
$payload['_auth_code_verified'] = true;
$verification_completed = true;
}
if (!class_exists('\AKSRT\Auth\Service')) {
return $this->error(__('认证服务不可用', 'aksrt'), 500);
}
if ($channel === 'phone') {
$payload['phone'] = $target;
$payload['_defer_login'] = !empty($oauth_data);
$result = \AKSRT\Auth\Service::register_by_phone($payload);
$result = \AKSRT\Auth\Service::register_by_phone($payload, $verification_completed, !empty($oauth_data));
if (empty($result['success'])) {
return $this->error($this->auth_registration_error((string) ($result['error'] ?? __('注册失败', 'aksrt'))));
}
@@ -348,21 +366,20 @@ trait UserControllerAuthPhoneOperations {
$payload['user_email'] = sanitize_email($target);
$payload['user_pass'] = (string) ($payload['password'] ?? '');
$payload['user_pass_confirm'] = (string) ($payload['password_confirm'] ?? $payload['password'] ?? '');
$payload['_defer_login'] = !empty($oauth_data);
$result = \AKSRT\Auth\Service::register_user($payload);
$result = \AKSRT\Auth\Service::register_user($payload, $verification_completed, !empty($oauth_data));
if (empty($result['success'])) {
return $this->error($this->auth_registration_error((string) ($result['error'] ?? __('注册失败', 'aksrt'))));
}
$user_id = (int) ($result['user_id'] ?? 0);
if ($oauth_data && $user_id > 0) {
$binding = \AKSRT\Auth\Service::finalize_oauth_binding($user_id, $oauth_data, $obk);
if (is_wp_error($binding)) {
wp_delete_user($user_id);
return $this->error($binding->get_error_message(), 409);
}
if (is_wp_error($binding)) {
$this->delete_oauth_registration_user($user_id);
return $this->error($binding->get_error_message(), 409);
}
$user = get_user_by('id', $user_id);
if ($user instanceof \WP_User) {
if ($channel === 'email' && !empty($payload['_auth_code_verified'])) {
if ($channel === 'email' && $verification_completed) {
\AKSRT\Auth\Service::mark_verified_email($user_id);
}
\AKSRT\Auth\OAuthAccountService::complete_login($user, true);
@@ -393,11 +410,9 @@ trait UserControllerAuthPhoneOperations {
if ($this->auth_rate_limited('reset-password', $target, 8, HOUR_IN_SECONDS)) {
return $this->error(__('请求过于频繁,请稍后再试', 'aksrt'), 429);
}
if (function_exists('aksrt_require_captcha')) {
$captcha = aksrt_require_captcha('lostpassword', $payload);
if ($captcha !== true) {
return $this->error((string) $captcha);
}
$captcha_error = $this->auth_require_captcha('lostpassword', $payload);
if ($captcha_error) {
return $captcha_error;
}
$user = $this->auth_find_user($target, $channel);
$password = (string) ($payload['password'] ?? '');
@@ -425,10 +440,8 @@ trait UserControllerAuthPhoneOperations {
if (!preg_match('/^1[3-9]\d{9}$/', $phone)) {
return $this->error(__('手机号格式不正确', 'aksrt'));
}
if (function_exists('aksrt_require_captcha')) {
$captcha = aksrt_require_captcha('bind_phone', $payload);
if ($captcha !== true) return $this->error((string) $captcha);
}
$captcha_error = $this->auth_require_captcha('bind_phone', $payload);
if ($captcha_error) return $captcha_error;
if (!class_exists('\AKSRT\Sms\SMS')) return $this->error(__('短信服务不可用', 'aksrt'), 503);
$result = \AKSRT\Sms\SMS::get_instance()->send_code($phone);
return !empty($result['success'])
@@ -439,6 +452,8 @@ trait UserControllerAuthPhoneOperations {
public function user_bind_phone(\WP_REST_Request $request): \WP_REST_Response {
$payload = $this->auth_payload($request);
if (!$this->auth_verify_rest_nonce($request, $payload)) return $this->error(__('安全验证失败', 'aksrt'), 403);
$captcha_error = $this->auth_require_captcha('bind_phone', $payload);
if ($captcha_error) return $captcha_error;
if (!class_exists('\AKSRT\Auth\Service')) return $this->error(__('认证服务不可用', 'aksrt'), 500);
$result = \AKSRT\Auth\Service::bind_phone($payload);
return !empty($result['success']) ? $this->success(['message' => $result['message'] ?? __('绑定成功', 'aksrt')]) : $this->error((string) ($result['error'] ?? __('绑定失败', 'aksrt')));
+21 -4
View File
@@ -29,6 +29,13 @@ class Moderation {
*/
private $forced_approved_map = [];
/**
* Request-local depth for comment submissions already admitted by the REST controller.
*
* @var int
*/
private $captcha_verified_comment_request_depth = 0;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
@@ -50,6 +57,14 @@ class Moderation {
add_action('manage_comments_custom_column', [$this, 'render_comments_custom_column'], 10, 2);
}
public function begin_captcha_verified_comment_request(): void {
$this->captcha_verified_comment_request_depth++;
}
public function end_captcha_verified_comment_request(): void {
$this->captcha_verified_comment_request_depth = max(0, $this->captcha_verified_comment_request_depth - 1);
}
public function get_config(): array {
$defaults = [
'enabled' => false,
@@ -90,17 +105,19 @@ class Moderation {
);
}
// 人机验证:覆盖 REST 与原生 wp-comments-post 两条提交合同(与审核开关无关)
if (function_exists('aksrt_require_captcha')) {
// 人机验证:原生 wp-comments-post 必须验证;REST 已在 Controller 准入时验证一次。
if ($this->captcha_verified_comment_request_depth === 0) {
$payload = is_array($_POST ?? null) ? wp_unslash($_POST) : [];
if (isset($payload['captcha']) && is_array($payload['captcha'])) {
$payload = array_merge($payload, $payload['captcha']);
}
$captcha_result = aksrt_require_captcha('comment', $payload);
if ($captcha_result !== true) {
$msg = is_string($captcha_result) ? $captcha_result : __('人机验证失败', 'aksrt');
$msg = is_wp_error($captcha_result)
? $captcha_result->get_error_message()
: __('人机验证失败', 'aksrt');
wp_die(
esc_html($msg),
esc_html($msg !== '' ? $msg : __('人机验证失败', 'aksrt')),
esc_html__('评论提交失败', 'aksrt'),
['response' => 400, 'back_link' => true]
);
+124 -5
View File
@@ -9,6 +9,9 @@ defined('ABSPATH') || exit;
final class OAuth {
private const BIND_PASS_KIND = 'oauth_bind_pass';
private const BIND_PASS_TTL = 5 * MINUTE_IN_SECONDS;
private static $instance = null;
private OAuthProviderBroker $broker;
@@ -32,6 +35,7 @@ final class OAuth {
}
}, 30);
add_action('template_redirect', [$this, 'handle_routes'], 8);
add_action('rest_api_init', [$this, 'register_rest_routes']);
add_action('wp_ajax_aksrt_oauth_unbind', [$this, 'ajax_unbind']);
}
@@ -47,6 +51,109 @@ final class OAuth {
});
}
public function register_rest_routes(): void {
register_rest_route('aksrt/v1', '/oauth/bind-pass', [
'methods' => 'POST',
'callback' => [$this, 'rest_issue_bind_pass'],
'permission_callback' => static function (): bool {
return is_user_logged_in();
},
]);
}
public function rest_issue_bind_pass(\WP_REST_Request $request): \WP_REST_Response {
$user_id = get_current_user_id();
$nonce = sanitize_text_field((string) $request->get_header('X-WP-Nonce'));
if ($user_id <= 0 || $nonce === '' || !wp_verify_nonce($nonce, 'wp_rest')) {
return new \WP_REST_Response([
'success' => false,
'code' => 'oauth_bind_nonce_invalid',
'message' => __('安全验证失败,请刷新页面重试', 'aksrt'),
], 403);
}
$payload = $request->get_json_params();
$payload = is_array($payload) ? $payload : [];
$provider = sanitize_key((string) ($payload['provider'] ?? ''));
if (!in_array($provider, OAuthProviderCatalog::enabled_login_keys(), true)) {
return new \WP_REST_Response([
'success' => false,
'code' => 'oauth_bind_provider_invalid',
'message' => __('该第三方登录方式不可用', 'aksrt'),
], 400);
}
$url = $this->get_auth_url($provider, 'bind');
if ($url === '') {
return new \WP_REST_Response([
'success' => false,
'code' => 'oauth_bind_provider_invalid',
'message' => __('该第三方登录方式不可用', 'aksrt'),
], 400);
}
$redirect = OAuthUrlPolicy::validated_redirect((string) ($payload['redirect_to'] ?? ''), home_url('/'));
$url = add_query_arg('redirect_to', $redirect, $url);
if (!aksrt_is_captcha_enabled_for('bind')) {
return new \WP_REST_Response([
'success' => true,
'data' => ['url' => $url],
], 200);
}
$result = aksrt_require_captcha('bind', $payload);
if ($result !== true) {
return new \WP_REST_Response([
'success' => false,
'code' => is_wp_error($result) ? $result->get_error_code() : 'aksrt_captcha_failed',
'message' => is_wp_error($result) ? $result->get_error_message() : __('人机验证失败,请重试', 'aksrt'),
], 400);
}
try {
$pass = OAuthTransactionStore::issue(self::BIND_PASS_KIND, [
'user_id' => $user_id,
'provider' => $provider,
], self::BIND_PASS_TTL, true);
} catch (\Throwable $e) {
return new \WP_REST_Response([
'success' => false,
'code' => 'oauth_bind_pass_issue_failed',
'message' => __('无法创建绑定会话,请稍后重试', 'aksrt'),
], 500);
}
$url = add_query_arg('bind_pass', $pass, $url);
return new \WP_REST_Response([
'success' => true,
'data' => ['url' => $url],
], 200);
}
public static function validate_bind_pass(string $token, string $provider, int $user_id, bool $consume = true): bool {
if (!aksrt_is_captcha_enabled_for('bind')) {
return true;
}
if ($token === '' || $user_id <= 0) {
return false;
}
$matches = static function (?array $row) use ($provider, $user_id): bool {
$payload = is_array($row['payload'] ?? null) ? $row['payload'] : [];
return $row !== null
&& (int) ($payload['user_id'] ?? 0) === $user_id
&& hash_equals(sanitize_key($provider), sanitize_key((string) ($payload['provider'] ?? '')));
};
$row = OAuthTransactionStore::peek($token, self::BIND_PASS_KIND, true);
if (!$matches($row)) {
return false;
}
if (!$consume) {
return true;
}
return $matches(OAuthTransactionStore::claim($token, self::BIND_PASS_KIND, true));
}
public function maybe_flush_rewrite_rules(): void {
$version = defined('AKSRT_VERSION') ? AKSRT_VERSION . ':oauth-v2' : 'oauth-v2';
if (get_option('aksrt_oauth_routes_flushed') !== $version) {
@@ -186,6 +293,17 @@ final class OAuth {
if (!OAuthProviderCatalog::master_enabled() || !in_array($provider, OAuthProviderCatalog::enabled_login_keys(), true)) {
$this->redirect_error('provider_disabled', $provider, $action);
}
$user_id = get_current_user_id();
if ($action === 'bind' && $user_id <= 0) {
wp_safe_redirect(function_exists('aksrt_get_login_url') ? aksrt_get_login_url() : wp_login_url());
exit;
}
if ($action === 'bind') {
$bind_pass = sanitize_text_field((string) ($_GET['bind_pass'] ?? ''));
if (!self::validate_bind_pass($bind_pass, $provider, $user_id, true)) {
$this->redirect_error('bind_verification_required', $provider, $action);
}
}
if (OAuthProviderCatalog::proxy_mode() === 'client') {
$url = OAuthProxyClient::get_instance()->get_proxy_auth_url($provider, $action);
if ($url === '') {
@@ -194,11 +312,6 @@ final class OAuth {
wp_redirect($url); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect
exit;
}
$user_id = get_current_user_id();
if ($action === 'bind' && $user_id <= 0) {
wp_safe_redirect(function_exists('aksrt_get_login_url') ? aksrt_get_login_url() : wp_login_url());
exit;
}
$redirect = isset($_GET['redirect_to']) ? OAuthUrlPolicy::validated_redirect((string) $_GET['redirect_to'], home_url('/')) : home_url('/');
$redirect_uri = home_url('/oauth/callback/');
try {
@@ -264,6 +377,12 @@ final class OAuth {
wp_safe_redirect(function_exists('aksrt_get_login_url') ? aksrt_get_login_url() : wp_login_url());
exit;
}
if ($action === 'bind') {
$bind_pass = sanitize_text_field((string) ($_GET['bind_pass'] ?? ''));
if (!self::validate_bind_pass($bind_pass, 'clogin_' . $type, get_current_user_id(), true)) {
$this->redirect_error('bind_verification_required', 'clogin_' . $type, $action);
}
}
$redirect = isset($_GET['redirect_to']) ? OAuthUrlPolicy::validated_redirect((string) $_GET['redirect_to'], home_url('/')) : home_url('/');
try {
$state = OAuthTransactionStore::issue('clogin', [
+1 -8
View File
@@ -117,16 +117,9 @@ final class Service {
return true;
}
public static function login_user(array $post, string $captcha_scene = 'login'): array {
public static function login_user(array $post): array {
$resolved_login = self::resolve_email_to_username((string) ($post['log'] ?? ''));
if (function_exists('aksrt_require_captcha')) {
$captcha_result = aksrt_require_captcha($captcha_scene, $post);
if ($captcha_result !== true) {
return ['success' => false, 'error' => $captcha_result];
}
}
$creds = [
'user_login' => $resolved_login,
'user_password' => (string) ($post['pwd'] ?? ''),
+7 -14
View File
@@ -10,7 +10,7 @@ defined('ABSPATH') || exit;
trait ServicePart1a {
public static function register_user(array $post): array {
public static function register_user(array $post, bool $verification_completed = false, bool $defer_login = false): array {
$user_login = sanitize_user(wp_unslash($post['user_login'] ?? ''), true);
$user_email = sanitize_email(wp_unslash($post['user_email'] ?? ''));
$password = (string) ($post['user_pass'] ?? '');
@@ -19,7 +19,7 @@ trait ServicePart1a {
if (
class_exists('\AKSRT\Settings\Settings')
&& \AKSRT\Settings\Settings::signup_verification_enabled()
&& empty($post['_auth_code_verified'])
&& !$verification_completed
) {
return ['success' => false, 'error' => __('请先完成邮箱或手机验证', 'aksrt')];
}
@@ -83,13 +83,6 @@ trait ServicePart1a {
return ['success' => false, 'error' => $password_check];
}
if (empty($post['_auth_code_verified']) && function_exists('aksrt_require_captcha')) {
$captcha_result = aksrt_require_captcha('register', $post);
if ($captcha_result !== true) {
return ['success' => false, 'error' => $captcha_result];
}
}
$ref_service = class_exists('\AKSRT\Payment\ReferralAttribution')
? \AKSRT\Payment\ReferralAttribution::get_instance()
: null;
@@ -143,15 +136,15 @@ trait ServicePart1a {
return ['success' => false, 'error' => __('注册成功,但读取用户信息失败,请联系管理员', 'aksrt')];
}
if (
empty($post['_defer_login'])
&& !empty($post['_auth_code_verified'])
&& class_exists('\AKSRT\Settings\Settings')
if (
!$defer_login
&& $verification_completed
&& class_exists('\AKSRT\Settings\Settings')
&& \AKSRT\Settings\Settings::signup_verification_enabled()
) {
self::mark_email_verified($user_id);
}
if (empty($post['_defer_login'])) {
if (!$defer_login) {
wp_set_auth_cookie((int) $user_id, true);
do_action('wp_login', $user->user_login, $user);
}
+3 -10
View File
@@ -15,7 +15,7 @@ trait ServicePart1b {
* @param array $post POST 数据 (phone, code, password, ref)
* @return array{success: bool, user_id?: int, error?: string, redirect?: string}
*/
public static function register_by_phone(array $post): array {
public static function register_by_phone(array $post, bool $verification_completed = false, bool $defer_login = false): array {
$phone = sanitize_text_field((string) ($post['phone'] ?? ''));
$pass = (string) ($post['password'] ?? '');
$confirm = (string) ($post['password_confirm'] ?? '');
@@ -24,7 +24,7 @@ trait ServicePart1b {
if (!preg_match('/^1[3-9]\d{9}$/', $phone)) {
return ['success' => false, 'error' => __('手机号格式不正确', 'aksrt')];
}
if (empty($post['_auth_code_verified'])) {
if (!$verification_completed) {
return ['success' => false, 'error' => __('请先完成手机验证', 'aksrt')];
}
if ($username === '' || !validate_username($username) || mb_strlen($username) < 3 || mb_strlen($username) > 60) {
@@ -111,7 +111,7 @@ trait ServicePart1b {
// 自动登录
$user = get_userdata($user_id);
if (empty($post['_defer_login'])) {
if (!$defer_login) {
wp_set_current_user($user_id);
wp_set_auth_cookie($user_id, true);
if ($user instanceof \WP_User) {
@@ -150,13 +150,6 @@ trait ServicePart1b {
return ['success' => false, 'error' => __('手机号格式不正确', 'aksrt')];
}
if (function_exists('aksrt_require_captcha')) {
$captcha_result = aksrt_require_captcha('bind_phone', $post);
if ($captcha_result !== true) {
return ['success' => false, 'error' => $captcha_result];
}
}
// 验证 SMS 验证码
if (class_exists('\AKSRT\Sms\SMS')) {
$verify_result = \AKSRT\Sms\SMS::get_instance()->verify_code($phone, $code);
+38 -11
View File
@@ -102,10 +102,19 @@ final class WeixinGzh {
if (is_wp_error($context)) {
return $this->error_response($context->get_error_message(), 403);
}
$config = self::settings();
if ((string) ($config['gzh_type'] ?? '') === 'subscribe') {
return $this->code_mode_response($config, $context);
}
$config = self::settings();
$mode = (string) ($config['gzh_type'] ?? '');
if ($context['action'] === 'bind') {
$data = $this->request_data($request);
$bind_pass = sanitize_text_field((string) ($data['bind_pass'] ?? ''));
if (!OAuth::validate_bind_pass($bind_pass, 'weixingzh', $context['user_id'], false)) {
return $this->error_response(__('请先完成人机验证后再绑定', 'aksrt'), 403);
}
$context['bind_pass'] = $bind_pass;
}
if ($mode === 'subscribe') {
return $this->code_mode_response($config, $context);
}
$appid = trim((string) ($config['appid'] ?? ''));
$secret = trim((string) ($config['appsecret'] ?? ''));
@@ -118,9 +127,10 @@ final class WeixinGzh {
'openid' => '',
'profile' => [],
'redirect_to' => $context['redirect_to'],
'action' => $context['action'],
'user_id' => $context['user_id'],
], self::SCAN_TTL, true);
'action' => $context['action'],
'user_id' => $context['user_id'],
'bind_pass' => (string) ($context['bind_pass'] ?? ''),
], self::SCAN_TTL, true);
if (!$this->store_scene_mapping($scene, $transaction)) {
throw new \RuntimeException('scene_mapping_failed');
}
@@ -211,11 +221,21 @@ final class WeixinGzh {
if (is_wp_error($context)) {
return $this->error_response($context->get_error_message(), 403);
}
$bind_pass = '';
if ($context['action'] === 'bind') {
$bind_pass = sanitize_text_field((string) ($data['bind_pass'] ?? ''));
if (!OAuth::validate_bind_pass($bind_pass, 'weixingzh', $context['user_id'], false)) {
return $this->error_response(__('请先完成人机验证后再绑定', 'aksrt'), 403);
}
}
$claimed = $this->claim_code_transaction($code, $context);
if (!$claimed) {
return $this->error_response(__('验证码无效、已过期或尝试次数过多', 'aksrt'), 400);
}
$payload = is_array($claimed['payload'] ?? null) ? $claimed['payload'] : [];
if ($context['action'] === 'bind') {
$payload['bind_pass'] = $bind_pass;
}
return $this->complete_browser_identity($payload);
}
@@ -297,9 +317,12 @@ final class WeixinGzh {
$html .= '<form class="aksrt-weixingzh-code-form" data-aksrt-weixingzh-code>';
$html .= '<p class="muted-2-color em09">' . esc_html(sprintf(__('关注公众号后发送「%s」获取验证码', 'aksrt'), $keyword)) . '</p>';
$html .= '<input type="hidden" name="action" value="' . esc_attr($context['action']) . '" />';
$html .= '<input type="hidden" name="user_id" value="' . esc_attr((string) $context['user_id']) . '" />';
$html .= '<input type="hidden" name="redirect_to" value="' . esc_attr($context['redirect_to']) . '" />';
$html .= '<input type="text" name="code" class="form-control" minlength="6" maxlength="12" autocomplete="one-time-code" placeholder="' . esc_attr__('请输入验证码', 'aksrt') . '" required />';
$html .= '<input type="hidden" name="user_id" value="' . esc_attr((string) $context['user_id']) . '" />';
$html .= '<input type="hidden" name="redirect_to" value="' . esc_attr($context['redirect_to']) . '" />';
if (!empty($context['bind_pass'])) {
$html .= '<input type="hidden" name="bind_pass" value="' . esc_attr((string) $context['bind_pass']) . '" />';
}
$html .= '<input type="text" name="code" class="form-control" minlength="6" maxlength="12" autocomplete="one-time-code" placeholder="' . esc_attr__('请输入验证码', 'aksrt') . '" required />';
$html .= '<button type="submit" class="but c-blue btn-block mt10">' . esc_html__('登录', 'aksrt') . '</button>';
$html .= '</form>';
return new \WP_REST_Response([
@@ -374,6 +397,10 @@ final class WeixinGzh {
if (!$this->binding_user_matches($payload)) {
return $this->error_response(__('绑定会话与当前用户不匹配', 'aksrt'), 403);
}
$bind_pass = sanitize_text_field((string) ($payload['bind_pass'] ?? ''));
if (!OAuth::validate_bind_pass($bind_pass, 'weixingzh', get_current_user_id(), true)) {
return $this->error_response(__('绑定验证已过期,请重新完成人机验证', 'aksrt'), 403);
}
$result = OAuthAccountService::bind(get_current_user_id(), 'weixingzh', $identity);
if (is_wp_error($result)) {
return $this->error_response($result->get_error_message(), 409);
@@ -382,7 +409,7 @@ final class WeixinGzh {
$redirect = add_query_arg(['oauth_action' => 'bind', 'oauth_provider' => 'weixingzh'], $redirect);
return new \WP_REST_Response([
'success' => true,
'data' => ['logged_in' => false, 'bound' => true, 'status' => 'bound', 'redirect' => $redirect],
'data' => ['logged_in' => false, 'bound' => true, 'status' => 'bound', 'redirect' => $redirect],
], 200);
}
+7 -12
View File
@@ -203,18 +203,13 @@ class Download {
}
}
$need_captcha = function_exists('aksrt_is_captcha_enabled_for') && aksrt_is_captcha_enabled_for('download');
if ($need_captcha && function_exists('aksrt_require_captcha')) {
$cap_payload = isset($params['captcha']) && is_array($params['captcha']) ? $params['captcha'] : [];
// 场景未启用时 require 直接放行;启用时强制校验 token。
if (function_exists('aksrt_is_captcha_enabled_for') && aksrt_is_captcha_enabled_for('download')) {
$verify = aksrt_require_captcha('download', $cap_payload);
if ($verify !== true) {
$msg = is_string($verify) ? $verify : __('人机验证失败', 'aksrt');
return new \WP_Error('aksrt_captcha_failed', $msg, ['status' => 400]);
}
}
}
$cap_payload = isset($params['captcha']) && is_array($params['captcha']) ? $params['captcha'] : [];
$verify = aksrt_require_captcha('download', $cap_payload);
if ($verify !== true) {
$code = is_wp_error($verify) ? $verify->get_error_code() : 'aksrt_captcha_failed';
$msg = is_wp_error($verify) ? $verify->get_error_message() : __('人机验证失败', 'aksrt');
return new \WP_Error($code ?: 'aksrt_captcha_failed', $msg, ['status' => 400]);
}
// Purchase is one-shot via cashier (resource order). Token issue does not re-deduct.
$config = $this->config();
+23 -10
View File
@@ -284,15 +284,18 @@ class CommentActions {
], 403);
}
if (function_exists('aksrt_require_captcha')) {
$captcha_result = aksrt_require_captcha('comment', $params);
if ($captcha_result !== true) {
return new \WP_REST_Response([
'success' => false,
'code' => 'captcha_failed',
'message' => (string) $captcha_result,
], 400);
}
$captcha_result = aksrt_require_captcha('comment', $params);
if ($captcha_result !== true) {
$captcha_error = is_wp_error($captcha_result) ? $captcha_result : null;
$captcha_data = $captcha_error ? $captcha_error->get_error_data() : null;
$captcha_status = is_array($captcha_data) ? (int) ($captcha_data['status'] ?? 400) : 400;
return new \WP_REST_Response([
'success' => false,
'code' => $captcha_error ? ($captcha_error->get_error_code() ?: 'captcha_failed') : 'captcha_failed',
'message' => $captcha_error
? ($captcha_error->get_error_message() ?: __('人机验证失败', 'aksrt'))
: __('人机验证失败', 'aksrt'),
], $captcha_status > 0 ? $captcha_status : 400);
}
$user = wp_get_current_user();
@@ -335,7 +338,17 @@ class CommentActions {
}
}
$comment_id = wp_new_comment(wp_slash($commentdata), true);
$comment_id = null;
if (class_exists('\AKSRT\Auth\Moderation')) {
\AKSRT\Auth\Moderation::get_instance()->begin_captcha_verified_comment_request();
}
try {
$comment_id = wp_new_comment(wp_slash($commentdata), true);
} finally {
if (class_exists('\AKSRT\Auth\Moderation')) {
\AKSRT\Auth\Moderation::get_instance()->end_captcha_verified_comment_request();
}
}
if (is_wp_error($comment_id)) {
return new \WP_REST_Response([
'success' => false,
+9 -4
View File
@@ -363,6 +363,9 @@ class FrontSubmit {
}
$data['posts_id'] = $post_id;
$action = (($data['status'] ?? '') === 'draft') ? 'posts_draft' : 'posts_save';
if ($action !== 'posts_draft') {
$this->verify_captcha();
}
$result = $this->persist($data, $action);
$this->send_result($result);
}
@@ -425,13 +428,15 @@ class FrontSubmit {
}
private function verify_captcha(): void {
if (!function_exists('aksrt_require_captcha')) {
return;
}
$verify = aksrt_require_captcha('front_submit', $_POST);
if ($verify !== true) {
$captcha_error = is_wp_error($verify) ? $verify : null;
$message = $captcha_error
? $captcha_error->get_error_message()
: __('人机验证失败,请重试', 'aksrt');
wp_send_json_error([
'message' => is_string($verify) ? $verify : __('人机验证失败,请重试', 'aksrt'),
'code' => $captcha_error ? ($captcha_error->get_error_code() ?: 'aksrt_captcha_failed') : 'aksrt_captcha_failed',
'message' => $message !== '' ? $message : __('人机验证失败,请重试', 'aksrt'),
], 400);
}
}
+10 -11
View File
@@ -103,17 +103,16 @@ class Signin {
$today_str = $this->today_date();
// 人机验证(若启用)
if (function_exists('aksrt_require_captcha')) {
$body = $request->get_json_params();
$cap_payload = is_array($body) && isset($body['captcha']) && is_array($body['captcha'])
? $body['captcha']
: (is_array($body) ? $body : []);
$verify = aksrt_require_captcha('signin', $cap_payload);
if ($verify !== true) {
$msg = is_string($verify) ? $verify : __('人机验证失败', 'aksrt');
return new \WP_Error('aksrt_captcha_failed', $msg, ['status' => 400]);
}
}
$body = $request->get_json_params();
$cap_payload = is_array($body) && isset($body['captcha']) && is_array($body['captcha'])
? $body['captcha']
: (is_array($body) ? $body : []);
$verify = aksrt_require_captcha('signin', $cap_payload);
if ($verify !== true) {
$code = is_wp_error($verify) ? $verify->get_error_code() : 'aksrt_captcha_failed';
$msg = is_wp_error($verify) ? $verify->get_error_message() : __('人机验证失败', 'aksrt');
return new \WP_Error($code ?: 'aksrt_captcha_failed', $msg, ['status' => 400]);
}
$existing_record = $this->get_record($user_id, $today_str);
if (is_array($existing_record)) {
+16 -5
View File
@@ -47,11 +47,16 @@ class Settings {
'aksrt_identity_secrets',
];
public const SCHEMA_MANAGED_STANDALONE_OPTION_KEYS = [
'aksrt_captcha_config',
];
/**
* 包含 secret 字段的 option key — 空值保留旧值策略。
* 包含 secret 字段的 option key — 永不进入独立导入导出。
*/
public const SECRET_OPTION_KEYS = [
'aksrt_mail_smtp',
'aksrt_captcha_config',
'aksrt_identity_secrets',
];
@@ -601,15 +606,20 @@ class Settings {
return \AKSRT\Identity\SecretStore::status($provider);
}
public static function standalone_export_keys(): array {
return array_values(array_diff(self::STANDALONE_OPTION_KEYS, self::SCHEMA_MANAGED_STANDALONE_OPTION_KEYS, self::SECRET_OPTION_KEYS));
}
/**
* Remove secrets from settings export and backup payloads.
* Remove duplicate schema-managed options and secrets from settings payloads.
*
* @param array<string,mixed> $payload
* @return array<string,mixed>
*/
public static function redact_sensitive_settings(array $payload): array {
if (isset($payload['standalone']) && is_array($payload['standalone'])) {
foreach (self::SECRET_OPTION_KEYS as $option_key) {
$blocked = array_merge(self::SCHEMA_MANAGED_STANDALONE_OPTION_KEYS, self::SECRET_OPTION_KEYS);
foreach ($blocked as $option_key) {
unset($payload['standalone'][$option_key]);
}
}
@@ -623,10 +633,11 @@ class Settings {
* @return array<string,mixed>
*/
public static function filter_sensitive_import(array $standalone): array {
foreach (self::SECRET_OPTION_KEYS as $option_key) {
$blocked = array_merge(self::SCHEMA_MANAGED_STANDALONE_OPTION_KEYS, self::SECRET_OPTION_KEYS);
foreach ($blocked as $option_key) {
unset($standalone[$option_key]);
}
return $standalone;
return array_intersect_key($standalone, array_flip(self::standalone_export_keys()));
}
-56
View File
@@ -17,8 +17,6 @@ class SMS {
}
public function init(): void {
add_action('wp_ajax_aksrt_send_sms_code', [$this, 'ajax_send_code']);
add_action('wp_ajax_nopriv_aksrt_send_sms_code', [$this, 'ajax_send_code']);
add_action('wp_ajax_aksrt_sms_test', [$this, 'ajax_test_provider']);
add_action('wp_ajax_aksrt_sms_status', [$this, 'ajax_provider_status']);
}
@@ -250,60 +248,6 @@ class SMS {
* AJAX Handlers
* ----------------------------------------------------------------*/
/**
* 手机号格式校验(对齐 legacy:支持中国号和国际号)
*/
private function validate_phone(string $phone): bool {
$config = $this->get_provider_config();
$limit_ch = !isset($config['sms_number_limit_ch']) || !empty($config['sms_number_limit_ch']);
if ($limit_ch) {
// 仅中国大陆手机号
return (bool) preg_match('/^1[3-9]\d{9}$/', $phone);
}
// 国际手机号:5 位以上数字,可选 + 前缀
return (bool) preg_match('/^(?:\d{5,}|\+\d{6,})$/', $phone);
}
/**
* AJAX 发送验证码
*/
public function ajax_send_code(): void {
check_ajax_referer('aksrt_sms', '_nonce');
$phone = sanitize_text_field((string) ($_POST['phone'] ?? ''));
if (!$this->validate_phone($phone)) {
wp_send_json_error(['message' => __('手机号格式不正确', 'aksrt')]);
}
$scene = sanitize_key((string) ($_POST['scene'] ?? 'register'));
if (!array_key_exists($scene, function_exists('aksrt_captcha_scenes') ? aksrt_captcha_scenes() : [])) {
$scene = 'register';
}
if (function_exists('aksrt_require_captcha')) {
$captcha_result = aksrt_require_captcha($scene, wp_unslash($_POST));
if ($captcha_result !== true) {
wp_send_json_error(['message' => $captcha_result ?: __('人机验证失败', 'aksrt')]);
}
}
$result = $this->send_code($phone);
if ($result['success']) {
wp_send_json_success([
'message' => $result['message'],
'remaining_time' => $result['remaining_time'] ?? 60,
]);
}
$error_data = ['message' => $result['message']];
if (isset($result['remaining_time'])) {
$error_data['remaining_time'] = $result['remaining_time'];
}
wp_send_json_error($error_data);
}
/**
* AJAX: Test SMS provider (admin only).
*/
+4 -1
View File
@@ -52,8 +52,11 @@ $oauth_email = sanitize_email((string) ($oauth_user_data['email'] ?? ''));
$oauth_provider_name = $is_oauth_bind ? \AKSRT\Auth\Service::get_oauth_provider_name((string) ($oauth_bind_data['provider'] ?? '')) : '';
$oauth_intent = sanitize_key((string) ($aksrt_auth_context['oauth_intent'] ?? ($_GET['oauth_intent'] ?? 'login')));
$oauth_intent = $oauth_intent === 'bind' ? 'bind' : 'login';
$oauth_bind_pass = $oauth_intent === 'bind'
? sanitize_text_field((string) ($aksrt_auth_context['bind_pass'] ?? ($_GET['bind_pass'] ?? '')))
: '';
?>
<div class="sign aksrt-auth<?php echo $oauth_large ? ' oauth-button-lg' : ''; ?>" data-aksrt-auth data-surface="<?php echo esc_attr($unique); ?>" data-initial-tab="<?php echo esc_attr($auth_tab); ?>" data-passwordless-mode="<?php echo esc_attr($passwordless_mode); ?>" data-redirect-to="<?php echo esc_attr($redirect_to); ?>" data-oauth-intent="<?php echo esc_attr($oauth_intent); ?>">
<div class="sign aksrt-auth<?php echo $oauth_large ? ' oauth-button-lg' : ''; ?>" data-aksrt-auth data-surface="<?php echo esc_attr($unique); ?>" data-initial-tab="<?php echo esc_attr($auth_tab); ?>" data-passwordless-mode="<?php echo esc_attr($passwordless_mode); ?>" data-redirect-to="<?php echo esc_attr($redirect_to); ?>" data-oauth-intent="<?php echo esc_attr($oauth_intent); ?>" data-bind-pass="<?php echo esc_attr($oauth_bind_pass); ?>">
<?php if ($show_logo) : ?>
<div class="sign-logo"><?php echo function_exists('aksrt_get_auth_sign_logo_html') ? aksrt_get_auth_sign_logo_html() : ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div>
<?php endif; ?>
@@ -115,16 +115,20 @@ test('legacy authentication templates and scripts are removed', () => {
test('unified handlers enforce nonce, captcha, rate limits, configured channels, and generic errors', () => {
const handlers = read('src/Admin/UserControllerAuthPhoneOperations.php');
const service = read('src/Auth/Service.php');
const serviceA = read('src/Auth/ServicePart1a.php');
const serviceB = read('src/Auth/ServicePart1b.php');
assert.match(handlers, /auth_verify_rest_nonce/);
assert.match(handlers, /aksrt_require_captcha/);
assert.match(handlers, /private function auth_require_captcha/);
assert.match(handlers, /get_error_code\(\)/);
assert.match(handlers, /get_error_message\(\)/);
assert.match(handlers, /auth_rate_limited/);
assert.match(handlers, /auth_channel_allowed/);
['auth_code', 'auth_login', 'auth_register', 'auth_reset_password'].forEach((method) => {
const start = handlers.indexOf(`public function ${method}(`);
const end = handlers.indexOf('\n\tpublic function ', start + 1);
const body = handlers.slice(start, end === -1 ? handlers.length : end);
assert.match(body, /aksrt_require_captcha/, method);
assert.match(body, /auth_require_captcha/, method);
});
assert.match(handlers, /passwordless_login_type/);
assert.match(handlers, /signup_verification_type/);
@@ -132,6 +136,10 @@ test('unified handlers enforce nonce, captcha, rate limits, configured channels,
assert.match(handlers, /账号或密码错误/);
assert.match(handlers, /账号或验证码错误/);
assert.match(service, /signin_phone_enabled/);
assert.doesNotMatch(service + serviceA + serviceB, /aksrt_require_captcha/);
assert.doesNotMatch(service + serviceA + serviceB, /_auth_code_verified|_defer_login/);
assert.doesNotMatch(service, /captcha_scene/);
assert.match(handlers, /public function user_bind_phone[\s\S]*?auth_require_captcha\('bind_phone'/);
assert.doesNotMatch(service, /login_by_phone_password|login_by_phone_code/);
});
@@ -0,0 +1,64 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
test('comment REST admission is request-local and preprocess keeps native captcha enforcement', () => {
const controller = read('src/Core/CommentActions.php');
const moderation = read('src/Auth/Moderation.php');
assert.match(controller, /aksrt_require_captcha\('comment', \$params\)/);
assert.match(controller, /begin_captcha_verified_comment_request\(\)[\s\S]*?try \{[\s\S]*?wp_new_comment[\s\S]*?finally \{[\s\S]*?end_captcha_verified_comment_request\(\)/);
assert.match(moderation, /private \$captcha_verified_comment_request_depth = 0/);
assert.match(moderation, /if \(\$this->captcha_verified_comment_request_depth === 0\) \{/);
assert.doesNotMatch(controller + moderation, /function_exists\('aksrt_require_captcha'\)/);
assert.doesNotMatch(controller + moderation, /\$_(?:POST|REQUEST)\[[^\]]*captcha[^\]]*verified|set_transient\([^\n]*comment/i);
});
test('captcha business failures preserve WP_Error codes and never stringify opaque results', () => {
const sources = [
read('src/Core/CommentActions.php'),
read('src/Auth/Moderation.php'),
read('src/Admin/UserControllerAuthPhoneOperations.php'),
read('inc/modules/user-parts/account-ajax.php'),
read('src/Core/FrontSubmit.php'),
].join('\n');
assert.match(sources, /get_error_code\(\)/);
assert.match(sources, /get_error_message\(\)/);
assert.doesNotMatch(sources, /\(string\)\s*\$(?:captcha|verify|result)/);
});
test('password and email account flows verify captcha once per distinct HTTP request', () => {
const account = read('inc/modules/user-parts/account-ajax.php');
const methodBody = (action, nextAction = '') => {
const start = account.indexOf(`add_action('${action}'`);
const end = nextAction ? account.indexOf(`add_action('${nextAction}'`, start + 1) : account.length;
return account.slice(start, end === -1 ? account.length : end);
};
const send = methodBody('wp_ajax_aksrt_send_email_code', 'wp_ajax_aksrt_change_password');
const password = methodBody('wp_ajax_aksrt_change_password', 'wp_ajax_aksrt_change_email');
const email = methodBody('wp_ajax_aksrt_change_email');
assert.equal((send.match(/aksrt_require_captcha\(/g) || []).length, 1);
assert.match(send, /aksrt_require_captcha\( \$scene,/);
assert.equal((password.match(/aksrt_require_captcha\(/g) || []).length, 1);
assert.match(password, /aksrt_require_captcha\( 'change_password'/);
assert.equal((email.match(/aksrt_require_captcha\(/g) || []).length, 1);
assert.match(email, /aksrt_require_captcha\( 'change_email'/);
});
test('legacy public SMS-code AJAX surface is removed while SMS service remains', () => {
const sms = read('src/Sms/SMS.php');
assert.doesNotMatch(sms, /aksrt_send_sms_code/);
assert.doesNotMatch(sms, /function ajax_send_code\s*\(/);
assert.match(sms, /public function send_code\(string \$phone\): array/);
assert.match(sms, /public function verify_code\(string \$phone, string \$code\): array/);
});
@@ -0,0 +1,109 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const vm = require('node:vm');
const root = path.resolve(__dirname, '..', '..');
const runtime = fs.readFileSync(path.join(root, 'assets/js/captcha-front.js'), 'utf8');
function createInput(name, value = '') {
return { name, value, remove() {}, nodeType: 1 };
}
function createHarness() {
const fields = new Map();
const challenge = createInput('aksrt_captcha_challenge', 'challenge-token');
const proof = createInput('aksrt_captcha_proof', '');
const verified = createInput('captcha_verified', '0');
fields.set(challenge.name, challenge);
fields.set(proof.name, proof);
fields.set(verified.name, verified);
const container = {
nodeType: 1,
contains: () => true,
querySelector(selector) {
const match = selector.match(/input\[name="([^"]+)"\]/);
return match ? fields.get(match[1]) || null : null;
},
querySelectorAll(selector) {
const value = this.querySelector(selector);
return value ? [value] : [];
},
appendChild(field) { fields.set(field.name, field); },
};
const box = {
nodeType: 1,
parentElement: container,
closest: () => container,
contains: () => false,
querySelector: () => null,
appendChild() {},
innerHTML: '',
};
const mount = {
nodeType: 1,
dataset: {},
parentElement: container,
contains: () => true,
setAttribute() {},
};
const scripts = [];
const document = {
readyState: 'complete',
documentElement: { nodeType: 1 },
querySelectorAll(selector) { return selector === '[data-aksrt-captcha-config]' ? [mount] : []; },
querySelector: () => null,
getElementById(id) { return id === 'captcha-box' ? box : verified; },
createElement(tag) {
if (tag === 'input') return createInput('', '');
return { setAttribute() {}, addEventListener() {}, appendChild() {}, style: {}, dataset: {} };
},
addEventListener() {},
head: { appendChild(script) { scripts.push(script); } },
body: { appendChild() {} },
};
mount.getAttribute = () => JSON.stringify({
provider: 'tencent', scene: 'login', uid: 'captcha-box', vid: 'captcha-verified',
tc: { app_id: '1' }, challenge_url: '/challenge', exchange_url: '/exchange',
});
let providerCallback;
const window = {
fetch(url) {
if (url === '/challenge') return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true, data: { challenge: 'challenge-token' } }) });
if (url === '/exchange') return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true, data: { proof: 'proof-token' } }) });
throw new Error('unexpected URL ' + url);
},
TencentCaptcha: class {
constructor(_id, callback) { providerCallback = callback; }
show() { providerCallback({ ret: 0, ticket: 'ticket', randstr: 'rand' }); }
},
setTimeout,
clearTimeout,
Promise,
};
vm.runInNewContext(runtime, { window, document, Promise, MutationObserver: undefined, setTimeout, clearTimeout, console });
return { api: window.AKSRTCaptcha, container };
}
test('captcha run is single-flight until the owning business request resets', async () => {
const { api, container } = createHarness();
await Promise.resolve();
await Promise.resolve();
const first = api.run('login', container);
const second = api.run('login', container);
const firstPayload = await first;
assert.equal(firstPayload.aksrt_captcha_proof, 'proof-token');
let secondSettled = false;
second.then(() => { secondSettled = true; });
await Promise.resolve();
assert.equal(secondSettled, false, 'concurrent caller settled before the owner reset');
api.reset('login', container);
assert.equal(await second, null);
});
@@ -0,0 +1,181 @@
<?php
/**
* Executable captcha configuration and fail-closed contract harness.
*/
declare(strict_types=1);
namespace {
define('ABSPATH', __DIR__ . '/');
define('MINUTE_IN_SECONDS', 60);
define('MONTH_IN_SECONDS', 2592000);
define('ARRAY_A', 'ARRAY_A');
$GLOBALS['aksrt_test_options'] = [];
$GLOBALS['wp_cache'] = [];
final class HarnessWpdb {
public string $options = 'wp_options';
public function prepare(string $query, ...$args): array { return [$query, $args]; }
public function esc_like(string $value): string { return $value; }
public function get_results($prepared, $format): array { return []; }
public function query($prepared): int {
[, $args] = $prepared;
[$key, $serialized] = $args;
if (!array_key_exists($key, $GLOBALS['aksrt_test_options']) || serialize($GLOBALS['aksrt_test_options'][$key]) !== $serialized) return 0;
unset($GLOBALS['aksrt_test_options'][$key]);
return 1;
}
}
$GLOBALS['wpdb'] = new HarnessWpdb();
final class WP_Error {
public function __construct(private string $code, private string $message, private array $data = []) {}
public function get_error_code(): string { return $this->code; }
public function get_error_message(): string { return $this->message; }
}
function is_wp_error($value): bool { return $value instanceof WP_Error; }
function __(string $text, string $domain = ''): string { return $text; }
function sanitize_key($value): string { return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $value)) ?? ''; }
function sanitize_text_field($value): string { return trim(strip_tags((string) $value)); }
function sanitize_textarea_field($value): string { return sanitize_text_field($value); }
function wp_kses_post($value): string { return strip_tags((string) $value, '<b><strong><em><i><a><p><br>'); }
function esc_url_raw($value): string { return trim((string) $value); }
function wp_unslash($value) { return $value; }
function get_option(string $key, $default = false) { return array_key_exists($key, $GLOBALS['aksrt_test_options']) ? $GLOBALS['aksrt_test_options'][$key] : $default; }
function update_option(string $key, $value): bool { $GLOBALS['aksrt_test_options'][$key] = $value; return true; }
function add_option(string $key, $value, string $deprecated = '', bool $autoload = true): bool {
if (array_key_exists($key, $GLOBALS['aksrt_test_options'])) return false;
$GLOBALS['aksrt_test_options'][$key] = $value;
return true;
}
function delete_option(string $key): bool {
if (!array_key_exists($key, $GLOBALS['aksrt_test_options'])) return false;
unset($GLOBALS['aksrt_test_options'][$key]);
return true;
}
function maybe_serialize($value): string { return serialize($value); }
function maybe_unserialize($value) { $decoded = @unserialize((string) $value); return $decoded === false && $value !== serialize(false) ? $value : $decoded; }
function wp_cache_delete(string $key, string $group = ''): void { unset($GLOBALS['wp_cache'][$group][$key]); }
function wp_salt(string $scheme = 'auth'): string { return 'captcha-harness-' . $scheme; }
function is_ssl(): bool { return true; }
function wp_rand(int $min, int $max): int { return $max; }
function add_action(...$args): void {}
function add_filter(...$args): void {}
function current_user_can(string $capability): bool { return false; }
function get_transient(string $key) { return false; }
function set_transient(string $key, $value, int $ttl): bool { return true; }
function get_current_user_id(): int { return 1; }
function wp_parse_url(string $url, int $component = -1) { return parse_url($url, $component); }
function home_url(string $path = ''): string { return 'https://example.test/' . ltrim($path, '/'); }
function wp_json_encode($value): string { return json_encode($value, JSON_UNESCAPED_SLASHES); }
function wp_generate_uuid4(): string { return '00000000-0000-4000-8000-000000000000'; }
function wp_remote_post(string $url, array $args = []) { return new WP_Error('unexpected_http', 'unexpected HTTP request'); }
function wp_remote_retrieve_response_code($response): int { return 500; }
function wp_remote_retrieve_body($response): string { return ''; }
function esc_html($value): string { return (string) $value; }
function wp_unique_id(string $prefix = ''): string { return $prefix . '1'; }
}
namespace AKSRT\Settings {
final class Settings {
public static function flush_settings_cache(): void {}
public static function sync_native_site_icon(string $value): void {}
}
}
namespace {
require dirname(__DIR__, 2) . '/inc/modules/captcha.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaPermissions.php';
require dirname(__DIR__, 2) . '/src/Admin/SchemaProvider.php';
function check(bool $condition, string $message): void {
if (!$condition) {
throw new RuntimeException($message);
}
}
check(aksrt_captcha_default_config()['enabled_scenarios'] === [], 'captcha scenarios are not opt-in');
check(!in_array('domain', array_keys(aksrt_captcha_provider_defaults('cloudflare')), true), 'Cloudflare domain remains allowed');
$GLOBALS['aksrt_test_options']['aksrt_captcha_config'] = [
'enabled' => true,
'active_provider' => 'cloudflare',
'enabled_scenarios' => ['login'],
'cloudflare' => [
'site_key' => 'site-key',
'secret_key' => 'stored-secret',
'theme' => 'dark',
'size' => 'compact',
'domain' => 'should-be-dropped.test',
],
];
aksrt_captcha_config_reset();
$provider = \AKSRT\Admin\SchemaProvider::get_instance();
$read = $provider->read_settings('permissions', 'login');
check(($read['cloudflare']['secret_key'] ?? '') === '***', 'nested secret was not masked');
check(!isset($read['cloudflare']['domain']), 'unknown provider key was exposed');
$provider->write_settings('permissions', [
'cloudflare' => [
'site_key' => 'new-site-key',
'secret_key' => '***',
'theme' => 'light',
'size' => 'normal',
'domain' => 'ignored.test',
],
], 'login');
$stored = $GLOBALS['aksrt_test_options']['aksrt_captcha_config']['cloudflare'];
check($stored['secret_key'] === 'stored-secret', 'masked secret did not preserve stored value');
check($stored['site_key'] === 'new-site-key', 'public provider field was not updated');
check(!isset($stored['domain']), 'unknown provider key was persisted');
$provider->write_settings('permissions', [
'cloudflare' => ['secret_key' => ''],
], 'login');
check($GLOBALS['aksrt_test_options']['aksrt_captcha_config']['cloudflare']['secret_key'] === 'stored-secret', 'empty secret did not preserve stored value');
$provider->write_settings('permissions', [
'__clear_secrets' => ['cloudflare.secret_key'],
], 'login');
check($GLOBALS['aksrt_test_options']['aksrt_captcha_config']['cloudflare']['secret_key'] === '', 'explicit nested secret clear failed');
$GLOBALS['aksrt_test_options']['aksrt_captcha_config'] = [
'enabled' => true,
'active_provider' => '',
'enabled_scenarios' => ['login'],
];
aksrt_captcha_config_reset();
$failed_closed = aksrt_require_captcha('login', []);
check(is_wp_error($failed_closed), 'enabled scene with missing provider failed open');
check($failed_closed->get_error_code() === 'aksrt_captcha_config', 'fail-closed error code mismatch');
$GLOBALS['aksrt_test_options']['aksrt_captcha_config'] = [
'enabled' => true,
'active_provider' => 'cloudflare',
'enabled_scenarios' => ['login', 'comment'],
'cloudflare' => ['site_key' => 'site', 'secret_key' => 'secret', 'theme' => 'auto', 'size' => 'normal'],
];
aksrt_captcha_config_reset();
$_COOKIE[AKSRT_CAPTCHA_BROWSER_COOKIE] = str_repeat('A', 43);
$challenge = aksrt_captcha_issue_challenge('login', 'cloudflare');
check(aksrt_captcha_validate_challenge($challenge, 'login', 'cloudflare', false), 'valid scene challenge failed');
check(!aksrt_captcha_validate_challenge($challenge, 'comment', 'cloudflare', false), 'cross-scene challenge accepted');
check(!aksrt_captcha_validate_challenge($challenge, 'login', 'geetest', false), 'cross-provider challenge accepted');
$_COOKIE[AKSRT_CAPTCHA_BROWSER_COOKIE] = str_repeat('B', 43);
check(!aksrt_captcha_validate_challenge($challenge, 'login', 'cloudflare', false), 'cross-browser challenge accepted');
$_COOKIE[AKSRT_CAPTCHA_BROWSER_COOKIE] = str_repeat('A', 43);
check(aksrt_captcha_validate_challenge($challenge, 'login', 'cloudflare', true), 'valid challenge consume failed');
check(!aksrt_captcha_validate_challenge($challenge, 'login', 'cloudflare', true), 'challenge replay accepted');
$proof = aksrt_captcha_issue_proof('login', 'cloudflare');
check(!aksrt_captcha_consume_proof($proof, 'comment', 'cloudflare'), 'cross-scene proof accepted');
check(aksrt_captcha_consume_proof($proof, 'login', 'cloudflare'), 'valid proof consume failed');
check(!aksrt_captcha_consume_proof($proof, 'login', 'cloudflare'), 'proof replay accepted');
$headers = aksrt_tencent_tc3_sign('id', 'key', 'captcha', 1700000000, '2023-11-14', 'DescribeCaptchaResult', '2019-07-22', '{}');
check(str_starts_with($headers['Authorization'], 'TC3-HMAC-SHA256 Credential=id/2023-11-14/captcha/tc3_request'), 'TC3 authorization scope mismatch');
echo "Captcha security harness passed.\n";
}
@@ -105,6 +105,18 @@ test('public theme does not inject a global focus-visible ring', () => {
assert.doesNotMatch(publicMinCss, globalFocusRule);
});
test('non-draft front-submit updates require one server captcha admission', () => {
const controller = read('src/Core/FrontSubmit.php');
const start = controller.indexOf('public function ajax_update_post(): void');
const end = controller.indexOf('\n public function ajax_delete_draft', start);
const body = controller.slice(start, end);
assert.match(body, /\$action = \(\(\$data\['status'\] \?\? ''\) === 'draft'\) \? 'posts_draft' : 'posts_save';/);
assert.match(body, /if \(\$action !== 'posts_draft'\) \{\s*\$this->verify_captcha\(\);\s*\}/);
assert.match(controller, /get_error_code\(\)/);
assert.match(controller, /get_error_message\(\)/);
});
test('front submit TinyMCE status bar shows one live character counter', () => {
const editor = read('assets/js/front-submit-editor.js');
const submitCss = read('assets/css/frontend/pages/submit.css');
@@ -119,6 +119,20 @@ test('PHP and JavaScript OAuth schema field keys stay in parity', () => {
const jsKeys = [...js.matchAll(/\{ key: '([^']+)', label:/g)].map((match) => match[1])
assert.deepEqual([...new Set(phpKeys)].sort(), [...new Set(jsKeys)].sort())
})
test('OAuth bind starts require a one-time captcha bind pass', () => {
const oauth = read('src/Auth/OAuth.php')
const weixingzh = read('src/Auth/WeixinGzh.php')
const bindings = read('inc/helpers/user-center-renderers/settings-bindings.php')
assert.match(oauth, /\/oauth\/bind-pass/)
assert.match(oauth, /BIND_PASS_KIND = 'oauth_bind_pass'/)
assert.match(oauth, /OAuthTransactionStore::issue\(self::BIND_PASS_KIND/)
assert.match(oauth, /OAuthTransactionStore::claim\(\$token, self::BIND_PASS_KIND, true\)/)
assert.match(oauth, /validate_bind_pass\(\$bind_pass, \$provider, \$user_id, true\)/)
assert.match(oauth, /validate_bind_pass\(\$bind_pass, 'clogin_' \. \$type, get_current_user_id\(\), true\)/)
assert.match(weixingzh, /OAuth::validate_bind_pass\(\$bind_pass, 'weixingzh'/)
assert.match(bindings, /aksrt_render_captcha\('bind'\)/)
})
test('OAuth result handler maps safe errors and preserves unrelated URL state', () => {
const notices = []
let replaced = ''
@@ -14,7 +14,9 @@ define('ARRAY_A', 'ARRAY_A');
$GLOBALS['wp_options'] = [];
$GLOBALS['wp_cache'] = [];
$GLOBALS['captcha_enabled'] = true;
function aksrt_is_captcha_enabled_for(string $scene): bool { return $scene === 'bind' && !empty($GLOBALS['captcha_enabled']); }
function __(string $text, string $domain = ''): string { return $text; }
function sanitize_key(string $value): string { return preg_replace('/[^a-z0-9_\-]/', '', strtolower($value)) ?? ''; }
function sanitize_text_field(string $value): string { return trim(strip_tags($value)); }
@@ -85,6 +87,7 @@ namespace {
require dirname(__DIR__, 2) . '/src/Auth/OAuthTransactionStore.php';
require dirname(__DIR__, 2) . '/src/Auth/OAuthUrlPolicy.php';
require dirname(__DIR__, 2) . '/src/Auth/OAuthProxyProtocol.php';
require dirname(__DIR__, 2) . '/src/Auth/OAuth.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaUsers.php';
require dirname(__DIR__, 2) . '/src/Admin/SchemaProvider.php';
@@ -102,6 +105,16 @@ namespace {
check(\AKSRT\Auth\OAuthTransactionStore::claim($token, 'direct', true) !== null, 'valid browser failed claim');
check(\AKSRT\Auth\OAuthTransactionStore::claim($token, 'direct', true) === null, 'transaction replay accepted');
$bindPass = \AKSRT\Auth\OAuthTransactionStore::issue('oauth_bind_pass', ['user_id' => 17, 'provider' => 'github'], 300, true);
check(\AKSRT\Auth\OAuth::validate_bind_pass($bindPass, 'github', 17, false), 'valid bind pass peek failed');
check(!\AKSRT\Auth\OAuth::validate_bind_pass($bindPass, 'gitee', 17, true), 'wrong bind provider consumed as valid');
check(!\AKSRT\Auth\OAuth::validate_bind_pass($bindPass, 'github', 18, true), 'wrong bind user consumed as valid');
check(\AKSRT\Auth\OAuth::validate_bind_pass($bindPass, 'github', 17, true), 'valid bind pass claim failed after rejected mismatches');
check(!\AKSRT\Auth\OAuth::validate_bind_pass($bindPass, 'github', 17, true), 'bind pass replay accepted');
$GLOBALS['captcha_enabled'] = false;
check(\AKSRT\Auth\OAuth::validate_bind_pass('', 'github', 17, true), 'disabled bind captcha required a pass');
$GLOBALS['captcha_enabled'] = true;
$expired = \AKSRT\Auth\OAuthTransactionStore::issue('direct', [], 30, true);
foreach ($GLOBALS['wp_options'] as $key => $row) {
if (is_array($row) && ($row['kind'] ?? '') === 'direct') $GLOBALS['wp_options'][$key]['expires_at'] = time() - 1;