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

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

No files matched your search

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