From 5275b8bf752eec1094ec9efbcc50c60b8d49646a Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Wed, 29 Jul 2026 21:57:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(smtp):=20=E9=82=AE=E7=AE=B1=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E7=A0=81=E8=BF=9E=E7=BB=AD=E6=A0=A1=E9=AA=8C=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=205=20=E6=AC=A1=E8=87=AA=E5=8A=A8=E4=BD=9C=E5=BA=9F?= =?UTF-8?q?=EF=BC=8C=E9=98=B2=E6=9E=9A=E4=B8=BE=206=20=E4=BD=8D=E6=95=B0?= =?UTF-8?q?=E5=AD=97=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- gateway/README.md | 2 +- smtp/smtp_v2.py | 24 +++++++++++++++++++++--- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a232bc5..e3e8c91 100755 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ ### smtp(邮件服务) - 注册流程的邮箱验证码后端,基于 pymsg(MsgServer,TCP 长度前缀帧 + JSON),监听 127.0.0.1:8008。 -- pymsg 路由:`send_email`(生成 6 位验证码并经 SMTP_SSL 发送,10 分钟有效,发送失败自动作废;同一邮箱 60 秒发送冷却,冷却期拒绝并返回 `retry_after`,不覆盖已发出的有效验证码,发送失败不计入冷却)、`verify_email`(校验,通过即一次性消费防重放,兼容旧字段 `input_captcha`)、`health`。 +- pymsg 路由:`send_email`(生成 6 位验证码并经 SMTP_SSL 发送,10 分钟有效,发送失败自动作废;同一邮箱 60 秒发送冷却,冷却期拒绝并返回 `retry_after`,不覆盖已发出的有效验证码,发送失败不计入冷却)、`verify_email`(校验,通过即一次性消费防重放,连续失败 5 次自动作废并返回 `invalidated`,兼容旧字段 `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 055e110..66b643d 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -78,7 +78,7 @@ UniServe Framework 的应用层网关,C++20 实现。 { "username": "user@example.com", "password": "...", "email_code": "123456" } ``` -校验顺序:密码强度 → 邮箱格式 → CAPTCHA(可选)→ **邮箱验证码**(经 pymsg 调 smtp:8008 `verify_email`,一次性消费,fail-closed),全部通过后写入 `Users` 表。 +校验顺序:密码强度 → 邮箱格式 → CAPTCHA(可选)→ **邮箱验证码**(经 pymsg 调 smtp:8008 `verify_email`,一次性消费,连续失败 5 次自动作废,fail-closed),全部通过后写入 `Users` 表。 错误码:`400`(密码/邮箱/CAPTCHA/邮箱验证码不合规)、`429`(速率超限)、`500`(数据库异常)。 diff --git a/smtp/smtp_v2.py b/smtp/smtp_v2.py index 3007fa1..de9edde 100644 --- a/smtp/smtp_v2.py +++ b/smtp/smtp_v2.py @@ -73,6 +73,7 @@ SMTP_PASSWORD = os.environ.get("__PASSWORD") or os.environ.get("SMTP_PASSWORD", CODE_LEN = 6 CODE_TTL = 600 # 验证码有效期(秒) SEND_INTERVAL = 60 # 同一邮箱两次成功发送的最小间隔(秒) +MAX_VERIFY_ATTEMPTS = 5 # 同一验证码最大校验失败次数,达到即作废(防枚举 6 位数字码) # 多数邮箱服务商(163/QQ 等)强制 From 头与登录账号一致,故默认跟随 SMTP_USER; # 仅当服务商允许自定义发件身份时才在 .env 里显式配 MAIL_FROM 覆盖 MAIL_FROM = os.environ.get("MAIL_FROM") or SMTP_USER @@ -80,7 +81,7 @@ DEFAULT_SUBJECT = "DNFS 邮箱验证" # 有效期文案由 CODE_TTL 派生,改 TTL 不用同步改这里({{code}} 是 f-string 转义) DEFAULT_CONTENT = f"DNFS: 您的邮箱验证码为 {{code}},{CODE_TTL // 60} 分钟内有效。" -code_store = {} # email -> {'code': str, 'expires_at': float} +code_store = {} # email -> {'code': str, 'expires_at': float, 'attempts': int} send_history = {} # email -> 上次成功发送的时间戳(发送冷却用) STORE_LOCK = threading.Lock() @@ -187,7 +188,9 @@ class SmtpServer: content = str(req.get("content") or DEFAULT_CONTENT).replace("{code}", code) with STORE_LOCK: - code_store[email] = {'code': code, 'expires_at': time.time() + CODE_TTL} + code_store[email] = {'code': code, + 'expires_at': time.time() + CODE_TTL, + 'attempts': 0} # 惰性清理过期项 now = time.time() expired = [k for k, v in code_store.items() if v['expires_at'] < now] @@ -223,13 +226,18 @@ class SmtpServer: return {"sent": True, "ttl": CODE_TTL} def _handle_verify_email(self, req): - """校验验证码,验证通过即一次性消费;不存在的 email 安全返回 invalid。""" + """校验验证码,验证通过即一次性消费;不存在的 email 安全返回 invalid。 + + 同一验证码连续校验失败达 MAX_VERIFY_ATTEMPTS 次即作废删除(响应带 + invalidated 标记),防止对 6 位数字码的在线枚举。 + """ email = str(req.get("email", "")).strip() # 兼容旧字段 input_captcha code = str(req.get("code") or req.get("input_captcha") or "").strip() if not email or not code: return {"valid": False, "error": "email and code required"} + invalidated = False with STORE_LOCK: entry = code_store.get(email) if entry and entry['expires_at'] < time.time(): @@ -241,10 +249,20 @@ class SmtpServer: valid = True else: valid = False + if entry: + # 仅对已发出且未过期的验证码计失败次数;达到上限即作废 + entry['attempts'] = entry.get('attempts', 0) + 1 + if entry['attempts'] >= MAX_VERIFY_ATTEMPTS: + del code_store[email] + invalidated = True if valid: logs.info("smtp", f"{email}: 验证通过") return {"valid": True} + if invalidated: + logs.info("smtp", f"{email}: 连续校验失败 {MAX_VERIFY_ATTEMPTS} 次,验证码已作废") + return {"valid": False, "invalidated": True, + "error": f"验证码连续错误 {MAX_VERIFY_ATTEMPTS} 次,已失效,请重新获取"} logs.info("smtp", f"{email}: 验证失败") return {"valid": False, "error": "invalid or expired code"}