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:
+151
-90
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user