diff --git a/README.md b/README.md index e614140..a232bc5 100755 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ - 极客操作:`kill -HUP ` 热加载规则;`waf -list` 列规则;`waf -test ` 校验语法;`waf -check ""` 测试单条输入。 - 配置见 [waf/config.yaml](waf/config.yaml),详见 [waf/README.md](waf/README.md)。 -### gateway(应用层网关 · v0.5.2) +### gateway(应用层网关 · v0.5.3) - 内部 HTTP 服务,默认监听 127.0.0.1:8443,不再承载 TLS(TLS 已由 WAF 终止)。 - 通过 `Server.tls = on/off` 配置切换 HTTPS/HTTP 模式([example.ini](gateway/example.ini)),独立部署时仍可启用 TLS。 @@ -136,7 +136,7 @@ ### smtp(邮件服务) - 注册流程的邮箱验证码后端,基于 pymsg(MsgServer,TCP 长度前缀帧 + JSON),监听 127.0.0.1:8008。 -- pymsg 路由:`send_email`(生成 6 位验证码并经 SMTP_SSL 发送,10 分钟有效,发送失败自动作废)、`verify_email`(校验,通过即一次性消费防重放,兼容旧字段 `input_captcha`)、`health`。 +- pymsg 路由:`send_email`(生成 6 位验证码并经 SMTP_SSL 发送,10 分钟有效,发送失败自动作废;同一邮箱 60 秒发送冷却,冷却期拒绝并返回 `retry_after`,不覆盖已发出的有效验证码,发送失败不计入冷却)、`verify_email`(校验,通过即一次性消费防重放,兼容旧字段 `input_captcha`)、`health`。 - 验证码存于进程内存(锁保护 + 惰性过期清理),SMTP 账号通过 `.env` 配置(`SMTP_SERVER` / `SMTP_PORT` / `SMTP_USER` / `__PASSWORD`,`__PASSWORD` 兼容别名 `SMTP_PASSWORD`)。 ### aibox(独立 AI 智能体系统) diff --git a/gateway/README.md b/gateway/README.md index 61e9d84..055e110 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -90,7 +90,7 @@ UniServe Framework 的应用层网关,C++20 实现。 { "email": "user@example.com" } ``` -限流 5 次/10 分钟/IP。错误码:`400`(邮箱格式错误)、`429`(速率超限)、`502`(smtp 服务不可用)、`500`(发送失败)。 +限流 5 次/10 分钟/IP;同一邮箱 60 秒发送冷却(由 smtp 服务执行,冷却期拒绝时返回 `retry_after` 剩余秒数与提示文案,gateway 按 429 透传;已发出且在有效期内的验证码不受影响,发送失败不计入冷却)。错误码:`400`(邮箱格式错误)、`429`(IP 速率超限或同邮箱发送冷却)、`502`(smtp 服务不可用)、`500`(发送失败)。 ### `GET/POST *`(反向代理) @@ -226,14 +226,15 @@ gateway/ ## 版本 -`0.5.2`(见 [src/main.cpp:29](src/main.cpp#L29))。启动时打印: +`0.5.3`(见 [src/main.cpp:30](src/main.cpp#L30))。启动时打印: ``` -Gateway 0.5.2 started +Gateway 0.5.3 started ``` 近期变更: +- `0.5.3`:配合 smtp 同邮箱 60 秒发送冷却——`/register/send_code` 在 smtp 返回 `retry_after` 时按 429 透传其提示文案(其余发送失败仍用通用 500 文案,SMTP 内部错误细节不向外暴露)。 - `0.5.2`:限流错误码统一为 429(登录原 400、注册/发码原 403,并修正 "attampts" 拼写);登录时用户不存在不再兜底 500——空结果统一走 401,并执行 dummy PBKDF2 校验拉平时序防用户名枚举;db 查询失败消息由误名的 "Login (Update Step) Failed" 改为 "Login (Query Step) Failed";pymsg 下游调用超时常量化(`DbC::DEFAULT_TIMEOUT`、`_TIMEOUT_CAPTCHA/_SMTP_VERIFY/_SMTP_SEND`)。 - `0.5.1`:登录成功后通过 db 更新 `Users.last_login`(`"$now"` 哨兵,db 日期适配层转为服务端当前 UTC 时间;更新失败经 THROW_EX_CUSTOM 抛 500,与其他步骤一致)。 diff --git a/gateway/src/main.cpp b/gateway/src/main.cpp index e6b1989..391deb7 100644 --- a/gateway/src/main.cpp +++ b/gateway/src/main.cpp @@ -27,7 +27,7 @@ using namespace qing; namespace json = boost::json; namespace po = boost::program_options; -constexpr const char *_VER = "0.5.2"; +constexpr const char *_VER = "0.5.3"; ////////////////////////////////// #include @@ -1108,6 +1108,8 @@ http::response handleSendEmailCode( // 调 smtp 服务(pymsg/TCP :8008)发送验证码 bool sent = false; + int retry_after = 0; // smtp 同邮箱发送冷却的剩余秒数,0 表示非冷却拒绝 + std::string smtp_err; // 冷却拒绝时 smtp 返回的提示文案(仅冷却场景透传客户端) try { pmc::net::MsgClient client("127.0.0.1", 8008, "gateway"); json::value jv_req = { @@ -1117,14 +1119,31 @@ http::response handleSendEmailCode( 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()) - sent = obj_r.at("data").as_object().at("sent").as_bool(); + 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(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, "/register/send_code"); } - if (!sent) + 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, "/register/send_code"); THROW_EX_CUSTOM(HttpException, 3103, "Failed to send verification code", 500, "/register/send_code"); + } logs().info("gateway", "验证码邮件发送成功: " + email, std::nullopt, json::serialize(json::value{{"ip", client_ip}})); diff --git a/smtp/smtp_v2.py b/smtp/smtp_v2.py index dc9840d..3007fa1 100644 --- a/smtp/smtp_v2.py +++ b/smtp/smtp_v2.py @@ -72,6 +72,7 @@ SMTP_PASSWORD = os.environ.get("__PASSWORD") or os.environ.get("SMTP_PASSWORD", CODE_LEN = 6 CODE_TTL = 600 # 验证码有效期(秒) +SEND_INTERVAL = 60 # 同一邮箱两次成功发送的最小间隔(秒) # 多数邮箱服务商(163/QQ 等)强制 From 头与登录账号一致,故默认跟随 SMTP_USER; # 仅当服务商允许自定义发件身份时才在 .env 里显式配 MAIL_FROM 覆盖 MAIL_FROM = os.environ.get("MAIL_FROM") or SMTP_USER @@ -80,6 +81,7 @@ DEFAULT_SUBJECT = "DNFS 邮箱验证" DEFAULT_CONTENT = f"DNFS: 您的邮箱验证码为 {{code}},{CODE_TTL // 60} 分钟内有效。" code_store = {} # email -> {'code': str, 'expires_at': float} +send_history = {} # email -> 上次成功发送的时间戳(发送冷却用) STORE_LOCK = threading.Lock() @@ -156,10 +158,11 @@ class SmtpServer: # ═══════════════════════════════════════════════════════════════════ def _handle_send_email(self, req): - """生成 6 位数字验证码,存入内存(60s),经 SMTP_SSL 发送邮件。 + """生成 6 位数字验证码,存入内存(TTL 600s),经 SMTP_SSL 发送邮件。 可选字段 subject / content 覆盖默认标题与正文,content 中 {code} - 会被替换为验证码。 + 会被替换为验证码。同一邮箱 SEND_INTERVAL 秒内仅允许成功发送一次, + 冷却期内的请求直接拒绝且不覆盖已发出的有效验证码。 """ email = str(req.get("email", "")).strip() if not email or "@" not in email: @@ -169,6 +172,16 @@ class SmtpServer: logs.error("smtp", f"{email}: SMTP 配置缺失,无法发送") return {"sent": False, "error": "SMTP 服务未配置"} + # 发送冷却:同一邮箱 SEND_INTERVAL 秒内仅放行一次;发送失败不计入, + # 用户可立即重试 + with STORE_LOCK: + remain = SEND_INTERVAL - (time.time() - send_history.get(email, 0.0)) + if remain > 0: + wait = int(remain) + 1 + logs.info("smtp", f"{email}: 发送冷却中,剩余 {wait}s,已拒绝") + return {"sent": False, "retry_after": wait, + "error": f"发送过于频繁,请 {wait} 秒后重试"} + code = "".join(str(random.randint(0, 9)) for _ in range(CODE_LEN)) subject = str(req.get("subject") or DEFAULT_SUBJECT) content = str(req.get("content") or DEFAULT_CONTENT).replace("{code}", code) @@ -198,6 +211,14 @@ class SmtpServer: logs.error("smtp", f"{email}: 验证码发送失败({err})") return {"sent": False, "error": f"验证码发送失败:{err}"} + # 发送成功:记录冷却起点,并惰性清理已失效的冷却记录 + with STORE_LOCK: + send_history[email] = time.time() + stale = [k for k, v in send_history.items() + if time.time() - v > SEND_INTERVAL] + for k in stale: + del send_history[k] + logs.info("smtp", f"{email}: 验证码发送成功({code})") return {"sent": True, "ttl": CODE_TTL} diff --git a/static/public/js/register.js b/static/public/js/register.js index b54a460..86380f0 100644 --- a/static/public/js/register.js +++ b/static/public/js/register.js @@ -10,6 +10,26 @@ const codeTip = document.getElementById('codeTip'); const submitButton = document.getElementById('formsub'); let codeTarget = ''; // 已发送验证码的目标邮箱(验证码本体由服务端校验,前端不经手) +let cooldownTimer = null; // 重发倒计时(与服务端同邮箱 60s 发送冷却对齐) + +// 发送成功后进入倒计时,避免用户触发服务端同邮箱 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(); @@ -114,12 +134,14 @@ getCodeButton.addEventListener('click', async function () { codeTip.textContent = '验证码发送中…'; codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green'); codeTip.classList.add('tip-yellow'); + let httpStatus = 0; try { const res = await fetch('/register/send_code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }) }); + httpStatus = res.status; const responseText = await res.text(); let result; try { result = JSON.parse(responseText); } catch { throw new Error('服务器响应格式异常'); } @@ -130,12 +152,14 @@ getCodeButton.addEventListener('click', async function () { 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 { - getCodeButton.disabled = false; + if (!cooldownTimer) getCodeButton.disabled = false; } });