feat(smtp): 同一邮箱 60 秒发送冷却,gateway 429 透传 + 前端重发倒计时(gateway v0.5.3)

This commit is contained in:
qing committed 2026-07-29 20:54:56 +08:00
1 parent f4a7aca2fa
commit c5dd8d198d
5 files changed
+77 -12

No files matched your search

+2 -2
View File
@@ -68,7 +68,7 @@
- 极客操作:`kill -HUP <pid>` 热加载规则;`waf -list` 列规则;`waf -test <file>` 校验语法;`waf -check "<input>"` 测试单条输入。
- 配置见 [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 智能体系统)
+4 -3
View File
@@ -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,与其他步骤一致)。
+23 -4
View File
@@ -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 <sstream>
@@ -1108,6 +1108,8 @@ http::response<http::string_body> 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<http::string_body> 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<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, "/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}}));
+23 -2
View File
@@ -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}
+25 -1
View File
@@ -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;
}
});