Compare commits
2
Commits
78ad3e6d57
...
df518d1adb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df518d1adb | ||
|
|
9078c4d051 |
No files matched your search
@@ -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` 进行内容审核。
|
||||
@@ -49,7 +49,7 @@
|
||||
| [db](db/) | Python(pymsg) | 8005 | 通用数据库服务,基于 pymsg 协议(TCP 长度前缀帧 + JSON)的 SQL-over-TCP,SQLAlchemy Core 参数化查询防注入;内置集中式日志收集(Logs 表 + 自动清理) |
|
||||
| [ai_review](ai_review/) | Python(Flask) | 8007 | AI 内容审核服务,DeepSeek LLM + ChromaDB RAG,支持人工反馈纠偏 |
|
||||
| [captcha](captcha/) | Python(pymsg) | 8009 | 验证码服务(pymsg/TCP),生成图形验证码(5 分钟过期、一次性),经 gateway 的 `GET /captcha/image` 代理给浏览器 |
|
||||
| [smtp](smtp/) | Python(Flask) | 8008 | 邮件服务,发送/校验邮箱验证码(/send_email、/verify_email),注册流程的邮件验证码后端,SMTP 账号配置在 .env |
|
||||
| [smtp](smtp/) | Python(pymsg) | 8008 | 邮件服务,发送/校验邮箱验证码(pymsg 路由 send_email/verify_email/health),注册流程的邮件验证码后端,SMTP 账号配置在 .env |
|
||||
| [aibox](aibox/) | Python | 8765 | 独立 AI 智能体系统(DeepSeek + WebSocket),自带技能系统/记忆/任务调度,由 core 拉起但与主干微服务无调用关系 |
|
||||
|
||||
### core(核心模块 · 进程托管服务)
|
||||
@@ -135,9 +135,9 @@
|
||||
|
||||
### smtp(邮件服务)
|
||||
|
||||
- 注册流程的邮箱验证码后端,基于 Flask + waitress,监听 127.0.0.1:8008。
|
||||
- 接口:`POST /send_email`(生成 6 位验证码并经 SMTP_SSL 发送至收件人,60 秒有效)、`POST /verify_email`(校验)。
|
||||
- 验证码存于进程内存,SMTP 账号通过 `.env` 配置(`SMTP_SERVER` / `SMTP_PORT` / `SMTP_USER` / `__PASSWORD`)。
|
||||
- 注册流程的邮箱验证码后端,基于 pymsg(MsgServer,TCP 长度前缀帧 + JSON),监听 127.0.0.1:8008。
|
||||
- pymsg 路由:`send_email`(生成 6 位验证码并经 SMTP_SSL 发送,60 秒有效,发送失败自动作废)、`verify_email`(校验,通过即一次性消费防重放,兼容旧字段 `input_captcha`)、`health`。
|
||||
- 验证码存于进程内存(锁保护 + 惰性过期清理),SMTP 账号通过 `.env` 配置(`SMTP_SERVER` / `SMTP_PORT` / `SMTP_USER` / `__PASSWORD`,`__PASSWORD` 兼容别名 `SMTP_PASSWORD`)。
|
||||
|
||||
### aibox(独立 AI 智能体系统)
|
||||
|
||||
|
||||
+21
-4
@@ -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 *`(反向代理)
|
||||
|
||||
|
||||
+110
-1
@@ -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 <sstream>
|
||||
@@ -287,6 +287,11 @@ http::response<http::string_body> handleRegister(
|
||||
const http::request<http::string_body>& req,
|
||||
const std::unordered_map<std::string, std::string>& params);
|
||||
|
||||
/* 发送注册邮箱验证码:pymsg 调 smtp 服务 send_email 路由 */
|
||||
http::response<http::string_body> handleSendEmailCode(
|
||||
const http::request<http::string_body>& req,
|
||||
const std::unordered_map<std::string, std::string>& params);
|
||||
|
||||
http::response<http::string_body> handleCaptcha(
|
||||
const http::request<http::string_body>& req,
|
||||
const std::unordered_map<std::string, std::string>& params);
|
||||
@@ -326,6 +331,7 @@ static std::unique_ptr<MyRSA::Generator> _KEYGEN;
|
||||
//static std::unique_ptr<Sqlite3> _SQLDB;
|
||||
static RateLimiter _LOGIN_RATE;
|
||||
static RateLimiter _REGISTER_RATE;
|
||||
static RateLimiter _EMAIL_CODE_RATE; // 发送邮箱验证码限流
|
||||
|
||||
/* CAPTCHA store: token -> answer (in-memory, with expiry) */
|
||||
#include <unordered_map>
|
||||
@@ -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<http::string_body> {
|
||||
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<http::string_body> {
|
||||
return wrapperHttp(req, params,
|
||||
[](const auto& req, const auto& params) { return handleLogout(req, params); });
|
||||
@@ -951,6 +983,14 @@ http::response<http::string_body> 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<http::string_body> handleRegister(
|
||||
return res;
|
||||
}
|
||||
|
||||
/* 处理发送注册邮箱验证码 /register/send_code
|
||||
*
|
||||
* 经 pymsg 调 smtp 服务 send_email 路由,向目标邮箱发送 6 位验证码。
|
||||
* 限流 5 次/10 分钟/IP;smtp 不可用返回 502(fail-closed)。 */
|
||||
http::response<http::string_body> handleSendEmailCode(
|
||||
const http::request<http::string_body>& req,
|
||||
const std::unordered_map<std::string, std::string>& params)
|
||||
{
|
||||
http::response<http::string_body> 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<http::string_body> handleProxy(
|
||||
const http::request<http::string_body>& req,
|
||||
const std::unordered_map<std::string, std::string>& params,
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
../pymsg
|
||||
nuitka
|
||||
flask
|
||||
flask-cors
|
||||
flask_sqlalchemy
|
||||
#gunicorn
|
||||
waitress
|
||||
python-dotenv
|
||||
nuitka
|
||||
+201
-64
@@ -1,84 +1,221 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
smtp — 基于 pymsg 协议(MsgServer)的邮箱验证码服务。
|
||||
|
||||
from flask import Flask, request, jsonify
|
||||
from email.mime.text import MIMEText
|
||||
from email import header
|
||||
from log_client import logs
|
||||
from dotenv import load_dotenv
|
||||
import waitress
|
||||
import smtplib
|
||||
替代原 Flask HTTP 接口,通过 TCP 长度前缀帧收发 JSON。
|
||||
协议:每条消息为完整 JSON,``_route`` 字段指定路由名。
|
||||
|
||||
请求示例::
|
||||
|
||||
{"_route": "send_email", "email": "user@example.com"} → 生成并发送验证码
|
||||
{"_route": "verify_email", "email": "...", "code": "123456"} → 校验(一次性消费)
|
||||
{"_route": "health"} → 健康检查
|
||||
|
||||
响应格式(与 dbv2 / captcha 一致)::
|
||||
|
||||
{"success": true, "data": {...}}
|
||||
{"success": false, "error": "..."}
|
||||
|
||||
SMTP 账号经 .env 配置:SMTP_SERVER / SMTP_PORT / SMTP_USER / __PASSWORD
|
||||
(__PASSWORD 兼容别名 SMTP_PASSWORD)。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import smtplib
|
||||
import threading
|
||||
import time
|
||||
import sys, os
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
||||
except ImportError:
|
||||
pass # 无 dotenv 时直接读进程环境变量
|
||||
|
||||
app = Flask(__name__)
|
||||
captchas: dict[str, list[str, str]] = {}
|
||||
from log_client import logs
|
||||
|
||||
@app.route("/send_email", methods=["POST"])
|
||||
# pymsg 导入
|
||||
import sys as _sys
|
||||
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from pymsg import MsgServer # noqa: E402
|
||||
|
||||
def send_email():
|
||||
def build_captcha() -> str:
|
||||
captcha: str = ""
|
||||
for i in range(0,6):
|
||||
digit: int = random.randint(0,9)
|
||||
captcha += str(digit)
|
||||
return captcha
|
||||
# ── 配置 ────────────────────────────────────────────────────────────────
|
||||
|
||||
data = request.get_json()
|
||||
SMTP_SERVER = os.environ.get("SMTP_SERVER", "")
|
||||
SMTP_PORT = int(os.environ.get("SMTP_PORT", "465"))
|
||||
SMTP_USER = os.environ.get("SMTP_USER", "")
|
||||
# 历史 .env 使用 __PASSWORD,允许 SMTP_PASSWORD 作为别名
|
||||
SMTP_PASSWORD = os.environ.get("__PASSWORD") or os.environ.get("SMTP_PASSWORD", "")
|
||||
|
||||
recipient: str = data.get("email")
|
||||
subject: str = "DNFS 邮箱验证"
|
||||
captcha: str = build_captcha()
|
||||
content: str = f"DNFS: 您的邮箱验证码为 {captcha},一分钟内有效。"
|
||||
CODE_LEN = 6
|
||||
CODE_TTL = 60 # 验证码有效期(秒)
|
||||
MAIL_FROM = "dnfs@qsont.me"
|
||||
DEFAULT_SUBJECT = "DNFS 邮箱验证"
|
||||
DEFAULT_CONTENT = "DNFS: 您的邮箱验证码为 {code},一分钟内有效。"
|
||||
|
||||
captchas[recipient] = ["", ""]
|
||||
captchas[recipient][0] = captcha
|
||||
captchas[recipient][1] = str(int(time.time())+60)
|
||||
|
||||
message = MIMEText(content, "plain", "UTF-8")
|
||||
message["From"] = "dnfs@qsont.me"
|
||||
message["To"] = recipient
|
||||
message["Subject"] = subject
|
||||
|
||||
try:
|
||||
server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10)
|
||||
server.login(SMTP_USER, __PASSWORD)
|
||||
server.sendmail(SMTP_USER, [recipient], message.as_string())
|
||||
logs.info("smtp", f"{recipient}: 验证码发送成功({captcha})")
|
||||
return jsonify({"success": "验证码发送成功"}), 200
|
||||
|
||||
except Exception as err:
|
||||
logs.error("smtp", f"{recipient}: 验证码发送失败({err})")
|
||||
return jsonify({"error": f"验证码发送失败:{err}"}), 400
|
||||
|
||||
finally:
|
||||
server.quit()
|
||||
code_store = {} # email -> {'code': str, 'expires_at': float}
|
||||
STORE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
@app.route("/verify_email", methods=["POST"])
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# SmtpServer — 主服务类
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
def verify_email():
|
||||
data = request.get_json()
|
||||
class SmtpServer:
|
||||
"""基于 pymsg 的邮箱验证码服务,通过 TCP 长连接收发 JSON。"""
|
||||
|
||||
for i in list(captchas.keys()):
|
||||
if int(captchas[i][1]) < int(time.time()):
|
||||
del captchas[i]
|
||||
def __init__(self, addr="127.0.0.1", port=8008):
|
||||
self.addr = addr
|
||||
self.port = port
|
||||
self._server = MsgServer(addr, port)
|
||||
|
||||
email: str = data.get("email")
|
||||
input_captcha: str = data.get("input_captcha")
|
||||
# 路由表
|
||||
self._routes = {
|
||||
"send_email": self._handle_send_email,
|
||||
"verify_email": self._handle_verify_email,
|
||||
"health": self._handle_health,
|
||||
}
|
||||
|
||||
if captchas[email][0] == input_captcha:
|
||||
logs.info("smtp", f"{email}: 验证通过")
|
||||
return jsonify({"success": "验证通过"}), 200
|
||||
else:
|
||||
# ── 启动 / 停止 ──────────────────────────────────────────────────
|
||||
def start(self) -> None:
|
||||
"""启动服务(阻塞当前线程)。"""
|
||||
if not all([SMTP_SERVER, SMTP_USER, SMTP_PASSWORD]):
|
||||
logs.error("smtp", "SMTP 配置不完整(.env 缺 SMTP_SERVER/SMTP_USER/__PASSWORD),"
|
||||
"send_email 将始终失败")
|
||||
print("[smtp] 警告: SMTP 配置不完整,send_email 将失败")
|
||||
print(f"[smtp] 监听 tcp://{self.addr}:{self.port}")
|
||||
logs.info("smtp", f"开始监听 tcp://{self.addr}:{self.port}")
|
||||
self._server.start(self._dispatch)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""从另一线程停止服务。"""
|
||||
self._server.stop()
|
||||
|
||||
# ── 消息调度 -------------------------------------------------------
|
||||
def _dispatch(self, sock, addr, text):
|
||||
"""解析 JSON 中的 _route,分发给对应的 handler,并回复结果。"""
|
||||
try:
|
||||
req = json.loads(text)
|
||||
except json.JSONDecodeError as e:
|
||||
self._reply(sock, False, error=f"JSON 解析失败: {e}")
|
||||
return
|
||||
|
||||
route = req.pop("_route", "")
|
||||
handler = self._routes.get(route)
|
||||
if handler is None:
|
||||
self._reply(sock, False, error=f"未知路由: {route}")
|
||||
return
|
||||
|
||||
try:
|
||||
result = handler(req)
|
||||
self._reply(sock, True, data=result)
|
||||
except Exception as e:
|
||||
logs.error("smtp", f"路由 {route} 处理异常: {e}")
|
||||
self._reply(sock, False, error=str(e))
|
||||
|
||||
def _reply(self, sock, success, data=None, error=None):
|
||||
resp = {"success": success}
|
||||
if data is not None:
|
||||
resp["data"] = data
|
||||
if error is not None:
|
||||
resp["error"] = error
|
||||
self._server.reply(sock, json.dumps(resp, ensure_ascii=False))
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 路由处理
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
def _handle_send_email(self, req):
|
||||
"""生成 6 位数字验证码,存入内存(60s),经 SMTP_SSL 发送邮件。
|
||||
|
||||
可选字段 subject / content 覆盖默认标题与正文,content 中 {code}
|
||||
会被替换为验证码。
|
||||
"""
|
||||
email = str(req.get("email", "")).strip()
|
||||
if not email or "@" not in email:
|
||||
return {"sent": False, "error": "invalid email"}
|
||||
|
||||
if not all([SMTP_SERVER, SMTP_USER, SMTP_PASSWORD]):
|
||||
logs.error("smtp", f"{email}: SMTP 配置缺失,无法发送")
|
||||
return {"sent": False, "error": "SMTP 服务未配置"}
|
||||
|
||||
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)
|
||||
|
||||
with STORE_LOCK:
|
||||
code_store[email] = {'code': code, 'expires_at': time.time() + CODE_TTL}
|
||||
# 惰性清理过期项
|
||||
now = time.time()
|
||||
expired = [k for k, v in code_store.items() if v['expires_at'] < now]
|
||||
for k in expired:
|
||||
del code_store[k]
|
||||
|
||||
message = MIMEText(content, "plain", "UTF-8")
|
||||
message["From"] = MAIL_FROM
|
||||
message["To"] = email
|
||||
message["Subject"] = subject
|
||||
|
||||
# server 作用域限定在 try 内;连接失败时不会误调 quit()
|
||||
try:
|
||||
with smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10) as server:
|
||||
server.login(SMTP_USER, SMTP_PASSWORD)
|
||||
server.sendmail(SMTP_USER, [email], message.as_string())
|
||||
except Exception as err:
|
||||
# 发送失败:作废刚写入的验证码,避免"码已存但邮件没到"的不可用状态
|
||||
with STORE_LOCK:
|
||||
code_store.pop(email, None)
|
||||
logs.error("smtp", f"{email}: 验证码发送失败({err})")
|
||||
return {"sent": False, "error": f"验证码发送失败:{err}"}
|
||||
|
||||
logs.info("smtp", f"{email}: 验证码发送成功({code})")
|
||||
return {"sent": True, "ttl": CODE_TTL}
|
||||
|
||||
def _handle_verify_email(self, req):
|
||||
"""校验验证码,验证通过即一次性消费;不存在的 email 安全返回 invalid。"""
|
||||
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"}
|
||||
|
||||
with STORE_LOCK:
|
||||
entry = code_store.get(email)
|
||||
if entry and entry['expires_at'] < time.time():
|
||||
del code_store[email]
|
||||
entry = None
|
||||
if entry and entry['code'] == code:
|
||||
# 验证通过:一次性消费,防重放
|
||||
del code_store[email]
|
||||
valid = True
|
||||
else:
|
||||
valid = False
|
||||
|
||||
if valid:
|
||||
logs.info("smtp", f"{email}: 验证通过")
|
||||
return {"valid": True}
|
||||
logs.info("smtp", f"{email}: 验证失败")
|
||||
return jsonify({"error": "验证失败"}), 400
|
||||
return {"valid": False, "error": "invalid or expired code"}
|
||||
|
||||
def _handle_health(self, _req):
|
||||
return {"status": "ok", "service": "smtp"}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# 入口
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
if __name__ == "__main__":
|
||||
logs.info("smtp", "开始监听http://127.0.0.1:8008")
|
||||
print("Listen at http://127.0.0.1:8008")
|
||||
waitress.serve(app, host="127.0.0.1", port=8008)
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="smtp — pymsg 邮箱验证码服务")
|
||||
parser.add_argument("--addr", default="127.0.0.1", help="监听地址")
|
||||
parser.add_argument("--port", type=int, default=8008, help="监听端口")
|
||||
args = parser.parse_args()
|
||||
|
||||
server = SmtpServer(args.addr, args.port)
|
||||
try:
|
||||
server.start()
|
||||
except KeyboardInterrupt:
|
||||
server.stop()
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user