From df518d1adb086ef90a9e8317af5b2749af76c19b Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Tue, 28 Jul 2026 17:29:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B3=A8=E5=86=8C=E5=BC=BA=E5=88=B6?= =?UTF-8?q?=E9=82=AE=E7=AE=B1=E9=AA=8C=E8=AF=81=E7=A0=81=EF=BC=88gateway?= =?UTF-8?q?=20v0.5.0=20=E6=96=B0=E5=A2=9E=20/register/send=5Fcode=EF=BC=8C?= =?UTF-8?q?pymsg=20=E8=B0=83=20smtp=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- gateway/README.md | 25 ++++++-- gateway/src/main.cpp | 111 ++++++++++++++++++++++++++++++++++- static/public/js/register.js | 56 +++++++++++------- 4 files changed, 166 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index dcf2175..5d9ebca 100755 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ 1. 客户端所有请求先打到 `waf:443`(HTTPS),WAF 终止 TLS、运行 coraza 规则匹配,命中则直接返回 403。 2. 未命中规则的请求由 WAF 以明文 HTTP 转发到 `gateway:8443`,并设置 `X-Forwarded-For` / `X-Forwarded-Proto` 头。 -3. `gateway` 对 `/login`、`/register` 做账号鉴权(PBKDF2 密码哈希 + RSA 签名 Token + 可选 CAPTCHA + 速率限制),用户数据通过 pymsg/TCP 调用 `db:8005` 存取。 +3. `gateway` 对 `/login`、`/register` 做账号鉴权(PBKDF2 密码哈希 + RSA 签名 Token + 可选 CAPTCHA + 速率限制;注册需邮箱验证码,经 pymsg/TCP 调 `smtp:8008` 发送与校验),用户数据通过 pymsg/TCP 调用 `db:8005` 存取。 4. 其余请求携带 Token,经 `gateway` 校验后反向代理到 `Proxy.opened` 中放开的内部服务(`static:9200` / `dnfs:8006`)。 5. `db:8005` 与 `ai_review:8007` 在 `Proxy.closed` 中,仅允许内部服务间调用,不对外暴露。 6. `dnfs` 处理节点增删改查时,通过 pymsg/TCP 调用 `db` 服务完成数据持久化,新增节点前调用 `ai_review` 进行内容审核。 diff --git a/gateway/README.md b/gateway/README.md index 6544096..44504e1 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -6,10 +6,11 @@ UniServe Framework 的应用层网关,C++20 实现。 ## 职责 -- **账号鉴权**:`/register` 注册、`/login` 登录,签发 RSA 签名的 Token。 +- **账号鉴权**:`/register` 注册(需邮箱验证码,经 pymsg 调 smtp:8008 校验)、`/login` 登录,签发 RSA 签名的 Token。 - **反向代理**:携带 Token 的请求校验后转发到 `Proxy.opened` 中放开的内部服务。 - **CAPTCHA 接口**:`/captcha` 生成验证码 token + 文本(内存存储,5 分钟过期,一次性使用)。 -- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟,按 `X-Forwarded-For` 客户端 IP 计数(由 WAF 注入)。 +- **邮箱验证码**:`/register/send_code` 发送 6 位验证码邮件(60 秒有效);注册时校验,一次性消费。 +- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟、发验证码 5 次/10 分钟,按 `X-Forwarded-For` 客户端 IP 计数(由 WAF 注入)。 - **OPTIONS 预检**:通配 `*` 返回 CORS 头,允许跨域。 ## 安全特性 @@ -71,9 +72,25 @@ UniServe Framework 的应用层网关,C++20 实现。 ### `POST /register` -请求体与 `/login` 相同。先做密码强度校验、邮箱格式校验、CAPTCHA 校验,再写入 `Users` 表。 +请求体在 `/login` 基础上增加 `email_code`(必填,6 位数字邮箱验证码): -错误码:`400`(密码/邮箱/CAPTCHA 不合规)、`429`(速率超限)、`500`(数据库异常)。 +```json +{ "username": "user@example.com", "password": "...", "email_code": "123456" } +``` + +校验顺序:密码强度 → 邮箱格式 → CAPTCHA(可选)→ **邮箱验证码**(经 pymsg 调 smtp:8008 `verify_email`,一次性消费,fail-closed),全部通过后写入 `Users` 表。 + +错误码:`400`(密码/邮箱/CAPTCHA/邮箱验证码不合规)、`403`(速率超限)、`500`(数据库异常)。 + +### `POST /register/send_code` + +向目标邮箱发送注册验证码(经 pymsg 调 smtp:8008 `send_email`,60 秒有效)。 + +```json +{ "email": "user@example.com" } +``` + +限流 5 次/10 分钟/IP。错误码:`400`(邮箱格式错误)、`403`(速率超限)、`502`(smtp 服务不可用)、`500`(发送失败)。 ### `GET/POST *`(反向代理) diff --git a/gateway/src/main.cpp b/gateway/src/main.cpp index e5e5530..0e88a60 100644 --- a/gateway/src/main.cpp +++ b/gateway/src/main.cpp @@ -26,7 +26,7 @@ using namespace qing; namespace json = boost::json; namespace po = boost::program_options; -constexpr const char *_VER = "0.4.4"; +constexpr const char *_VER = "0.5.0"; ////////////////////////////////// #include @@ -287,6 +287,11 @@ http::response handleRegister( const http::request& req, const std::unordered_map& params); +/* 发送注册邮箱验证码:pymsg 调 smtp 服务 send_email 路由 */ +http::response handleSendEmailCode( + const http::request& req, + const std::unordered_map& params); + http::response handleCaptcha( const http::request& req, const std::unordered_map& params); @@ -326,6 +331,7 @@ static std::unique_ptr _KEYGEN; //static std::unique_ptr _SQLDB; static RateLimiter _LOGIN_RATE; static RateLimiter _REGISTER_RATE; +static RateLimiter _EMAIL_CODE_RATE; // 发送邮箱验证码限流 /* CAPTCHA store: token -> answer (in-memory, with expiry) */ #include @@ -389,6 +395,26 @@ bool verify_captcha_remote(const std::string& token, const std::string& answer) } } +/* 调用 smtp 服务(pymsg/TCP :8008)校验邮箱验证码。 + * 验证码一次性消费;服务不可用或响应异常一律视为校验失败(fail-closed)。 */ +bool verify_email_code_remote(const std::string& email, const std::string& code) { + try { + pmc::net::MsgClient client("127.0.0.1", 8008, "gateway"); + json::value jv_req = { + {"_route", "verify_email"}, + {"email", email}, + {"code", code} + }; + std::string resp = client.call(json::serialize(jv_req), 5.0); + auto jv = json::parse(resp); + auto& obj = jv.as_object(); + if (!obj.at("success").as_bool()) return false; + return obj.at("data").as_object().at("valid").as_bool(); + } catch (const std::exception&) { + return false; + } +} + /* -------- 信号处理回调函数 -------- */ void signalHandler(int signum) { @@ -565,6 +591,12 @@ void registerRoutes(HttpServer &server) { [](const auto& req, const auto& params) { return handleRegister(req, params); }); }); + /* 发送注册邮箱验证码(须在 post("*") 通配符之前注册) */ + server.post("/register/send_code", [](const auto& req, const auto& params) -> http::response { + return wrapperHttp(req, params, + [](const auto& req, const auto& params) { return handleSendEmailCode(req, params); }); + }); + server.post("/logout", [](const auto& req, const auto& params) -> http::response { return wrapperHttp(req, params, [](const auto& req, const auto& params) { return handleLogout(req, params); }); @@ -951,6 +983,14 @@ http::response handleRegister( THROW_EX_CUSTOM(HttpException, 2201, "Invalid CAPTCHA", 400, "/register"); } + // 邮箱验证码校验(smtp 服务,一次性消费;须放在所有本地校验通过之后、写库之前) + auto ecode_it = obj.find("email_code"); + if (ecode_it == obj.end()) + THROW_EX_CUSTOM(HttpException, 3005, "Email verification code required", 400, "/register"); + 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, "/register"); + // generate the hash of password auto password_hash = PBKDF2Hasher::hash_password(password); @@ -983,6 +1023,75 @@ http::response handleRegister( return res; } +/* 处理发送注册邮箱验证码 /register/send_code + * + * 经 pymsg 调 smtp 服务 send_email 路由,向目标邮箱发送 6 位验证码。 + * 限流 5 次/10 分钟/IP;smtp 不可用返回 502(fail-closed)。 */ +http::response handleSendEmailCode( + const http::request& req, + const std::unordered_map& params) +{ + http::response 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 (!_EMAIL_CODE_RATE.check_and_record(client_ip, 5, 600)) + THROW_EX_CUSTOM(HttpException, 3101, "Too many code requests. Please try again later.", + 403, "/register/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, "/register/send_code"); + } + + if (!jv.is_object()) + THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/register/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, "/register/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, "/register/send_code"); + + // 调 smtp 服务(pymsg/TCP :8008)发送验证码 + bool sent = false; + try { + pmc::net::MsgClient client("127.0.0.1", 8008, "gateway"); + json::value jv_req = { + {"_route", "send_email"}, + {"email", email} + }; + std::string resp = client.call(json::serialize(jv_req), 20.0); + auto jv_r = json::parse(resp); + auto& obj_r = jv_r.as_object(); + if (obj_r.at("success").as_bool()) + sent = obj_r.at("data").as_object().at("sent").as_bool(); + } catch (const std::exception&) { + THROW_EX_CUSTOM(HttpException, 3102, "Mail service unavailable", 502, "/register/send_code"); + } + + if (!sent) + THROW_EX_CUSTOM(HttpException, 3103, "Failed to send verification code", 500, "/register/send_code"); + + 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; +} + http::response handleProxy( const http::request& req, const std::unordered_map& params, diff --git a/static/public/js/register.js b/static/public/js/register.js index 7ec5c34..58fa28d 100644 --- a/static/public/js/register.js +++ b/static/public/js/register.js @@ -9,12 +9,10 @@ const pwdTip = document.getElementById('pwdTip'); const codeTip = document.getElementById('codeTip'); const submitButton = document.getElementById('formsub'); -let sendCode = ''; -let codeTarget = ''; +let codeTarget = ''; // 已发送验证码的目标邮箱(验证码本体由服务端校验,前端不经手) form.addEventListener('reset', function () { clearTipStyle(); - sendCode = ''; codeTarget = ''; }); @@ -64,7 +62,7 @@ function checkCode() { codeTip.innerHTML = ''; codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green'); - if (!sendCode) { + if (!codeTarget) { codeTip.textContent = '请先获取验证码!'; codeTip.classList.add('tip-red'); return false; @@ -79,14 +77,7 @@ function checkCode() { codeTip.classList.add('tip-yellow'); return false; } - if (sendCode === 'email_sent') { /* Server-verified, skip client check */ } else if (code !== sendCode) { - codeTip.textContent = '验证码错误,请核对!'; - codeTip.classList.add('tip-red'); - return false; - } - - codeTip.textContent = '验证码正确'; - codeTip.classList.add('tip-green'); + // 验证码正确性由服务端在注册提交时校验(一次性消费) return code; } @@ -102,7 +93,7 @@ repeatPasswordInput.addEventListener('blur', checkPassword); emailInput.addEventListener('focus', checkEmail); emailInput.addEventListener('input', function () { checkEmail(); - if (sendCode) { + if (codeTarget) { checkCode(); } }); @@ -112,19 +103,40 @@ codeInput.addEventListener('focus', checkCode); codeInput.addEventListener('input', checkCode); codeInput.addEventListener('blur', checkCode); -getCodeButton.addEventListener('click', function () { +getCodeButton.addEventListener('click', async function () { + if (getCodeButton.disabled) return; // 防重复点击 const email = checkEmail(); if (!email) { return; } - sendCode = Math.floor(100000 + Math.random() * 900000).toString(); - codeTarget = email; - codeInput.value = ''; - codeTip.textContent = '验证码已发送,请填写验证码'; + getCodeButton.disabled = true; + codeTip.textContent = '验证码发送中…'; codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green'); - codeTip.classList.add('tip-green'); - alert(`验证码已发送至${email},您的验证码:${sendCode}`); + codeTip.classList.add('tip-yellow'); + try { + const res = await fetch('/register/send_code', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }) + }); + const responseText = await res.text(); + 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 = '验证码已发送至邮箱,请查收(60 秒内有效)'; + codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green'); + codeTip.classList.add('tip-green'); + } catch (err) { + codeTip.textContent = err.message || '验证码发送失败,请稍后重试'; + codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green'); + codeTip.classList.add('tip-red'); + } finally { + getCodeButton.disabled = false; + } }); form.addEventListener('submit', async function (e) { @@ -139,7 +151,8 @@ form.addEventListener('submit', async function (e) { } const jsonBody = JSON.stringify({ username: email, - password + password, + email_code: code }); try { @@ -169,7 +182,6 @@ form.addEventListener('submit', async function (e) { if (result.success) { alert('注册成功'); form.reset(); - sendCode = ''; codeTarget = ''; location.href = 'login.html'; return;