feat(gateway): 新增重置密码接口并改造前端 forgot 页对接

- gateway 新增 POST /reset_password:复用注册校验链(限流/密码强度/邮箱格式/CAPTCHA/邮箱验证码),最后一步由创建用户改为 UPDATE Users SET password_hash,用户不存在时统一返回成功防枚举
- gateway 新增 POST /reset/send_code:CAPTCHA 强制 + 邮箱双窗口限流,定制重置密码文案
- 前端 forgot.html 弃用 static 被禁用的 /api/proxy_* 接口,对接 gateway 新接口,并改为中文 register.html 风格
This commit is contained in:
qing committed 2026-08-03 22:40:43 +08:00
1 parent dd4fef6dee
commit fb5920d89f
2 files changed
+569 -83

No files matched your search

+334 -83
View File
@@ -1,86 +1,337 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
<title>Forgot Password</title>
<link rel="stylesheet" href="css/theme.css">
<style>
.auth-box{max-width:400px;margin:60px auto;padding:30px;background:#fff;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.06)}
.auth-box h2{text-align:center;margin-bottom:24px;color:#8b6914}
.auth-box label{display:block;margin:12px 0 4px;font-weight:600;color:#555}
.auth-box input[type="text"],.auth-box input[type="password"],.auth-box input[type="email"]{width:100%;padding:10px 12px;border:1.5px solid #ddd;border-radius:8px;font-size:15px;box-sizing:border-box}
.auth-box input:focus{border-color:#c9a96e;outline:none}
.code-row{display:flex;gap:8px;align-items:center}
.code-row input{flex:1}
.btn{width:100%;padding:12px;margin-top:16px;border:none;border-radius:8px;font-size:16px;font-weight:600;cursor:pointer;color:#fff;background:linear-gradient(135deg,#c9a96e,#8b6914)}
.btn:hover{opacity:.9}.btn:disabled{opacity:.5;cursor:not-allowed}
.tip{font-size:13px;margin-top:6px}
.tip-ok{color:#2d8a4e}.tip-err{color:#c0392b}
.back{display:block;text-align:center;margin-top:16px;color:#999;font-size:14px;text-decoration:none}
</style>
</head>
<body>
<div class="auth-box">
<h2>Forgot Password</h2>
<div>
<label>Email</label>
<input type="email" id="email" placeholder="Enter your email">
<div class="code-row">
<input type="text" id="code" placeholder="6-digit code" maxlength="6" inputmode="numeric">
<button class="btn" id="sendBtn" style="width:auto;margin:0;padding:10px 16px">Send Code</button>
</div>
<p id="tip1" class="tip"></p>
<label>New Password</label>
<input type="password" id="newPwd" placeholder="Min 6 characters">
<label>Confirm Password</label>
<input type="password" id="confirmPwd" placeholder="Enter again">
<p id="tip2" class="tip"></p>
<button class="btn" id="resetBtn">Reset Password</button>
<a class="back" href="login.html">Back to Login</a>
</div>
</div>
<script>
var PA="/api/proxy_send_code";
var PH={"Content-Type":"application/json"};
document.getElementById("sendBtn").onclick=function(){
var email=document.getElementById("email").value.trim();
if(!email||!email.includes("@")){document.getElementById("tip1").textContent="Invalid email";document.getElementById("tip1").className="tip tip-err";return}
var btn=document.getElementById("sendBtn");btn.disabled=true;btn.textContent="Sending...";
fetch(PA+"/send_code",{method:"POST",headers:PH,body:JSON.stringify({email:email,subject:"QSONT.me - Password Reset",body:"Your code: {code}\nValid for 10 min."})})
.then(function(r){return r.json()}).then(function(d){
var t1=document.getElementById("tip1");
if(d.success){t1.textContent="Code sent!";t1.className="tip tip-ok"}
else{t1.textContent=d.error||"Failed";t1.className="tip tip-err"}
btn.disabled=false;btn.textContent="Send Code"
}).catch(function(){document.getElementById("tip1").textContent="Network error";document.getElementById("tip1").className="tip tip-err";btn.disabled=false;btn.textContent="Send Code"})
};
document.getElementById("resetBtn").onclick=function(){
var email=document.getElementById("email").value.trim();
var code=document.getElementById("code").value.trim();
var pwd=document.getElementById("newPwd").value;
var cpwd=document.getElementById("confirmPwd").value;
var t2=document.getElementById("tip2");
if(!email||!code){t2.textContent="Enter email and code";t2.className="tip tip-err";return}
if(pwd.length<6){t2.textContent="Password min 6 chars";t2.className="tip tip-err";return}
if(pwd!==cpwd){t2.textContent="Passwords don't match";t2.className="tip tip-err";return}
var btn=document.getElementById("resetBtn");btn.disabled=true;btn.textContent="Verifying...";
fetch("/api/proxy_check_code",{method:"POST",headers:PH,body:JSON.stringify({email:email,code:code})})
.then(function(r){return r.json()}).then(function(d){
if(!d.success){t2.textContent=d.error||"Invalid code";t2.className="tip tip-err";btn.disabled=false;btn.textContent="Reset Password";return}
return fetch("/api/reset_token",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:email})})
}).then(function(r){if(r)return r.json()}).then(function(d){
if(!d||!d.found){t2.textContent="Email not registered";t2.className="tip tip-err";btn.disabled=false;btn.textContent="Reset Password";return}
return fetch("/api/hash",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({password:pwd})})
}).then(function(r){if(r)return r.json()}).then(function(d){
if(!d||!d.hash){t2.textContent="Hash failed";t2.className="tip tip-err";btn.disabled=false;btn.textContent="Reset Password";return}
return fetch("/api/reset_token",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:email})}).then(function(r){return r.json()}).then(function(tk){return fetch("/api/reset_password",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:email,password_hash:d.hash,verify_token:tk.token})})})
}).then(function(r){if(r)return r.json()}).then(function(d){
if(d&&d.success){t2.textContent="Reset success! Redirecting...";t2.className="tip tip-ok";setTimeout(function(){location.href="login.html"},3000)}
else{t2.textContent="Reset failed";t2.className="tip tip-err"}
btn.disabled=false;btn.textContent="Reset Password"
}).catch(function(){t2.textContent="Network error";t2.className="tip tip-err";btn.disabled=false;btn.textContent="Reset Password"})
};
</script>
</body>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
<title>重置密码</title>
<link rel="stylesheet" href="css/theme.css">
</head>
<body>
<div class="card">
<h2>重置密码页面</h2>
<form id="form">
<div>
<label>邮箱号:</label>
<input type="email" id="regemail" placeholder="请输入已注册的邮箱号">
<span id="emlTip"></span>
<div class="code-box">图形验证码:
<input type="text" id="captchaAnswer" placeholder="请输入图形验证码" maxlength="4" autocomplete="off">
<img id="captchaImg" class="captcha-img" alt="加载中…" title="点击刷新验证码">
<span id="captchaTip"></span>
</div>
<div class="code-box">邮箱验证码:
<input type="text" id="code" placeholder="请输入6位验证码" inputmode="numeric" maxlength="6">
<input type="button" id="getCode" value="获取验证码">
<span id="codeTip"></span>
</div>
<label>新密码:</label>
<input type="password" id="regpsw" placeholder="至少6位,需含大小写/数字/特殊字符中至少3类">
<label>确认密码:</label>
<input type="password" id="repwd" placeholder="请再次输入新密码">
<span id="pwdTip"></span>
</div>
<div class="form-actions">
<input id="formsub" type="submit" value="重置密码">
<input id="resetUser" type="reset" value="重置">
<input type="button" value="返回登录" onclick="location.href='login.html'">
</div>
</form>
</div>
<footer class="footer">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2024107064号-2</a>
</footer>
<script src="js/validation.js"></script>
<script>
const form = document.getElementById('form');
const emailInput = document.getElementById('regemail');
const codeInput = document.getElementById('code');
const getCodeButton = document.getElementById('getCode');
const passwordInput = document.getElementById('regpsw');
const repeatPasswordInput = document.getElementById('repwd');
const emlTip = document.getElementById('emlTip');
const pwdTip = document.getElementById('pwdTip');
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');
let codeTarget = ''; // 已发送验证码的目标邮箱(验证码本体由服务端校验,前端不经手)
let cooldownTimer = null; // 重发倒计时(与服务端 smtp 同邮箱 60s 发送冷却对齐)
/* ── 图形验证码(gateway 代理 captcha 服务:GET /captcha/image) ── */
let captchaToken = '';
let captchaLoading = false; // 防重复点击:加载中忽略再次触发
async function refreshCaptcha() {
if (captchaLoading) return;
captchaLoading = true;
captchaToken = '';
captchaInput.value = ''; // 开始刷新时清空输入,避免用户误用旧验证码
captchaImg.style.opacity = '0.5'; // 视觉提示:正在刷新
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 = '点击刷新';
captchaImg.style.opacity = ''; // 恢复透明度
} catch (e) {
captchaImg.removeAttribute('src');
captchaImg.alt = '加载失败,点击重试';
} finally {
captchaLoading = false;
}
}
captchaImg.addEventListener('click', refreshCaptcha);
refreshCaptcha();
// 发送成功后进入倒计时,避免用户触发服务端 smtp 同邮箱 60s 发送冷却
function startResendCooldown(seconds) {
clearInterval(cooldownTimer);
let remain = seconds;
getCodeButton.disabled = true;
getCodeButton.value = `${remain}s 后重发`;
cooldownTimer = setInterval(function () {
remain -= 1;
if (remain <= 0) {
clearInterval(cooldownTimer);
cooldownTimer = null;
getCodeButton.disabled = false;
getCodeButton.value = '获取验证码';
} else {
getCodeButton.value = `${remain}s 后重发`;
}
}, 1000);
}
form.addEventListener('reset', function () {
clearTipStyle();
codeTarget = '';
captchaInput.value = '';
refreshCaptcha();
});
function clearTipStyle() {
emlTip.innerHTML = '';
pwdTip.innerHTML = '';
codeTip.innerHTML = '';
captchaTip.innerHTML = '';
emlTip.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');
captchaTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
}
clearTipStyle();
function checkEmail() {
const result = FormValidators.email(emailInput.value);
emlTip.innerHTML = '';
emlTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
emlTip.textContent = result.message;
emlTip.classList.add(result.className);
return result.valid ? result.value : false;
}
function checkPassword() {
const result = FormValidators.password(passwordInput.value, 6);
pwdTip.innerHTML = '';
pwdTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
if (!result.valid) {
pwdTip.textContent = result.message;
pwdTip.classList.add(result.className);
return false;
}
if (result.value !== repeatPasswordInput.value) {
pwdTip.textContent = '两次输入的密码不一致!';
pwdTip.classList.add('tip-yellow');
return false;
}
pwdTip.textContent = result.message;
pwdTip.classList.add(result.className);
return result.value;
}
function checkCode() {
const code = codeInput.value.trim();
codeTip.innerHTML = '';
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
if (!codeTarget) {
codeTip.textContent = '请先获取验证码!';
codeTip.classList.add('tip-red');
return false;
}
if (emailInput.value.trim() !== codeTarget) {
codeTip.textContent = '邮箱已修改,请重新获取验证码!';
codeTip.classList.add('tip-red');
return false;
}
if (!/^\d{6}$/.test(code)) {
codeTip.textContent = '验证码必须是6位数字!';
codeTip.classList.add('tip-yellow');
return false;
}
// 验证码正确性由服务端在重置提交时校验(一次性消费)
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);
repeatPasswordInput.addEventListener('focus', checkPassword);
passwordInput.addEventListener('input', checkPassword);
repeatPasswordInput.addEventListener('input', checkPassword);
passwordInput.addEventListener('blur', checkPassword);
repeatPasswordInput.addEventListener('blur', checkPassword);
emailInput.addEventListener('focus', checkEmail);
emailInput.addEventListener('input', function () {
checkEmail();
if (codeTarget) {
checkCode();
}
});
emailInput.addEventListener('blur', checkEmail);
codeInput.addEventListener('focus', checkCode);
codeInput.addEventListener('input', checkCode);
codeInput.addEventListener('blur', checkCode);
// 发送重置密码验证码:/reset/send_code(CAPTCHA 必填,gateway 强制校验防邮件轰炸)
getCodeButton.addEventListener('click', async function () {
if (getCodeButton.disabled) return; // 防重复点击
const email = checkEmail();
if (!email) return;
const captchaAnswer = checkCaptcha();
if (!captchaAnswer) return;
getCodeButton.disabled = true;
codeTip.textContent = '验证码发送中…';
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
codeTip.classList.add('tip-yellow');
let httpStatus = 0;
try {
const res = await fetch('/reset/send_code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
captcha_token: captchaToken,
captcha_answer: captchaAnswer
})
});
httpStatus = res.status;
const responseText = await res.text();
if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE);
let result;
try { result = JSON.parse(responseText); } catch { throw new Error('服务器响应格式异常'); }
if (!res.ok || !result.success) throw new Error(result.message || '验证码发送失败');
codeTarget = email;
codeInput.value = '';
codeTip.textContent = '验证码已发送至邮箱,请查收(10 分钟内有效)';
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
codeTip.classList.add('tip-green');
startResendCooldown(60);
} catch (err) {
codeTip.textContent = err.message || '验证码发送失败,请稍后重试';
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
codeTip.classList.add('tip-red');
if (httpStatus === 429) startResendCooldown(60); // 命中冷却/限流,倒计时对齐
} finally {
// CAPTCHA Token 一次性消费,无论成败都刷新
refreshCaptcha();
if (!cooldownTimer) getCodeButton.disabled = false;
}
});
// 提交重置密码:/reset_password(创建用户改为更新密码;用户不存在时服务端统一返回成功防枚举)
form.addEventListener('submit', async function (e) {
e.preventDefault();
const email = checkEmail();
const password = checkPassword();
const code = checkCode();
if (!email || !password || !code) {
return;
}
const jsonBody = JSON.stringify({
username: email,
password,
email_code: code
});
try {
submitButton.disabled = true;
const res = await fetch('/reset_password', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: jsonBody
});
const responseText = await res.text();
if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE);
let result;
try {
result = JSON.parse(responseText);
} catch {
console.error(`重置密码接口返回非 JSON 内容(HTTP ${res.status}):`, responseText);
throw new Error('服务器响应格式异常,请稍后重试');
}
if (!res.ok) {
throw new Error(result.message || `重置密码失败(HTTP ${res.status})`);
}
if (result.success) {
alert('重置成功,请使用新密码登录');
form.reset();
codeTarget = '';
location.href = 'login.html';
return;
}
alert(result.message || '重置失败');
} catch (err) {
console.error('重置密码请求异常:', err);
alert(err.message || '接口请求失败,请检查后端服务是否启动');
} finally {
submitButton.disabled = false;
}
});
</script>
</body>
</html>