feat(static): 注册页获取验证码接入图形验证码

配合 gateway v0.5.4 邮件轰炸防护(/register/send_code 强制 CAPTCHA):

- register.html: 邮箱与邮箱验证码之间新增图形验证码区块(图片+输入框,复用 login 页结构)
- register.js:
  - 新增 refreshCaptcha() 复用 /captcha/image 代理路由(与 login.js 一致)
  - 新增 checkCaptcha() 校验图形验证码非空且 Token 已加载
  - 获取验证码按钮点击时先校验图形验证码,再携带 captcha_token + captcha_answer 发送
  - 无论发送成败都刷新图形验证码(Token 一次性消费)
  - 重置表单时清空图形验证码并刷新

方案 A:仅在「获取验证码」时要求 CAPTCHA,注册提交不加(邮箱验证码已是二次验证)
This commit is contained in:
qing committed 2026-08-01 02:42:15 +08:00
1 parent 0ef96c28f6
commit 6ee1eca789
2 files changed
+75 -7

No files matched your search

+66 -4
View File
@@ -7,11 +7,45 @@ const repeatPasswordInput = document.getElementById('repwd');
const emlTip = document.getElementById('emlTip'); const emlTip = document.getElementById('emlTip');
const pwdTip = document.getElementById('pwdTip'); const pwdTip = document.getElementById('pwdTip');
const codeTip = document.getElementById('codeTip'); const codeTip = document.getElementById('codeTip');
const captchaImg = document.getElementById('captchaImg');
const captchaInput = document.getElementById('captchaAnswer');
const captchaTip = document.getElementById('captchaTip');
const submitButton = document.getElementById('formsub'); const submitButton = document.getElementById('formsub');
let codeTarget = ''; // 已发送验证码的目标邮箱(验证码本体由服务端校验,前端不经手) let codeTarget = ''; // 已发送验证码的目标邮箱(验证码本体由服务端校验,前端不经手)
let cooldownTimer = null; // 重发倒计时(与服务端同邮箱 60s 发送冷却对齐) let cooldownTimer = null; // 重发倒计时(与服务端同邮箱 60s 发送冷却对齐)
/* ── 图形验证码(gateway 代理 captcha 服务:GET /captcha/image) ── */
let captchaToken = '';
let captchaLoading = false; // 防重复点击:加载中忽略再次触发
async function refreshCaptcha() {
if (captchaLoading) return;
captchaLoading = true;
captchaToken = '';
try {
const res = await fetch('/captcha/image', { cache: 'no-store' });
if (res.status === 403) { // WAF 拦截:body 非图片,提示后待用户点击重试
captchaImg.removeAttribute('src');
captchaImg.alt = '验证码加载被安全规则拦截,点击重试';
return;
}
if (!res.ok) throw new Error('captcha load failed');
captchaToken = res.headers.get('X-Captcha-Token') || '';
const blob = await res.blob();
if (captchaImg.src && captchaImg.src.startsWith('blob:')) URL.revokeObjectURL(captchaImg.src);
captchaImg.src = URL.createObjectURL(blob);
captchaImg.alt = '点击刷新';
} catch (e) {
captchaImg.removeAttribute('src');
captchaImg.alt = '加载失败,点击重试';
} finally {
captchaLoading = false;
}
}
captchaImg.addEventListener('click', refreshCaptcha);
refreshCaptcha();
// 发送成功后进入倒计时,避免用户触发服务端同邮箱 60s 发送冷却 // 发送成功后进入倒计时,避免用户触发服务端同邮箱 60s 发送冷却
function startResendCooldown(seconds) { function startResendCooldown(seconds) {
clearInterval(cooldownTimer); clearInterval(cooldownTimer);
@@ -34,16 +68,20 @@ function startResendCooldown(seconds) {
form.addEventListener('reset', function () { form.addEventListener('reset', function () {
clearTipStyle(); clearTipStyle();
codeTarget = ''; codeTarget = '';
captchaInput.value = '';
refreshCaptcha();
}); });
function clearTipStyle() { function clearTipStyle() {
emlTip.innerHTML = ''; emlTip.innerHTML = '';
pwdTip.innerHTML = ''; pwdTip.innerHTML = '';
codeTip.innerHTML = ''; codeTip.innerHTML = '';
captchaTip.innerHTML = '';
emlTip.classList.remove('tip-red', 'tip-yellow', 'tip-green'); emlTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
pwdTip.classList.remove('tip-red', 'tip-yellow', 'tip-green'); pwdTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green'); codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
captchaTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
} }
clearTipStyle(); clearTipStyle();
@@ -101,6 +139,23 @@ function checkCode() {
return code; return code;
} }
function checkCaptcha() {
captchaTip.innerHTML = '';
captchaTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
const v = captchaInput.value.trim();
if (!v) {
captchaTip.textContent = '请输入图形验证码!';
captchaTip.classList.add('tip-red');
return false;
}
if (!captchaToken) {
captchaTip.textContent = '图形验证码未加载,请点击图片刷新';
captchaTip.classList.add('tip-red');
return false;
}
return v;
}
passwordInput.addEventListener('focus', checkPassword); passwordInput.addEventListener('focus', checkPassword);
repeatPasswordInput.addEventListener('focus', checkPassword); repeatPasswordInput.addEventListener('focus', checkPassword);
@@ -126,9 +181,9 @@ codeInput.addEventListener('blur', checkCode);
getCodeButton.addEventListener('click', async function () { getCodeButton.addEventListener('click', async function () {
if (getCodeButton.disabled) return; // 防重复点击 if (getCodeButton.disabled) return; // 防重复点击
const email = checkEmail(); const email = checkEmail();
if (!email) { if (!email) return;
return; const captchaAnswer = checkCaptcha();
} if (!captchaAnswer) return;
getCodeButton.disabled = true; getCodeButton.disabled = true;
codeTip.textContent = '验证码发送中…'; codeTip.textContent = '验证码发送中…';
@@ -139,7 +194,11 @@ getCodeButton.addEventListener('click', async function () {
const res = await fetch('/register/send_code', { const res = await fetch('/register/send_code', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }) body: JSON.stringify({
email,
captcha_token: captchaToken,
captcha_answer: captchaAnswer
})
}); });
httpStatus = res.status; httpStatus = res.status;
const responseText = await res.text(); const responseText = await res.text();
@@ -160,6 +219,9 @@ getCodeButton.addEventListener('click', async function () {
codeTip.classList.add('tip-red'); codeTip.classList.add('tip-red');
if (httpStatus === 429) startResendCooldown(60); // 命中冷却/限流,倒计时对齐 if (httpStatus === 429) startResendCooldown(60); // 命中冷却/限流,倒计时对齐
} finally { } finally {
// CAPTCHA Token 一次性消费,无论成败都刷新
captchaInput.value = '';
refreshCaptcha();
if (!cooldownTimer) getCodeButton.disabled = false; if (!cooldownTimer) getCodeButton.disabled = false;
} }
}); });
+9 -3
View File
@@ -13,12 +13,18 @@
<div> <div>
<label>邮箱号:</label> <label>邮箱号:</label>
<input type="email" id="regemail" placeholder="请输入邮箱号"> <input type="email" id="regemail" placeholder="请输入邮箱号">
<div class="code-box">验证码: <span id="emlTip"></span>
<label>图形验证码:</label>
<div style="display:flex;align-items:center;gap:8px">
<input type="text" id="captchaAnswer" placeholder="请输入图形验证码" maxlength="4" autocomplete="off" style="flex:1;min-width:0;width:auto">
<img id="captchaImg" alt="加载中…" title="点击刷新验证码" style="width:120px;height:38px;flex:none;cursor:pointer;border:1.5px solid var(--border);border-radius:10px;box-sizing:border-box">
</div>
<span id="captchaTip"></span>
<div class="code-box">邮箱验证码:
<input type="text" id="code" placeholder="请输入6位验证码" inputmode="numeric" maxlength="6"> <input type="text" id="code" placeholder="请输入6位验证码" inputmode="numeric" maxlength="6">
<input type="button" id="getCode" value="获取验证码"> <input type="button" id="getCode" value="获取验证码">
<span id="codeTip"></span> <span id="codeTip"></span>
</div> </div>
<span id="emlTip"></span>
<label>密码:</label> <label>密码:</label>
<input type="password" id="regpsw" placeholder="密码至少6位"> <input type="password" id="regpsw" placeholder="密码至少6位">
<label>验证密码:</label> <label>验证密码:</label>
@@ -36,6 +42,6 @@
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2024107064号-2</a> <a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2024107064号-2</a>
</footer> </footer>
<script src="js/validation.js"></script> <script src="js/validation.js"></script>
<script src="js/register.js?v=6"></script> <script src="js/register.js?v=7"></script>
</body> </body>
</html> </html>