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:
1 parent
dd4fef6dee
commit
fb5920d89f
2 files changed
+569
-83
No files matched your search
@@ -294,6 +294,16 @@ http::response<http::string_body> handleSendEmailCode(
|
||||
const http::request<http::string_body>& req,
|
||||
const std::unordered_map<std::string, std::string>& params);
|
||||
|
||||
/* 发送重置密码邮箱验证码:pymsg 调 smtp 服务 send_email 路由(定制重置文案) */
|
||||
http::response<http::string_body> handleResetSendCode(
|
||||
const http::request<http::string_body>& req,
|
||||
const std::unordered_map<std::string, std::string>& params);
|
||||
|
||||
/* 重置密码:与注册流程一致,但最后一步由创建用户改为更新密码 */
|
||||
http::response<http::string_body> handleResetPassword(
|
||||
const http::request<http::string_body>& req,
|
||||
const std::unordered_map<std::string, std::string>& params);
|
||||
|
||||
http::response<http::string_body> handleCaptcha(
|
||||
const http::request<http::string_body>& req,
|
||||
const std::unordered_map<std::string, std::string>& params);
|
||||
@@ -362,6 +372,8 @@ static RateLimiter _LOGIN_RATE;
|
||||
static RateLimiter _REGISTER_RATE;
|
||||
static RateLimiter _EMAIL_CODE_RATE; // 发送邮箱验证码 IP 级限流
|
||||
static RateLimiter _REFRESH_TOKEN_RATE; // Token 刷新限流
|
||||
static RateLimiter _RESET_PASSWORD_RATE; // 重置密码 IP 级限流
|
||||
static RateLimiter _RESET_CODE_RATE; // 发送重置密码验证码 IP 级限流
|
||||
// 邮箱级双窗口限流:同一邮箱在 gateway 层的发送配额。
|
||||
// 配合 IP 限流(IP 可被代理池绕过)与 smtp 同邮箱 60s 冷却(仅防短期重复)。
|
||||
// 双窗口设计:小时窗限制短期轰炸,天窗限制长期骚扰。
|
||||
@@ -832,6 +844,18 @@ void registerRoutes(HttpServer &server) {
|
||||
[](const auto& req, const auto& params) { return handleSendEmailCode(req, params); });
|
||||
});
|
||||
|
||||
/* 发送重置密码邮箱验证码(须在 post("*") 通配符之前注册) */
|
||||
server.post("/reset/send_code", [](const auto& req, const auto& params) -> http::response<http::string_body> {
|
||||
return wrapperHttp(req, params,
|
||||
[](const auto& req, const auto& params) { return handleResetSendCode(req, params); });
|
||||
});
|
||||
|
||||
/* 重置密码(须在 post("*") 通配符之前注册) */
|
||||
server.post("/reset_password", [](const auto& req, const auto& params) -> http::response<http::string_body> {
|
||||
return wrapperHttp(req, params,
|
||||
[](const auto& req, const auto& params) { return handleResetPassword(req, params); });
|
||||
});
|
||||
|
||||
server.post("/logout", [](const auto& req, const auto& params) -> http::response<http::string_body> {
|
||||
return wrapperHttp(req, params,
|
||||
[](const auto& req, const auto& params) { return handleLogout(req, params); });
|
||||
@@ -1709,6 +1733,217 @@ http::response<http::string_body> handleSendEmailCode(
|
||||
return res;
|
||||
}
|
||||
|
||||
/* 发送重置密码邮箱验证码 /reset/send_code
|
||||
*
|
||||
* 与注册发码(/register/send_code)结构一致,区别在定制"重置密码"主题与文案:
|
||||
* 经 pymsg 调 smtp 服务 send_email 路由(subject/content 字段覆盖默认注册文案)。
|
||||
* 限流 5 次/10 分钟/IP;CAPTCHA 强制校验;smtp 不可用返回 502(fail-closed)。 */
|
||||
http::response<http::string_body> handleResetSendCode(
|
||||
const http::request<http::string_body>& req,
|
||||
const std::unordered_map<std::string, std::string>& params)
|
||||
{
|
||||
http::response<http::string_body> res;
|
||||
res.version(req.version());
|
||||
|
||||
// IP rate limit check
|
||||
std::string client_ip = "direct";
|
||||
auto it_host = req.base().find("X-Forwarded-For");
|
||||
if (it_host != req.base().end())
|
||||
client_ip = std::string(it_host->value());
|
||||
if (!_RESET_CODE_RATE.check_and_record(client_ip, 5, 600))
|
||||
THROW_EX_CUSTOM(HttpException, 3201, "Too many code requests. Please try again later.",
|
||||
429, "/reset/send_code");
|
||||
|
||||
// parse the req body
|
||||
json::value jv;
|
||||
try { jv = json::parse(req.body()); }
|
||||
catch (const std::exception&) {
|
||||
THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/reset/send_code");
|
||||
}
|
||||
|
||||
if (!jv.is_object())
|
||||
THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/reset/send_code");
|
||||
|
||||
auto& obj = jv.as_object();
|
||||
auto email_it = obj.find("email");
|
||||
if (email_it == obj.end())
|
||||
THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/reset/send_code");
|
||||
auto email = std::string(email_it->value().as_string());
|
||||
|
||||
// Email format check
|
||||
std::regex email_regex(R"(^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$)");
|
||||
if (!std::regex_match(email, email_regex))
|
||||
THROW_EX_CUSTOM(HttpException, 3004, "Invalid email format", 400, "/reset/send_code");
|
||||
|
||||
// CAPTCHA 强制校验(防邮件轰炸,与 /register/send_code 一致)
|
||||
// 经 captcha:8009 pymsg 校验,Token 一次性消费;fail-closed。
|
||||
auto captcha_token_it = obj.find("captcha_token");
|
||||
auto captcha_answer_it = obj.find("captcha_answer");
|
||||
if (captcha_token_it == obj.end() || captcha_answer_it == obj.end())
|
||||
THROW_EX_CUSTOM(HttpException, 3007, "CAPTCHA required", 400, "/reset/send_code");
|
||||
if (!verify_captcha_remote(std::string(captcha_token_it->value().as_string()),
|
||||
std::string(captcha_answer_it->value().as_string())))
|
||||
THROW_EX_CUSTOM(HttpException, 2201, "Invalid CAPTCHA", 400, "/reset/send_code");
|
||||
|
||||
// 邮箱级双窗口限流(CAPTCHA 通过后才计入配额,避免无效请求消耗限流额度)
|
||||
// 与注册发码共用同一配额,防止重置/注册双通道叠加绕过邮箱轰炸防护
|
||||
if (!_EMAIL_ADDR_RATE_HOUR.check_and_record(email, 3, 3600))
|
||||
THROW_EX_CUSTOM(HttpException, 3104,
|
||||
"Too many code requests for this email. Please try again later.",
|
||||
429, "/reset/send_code");
|
||||
if (!_EMAIL_ADDR_RATE_DAY.check_and_record(email, 10, 86400))
|
||||
THROW_EX_CUSTOM(HttpException, 3105,
|
||||
"Daily email limit exceeded. Please try again tomorrow.",
|
||||
429, "/reset/send_code");
|
||||
|
||||
// 调 smtp 服务(pymsg/TCP :8008)发送重置密码验证码(定制文案)
|
||||
bool sent = false;
|
||||
int retry_after = 0; // smtp 同邮箱发送冷却的剩余秒数,0 表示非冷却拒绝
|
||||
std::string smtp_err; // 冷却拒绝时 smtp 返回的提示文案(仅冷却场景透传客户端)
|
||||
try {
|
||||
pmc::net::MsgClient client(_SMTP_HOST, _SMTP_PORT, "gateway");
|
||||
json::value jv_req = {
|
||||
{"_route", "send_email"},
|
||||
{"email", email},
|
||||
{"subject", "QSONT.me - Password Reset"},
|
||||
{"content", "您正在重置 QSONT.me 账号密码,验证码为 {code},10 分钟内有效。如非本人操作,请忽略本邮件。"}
|
||||
};
|
||||
std::string resp = client.call(json::serialize(jv_req), _TIMEOUT_SMTP_SEND);
|
||||
auto jv_r = json::parse(resp);
|
||||
auto& obj_r = jv_r.as_object();
|
||||
if (obj_r.at("success").as_bool()) {
|
||||
auto& data = obj_r.at("data").as_object();
|
||||
sent = data.at("sent").as_bool();
|
||||
if (!sent) {
|
||||
auto it_ra = data.find("retry_after");
|
||||
if (it_ra != data.end() && it_ra->value().is_int64())
|
||||
retry_after = static_cast<int>(it_ra->value().as_int64());
|
||||
auto it_err = data.find("error");
|
||||
if (it_err != data.end() && it_err->value().is_string())
|
||||
smtp_err = std::string(it_err->value().as_string());
|
||||
}
|
||||
}
|
||||
} catch (const std::exception&) {
|
||||
THROW_EX_CUSTOM(HttpException, 3102, "Mail service unavailable", 502, "/reset/send_code");
|
||||
}
|
||||
|
||||
if (!sent) {
|
||||
// 同邮箱发送冷却:按限流语义返回 429 并透传 smtp 提示文案;
|
||||
// 其余发送失败(含 SMTP 内部错误细节)仍用通用文案,不向外暴露
|
||||
if (retry_after > 0)
|
||||
THROW_EX_CUSTOM(HttpException, 3103,
|
||||
smtp_err.empty() ? "Too many code requests. Please try again later." : smtp_err,
|
||||
429, "/reset/send_code");
|
||||
THROW_EX_CUSTOM(HttpException, 3103, "Failed to send verification code", 500, "/reset/send_code");
|
||||
}
|
||||
|
||||
logs().info("gateway", "重置密码验证码邮件发送成功: " + email, std::nullopt,
|
||||
json::serialize(json::value{{"ip", client_ip}}));
|
||||
|
||||
auto jv_r = json::value{{"success", true}};
|
||||
res.result(http::status::ok);
|
||||
res.set(http::field::content_type, "application/json");
|
||||
res.body() = json::serialize(jv_r);
|
||||
res.prepare_payload();
|
||||
return res;
|
||||
}
|
||||
|
||||
/* 处理重置密码 /reset_password
|
||||
*
|
||||
* 与注册流程一致,最后一步由"创建用户"改为"更新密码":
|
||||
* 校验顺序:密码强度 → 邮箱格式 → CAPTCHA(可选)→ 邮箱验证码(smtp verify_email,一次性)
|
||||
* → 生成 PBKDF2 哈希 → UPDATE Users SET password_hash WHERE username
|
||||
* 防枚举:用户不存在(update 匹配 0 行)时仍统一返回成功,不泄露邮箱是否已注册。
|
||||
* CAPTCHA 走 captcha:8009 远端校验(与前端 /captcha/image 的 token 匹配)。
|
||||
*/
|
||||
http::response<http::string_body> handleResetPassword(
|
||||
const http::request<http::string_body>& req,
|
||||
const std::unordered_map<std::string, std::string>& params)
|
||||
{
|
||||
http::response<http::string_body> res;
|
||||
res.version(req.version());
|
||||
|
||||
// IP rate limit check
|
||||
std::string client_ip = "direct";
|
||||
auto it_host = req.base().find("X-Forwarded-For");
|
||||
if (it_host != req.base().end())
|
||||
client_ip = std::string(it_host->value());
|
||||
if (!_RESET_PASSWORD_RATE.check_and_record(client_ip, 5, 600))
|
||||
THROW_EX_CUSTOM(HttpException, 3301, "Too many reset attempts. Please try again later.",
|
||||
429, "/reset_password");
|
||||
|
||||
// parse the req body
|
||||
json::value jv;
|
||||
try { jv = json::parse(req.body()); }
|
||||
catch (const std::exception&) {
|
||||
THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/reset_password");
|
||||
}
|
||||
|
||||
if (!jv.is_object())
|
||||
THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/reset_password");
|
||||
|
||||
auto& obj = jv.as_object();
|
||||
auto username = std::string(obj.at("username").as_string());
|
||||
auto password = std::string(obj.at("password").as_string());
|
||||
|
||||
// Strong password check
|
||||
auto pw_result = PasswordValidator::validate(password);
|
||||
if (!pw_result.valid)
|
||||
THROW_EX_CUSTOM(HttpException, 3003, pw_result.message, 401, "/reset_password");
|
||||
|
||||
// Email format check
|
||||
std::regex email_regex(R"(^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$)");
|
||||
if (!std::regex_match(username, email_regex))
|
||||
THROW_EX_CUSTOM(HttpException, 3004, "Invalid email format", 400, "/reset_password");
|
||||
|
||||
// CAPTCHA verification(可选,与注册一致;走 captcha 服务远端校验)
|
||||
auto captcha_it = obj.find("captcha_token");
|
||||
auto captcha_ans_it = obj.find("captcha_answer");
|
||||
if (captcha_it != obj.end() && captcha_ans_it != obj.end()) {
|
||||
std::string ct = std::string(captcha_it->value().as_string());
|
||||
std::string ca = std::string(captcha_ans_it->value().as_string());
|
||||
if (!verify_captcha_remote(ct, ca))
|
||||
THROW_EX_CUSTOM(HttpException, 2201, "Invalid CAPTCHA", 400, "/reset_password");
|
||||
}
|
||||
|
||||
// 邮箱验证码校验(smtp 服务,一次性消费;须放在所有本地校验通过之后、写库之前)
|
||||
auto ecode_it = obj.find("email_code");
|
||||
if (ecode_it == obj.end())
|
||||
THROW_EX_CUSTOM(HttpException, 3005, "Email verification code required", 400, "/reset_password");
|
||||
std::string email_code = std::string(ecode_it->value().as_string());
|
||||
if (!verify_email_code_remote(username, email_code))
|
||||
THROW_EX_CUSTOM(HttpException, 3006, "Invalid or expired email verification code", 400, "/reset_password");
|
||||
|
||||
// generate the hash of password
|
||||
auto password_hash = PBKDF2Hasher::hash_password(password);
|
||||
|
||||
// 更新密码:UPDATE Users SET password_hash=? WHERE username=?(经 db 服务 pymsg execute)
|
||||
json::value jv_update = {
|
||||
{"action", "update"},
|
||||
{"table", "Users"},
|
||||
{"values", json::object{
|
||||
{"password_hash", password_hash}
|
||||
}},
|
||||
{"where", json::object{
|
||||
{"username", username}
|
||||
}}
|
||||
};
|
||||
auto jv_result = DbC::call_msg(jv_update);
|
||||
auto obj_r = jv_result.as_object();
|
||||
if (!obj_r["success"].as_bool())
|
||||
THROW_EX_CUSTOM(HttpException, 2202, "Reset Failed", 500, "/reset_password");
|
||||
|
||||
// 防枚举:用户不存在(rowcount=0)时仍统一返回成功,不泄露邮箱是否已注册
|
||||
logs().info("gateway", "重置密码成功: " + username, std::nullopt,
|
||||
json::serialize(json::value{{"ip", client_ip}}));
|
||||
|
||||
auto jv_r = json::value{{"success", true}};
|
||||
res.result(http::status::ok);
|
||||
res.body() = json::serialize(jv_r);
|
||||
res.prepare_payload();
|
||||
return res;
|
||||
}
|
||||
|
||||
http::response<http::string_body> handleProxy(
|
||||
const http::request<http::string_body>& req,
|
||||
const std::unordered_map<std::string, std::string>& params,
|
||||
|
||||
+334
-83
@@ -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>
|
||||
Reference in new issue
Block a user