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
+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);
});
});
}