feat(smtp): 同一邮箱 60 秒发送冷却,gateway 429 透传 + 前端重发倒计时(gateway v0.5.3)
This commit is contained in:
1 parent
f4a7aca2fa
commit
c5dd8d198d
5 files changed
+77
-12
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user