diff --git a/README.md b/README.md index f6b426c..79bf3fa 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.4.3) +### gateway(应用层网关 · v0.5.2) - 内部 HTTP 服务,默认监听 127.0.0.1:8443,不再承载 TLS(TLS 已由 WAF 终止)。 - 通过 `Server.tls = on/off` 配置切换 HTTPS/HTTP 模式([example.ini](gateway/example.ini)),独立部署时仍可启用 TLS。 @@ -79,7 +79,7 @@ - **CAPTCHA**:`/captcha` 接口生成验证码(5 分钟过期、一次性使用),登录与注册均可选启用;`GET /captcha/image` 为 captcha 服务(pymsg/TCP :8009)的图形验证码代理路由。 - **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟,按 `X-Forwarded-For`(WAF 注入)客户端 IP 计数。 - **反向代理**:`Target-Url` 必须在 `Proxy.opened` 白名单中(`closed` 仅记录用途,实际隔离靠"不在 opened 中即拒绝"),缺省走 `Proxy.default`(不需 Token,服务前端静态资源)。 -- **自动建表**:启动时若 `Users` 表不存在则自动创建,首位注册者即为管理员。 +- **用户表**:`Users` 表由 [db/create_sqlite3.sql](db/create_sqlite3.sql) 建立(gateway 不再自动建表),无默认管理员账号,首位注册者即为管理员。 - 详见 [gateway/README.md](gateway/README.md)。 ### static(前端托管服务) diff --git a/gateway/README.md b/gateway/README.md index c4d999c..dee5208 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -10,7 +10,7 @@ UniServe Framework 的应用层网关,C++20 实现。 - **反向代理**:携带 Token 的请求校验后转发到 `Proxy.opened` 中放开的内部服务。 - **CAPTCHA 接口**:`/captcha` 生成验证码 token + 文本(内存存储,5 分钟过期,一次性使用)。 - **邮箱验证码**:`/register/send_code` 发送 6 位验证码邮件(60 秒有效);注册时校验,一次性消费。 -- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟、发验证码 5 次/10 分钟,按 `X-Forwarded-For` 客户端 IP 计数(由 WAF 注入)。 +- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟、发验证码 5 次/10 分钟,按 `X-Forwarded-For` 客户端 IP 计数(由 WAF 注入),超限一律返回 429。 - **OPTIONS 预检**:通配 `*` 返回 CORS 头,允许跨域。 ## 安全特性 @@ -80,7 +80,7 @@ UniServe Framework 的应用层网关,C++20 实现。 校验顺序:密码强度 → 邮箱格式 → CAPTCHA(可选)→ **邮箱验证码**(经 pymsg 调 smtp:8008 `verify_email`,一次性消费,fail-closed),全部通过后写入 `Users` 表。 -错误码:`400`(密码/邮箱/CAPTCHA/邮箱验证码不合规)、`403`(速率超限)、`500`(数据库异常)。 +错误码:`400`(密码/邮箱/CAPTCHA/邮箱验证码不合规)、`429`(速率超限)、`500`(数据库异常)。 ### `POST /register/send_code` @@ -90,7 +90,7 @@ UniServe Framework 的应用层网关,C++20 实现。 { "email": "user@example.com" } ``` -限流 5 次/10 分钟/IP。错误码:`400`(邮箱格式错误)、`403`(速率超限)、`502`(smtp 服务不可用)、`500`(发送失败)。 +限流 5 次/10 分钟/IP。错误码:`400`(邮箱格式错误)、`429`(速率超限)、`502`(smtp 服务不可用)、`500`(发送失败)。 ### `GET/POST *`(反向代理) @@ -112,18 +112,18 @@ Target-Url: http://127.0.0.1:9200 // 可选,缺省取 Proxy.default ## 数据库 -SQLite3,表 `Users`: +用户数据存于 db 服务(pymsg/TCP :8005)的 SQLite 库,表 `Users`(由 [db/create_sqlite3.sql](../db/create_sqlite3.sql) 建立,gateway 不再自动建表): | 字段 | 类型 | 说明 | |------|------|------| | id | INTEGER PK AUTOINCREMENT | | -| email | TEXT UNIQUE NOT NULL | 登录账号 | +| username | TEXT UNIQUE NOT NULL | 登录账号(邮箱格式) | | password_hash | TEXT NOT NULL | `pbkdf2:100000::` | | create_at | DATETIME DEFAULT CURRENT_TIMESTAMP | | | last_login | DATETIME | 每次登录更新 | | is_active | BOOLEAN DEFAULT 1 | | -启动时若 `Users` 表不存在则自动建表(含 `idx_users_email` 索引),无默认管理员账号——首位注册者即为管理员。 +gateway 不直连数据库(早期直连本地 sqlite 的代码已注释保留),所有读写经 `DbC::call_msg` 调 db 的 `execute` 路由。无默认管理员账号——首位注册者即为管理员。 ## 配置 @@ -226,13 +226,16 @@ gateway/ ## 版本 -`0.5.1`(见 [src/main.cpp:29](src/main.cpp#L29))。启动时打印: +`0.5.2`(见 [src/main.cpp:29](src/main.cpp#L29))。启动时打印: ``` -Gateway 0.5.1 started +Gateway 0.5.2 started ``` -近期变更:登录成功后通过 db 更新 `Users.last_login`(`"$now"` 哨兵,db 日期适配层转为服务端当前 UTC 时间;更新失败经 THROW_EX_CUSTOM 抛 500,与其他步骤一致)。 +近期变更: + +- `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/inc/DbC.hpp b/gateway/inc/DbC.hpp index 7ca99c9..00ded14 100644 --- a/gateway/inc/DbC.hpp +++ b/gateway/inc/DbC.hpp @@ -6,6 +6,10 @@ namespace qing { namespace json = boost::json; class DbC { public: + /* 本机 db 轻量查询的默认超时(秒);慢操作由调用方显式放宽。 + * smtp/captcha 等其他下游的超时常量见 main.cpp。 */ + static constexpr double DEFAULT_TIMEOUT = 5.0; + // 【保留】旧版 HTTP 接口:通过 HttpClient POST 到 dbv2 HTTP /execute 端点。 // 保留用于兼容性参考,新代码优先使用 call_msg()(pymsg TCP 协议,更可靠)。 static json::value call_http(json::value& params) { @@ -25,7 +29,7 @@ namespace qing { // {"success":..., "data":[...]}。 // 响应格式与 call_http 兼容,调用方可直接替换。 static json::value call_msg(const json::value& params, - double timeout = 5.0) { + double timeout = DEFAULT_TIMEOUT) { json::value jv_route = params; jv_route.as_object()["_route"] = "execute"; pmc::net::MsgClient client("127.0.0.1", 8005, "gateway"); diff --git a/gateway/src/main.cpp b/gateway/src/main.cpp index fbf8ce2..9bb713e 100644 --- a/gateway/src/main.cpp +++ b/gateway/src/main.cpp @@ -26,7 +26,7 @@ using namespace qing; namespace json = boost::json; namespace po = boost::program_options; -constexpr const char *_VER = "0.5.1"; +constexpr const char *_VER = "0.5.2"; ////////////////////////////////// #include @@ -333,6 +333,14 @@ static RateLimiter _LOGIN_RATE; static RateLimiter _REGISTER_RATE; static RateLimiter _EMAIL_CODE_RATE; // 发送邮箱验证码限流 +/* pymsg 下游调用超时(秒): + * captcha / smtp verify_email 均为本机内存操作,5s 足够; + * smtp send_email 需完成一次完整外部 SMTP 会话(握手 + TLS + 投递),放宽到 20s。 + * db 调用的默认超时见 DbC.hpp 的 DbC::DEFAULT_TIMEOUT。 */ +static constexpr double _TIMEOUT_CAPTCHA = 5.0; +static constexpr double _TIMEOUT_SMTP_VERIFY = 5.0; +static constexpr double _TIMEOUT_SMTP_SEND = 20.0; + /* CAPTCHA store: token -> answer (in-memory, with expiry) */ #include #include @@ -385,7 +393,7 @@ bool verify_captcha_remote(const std::string& token, const std::string& answer) {"token", token}, {"answer", answer} }; - std::string resp = client.call(json::serialize(jv_req), 5.0); + std::string resp = client.call(json::serialize(jv_req), _TIMEOUT_CAPTCHA); auto jv = json::parse(resp); auto& obj = jv.as_object(); if (!obj.at("success").as_bool()) return false; @@ -405,7 +413,7 @@ bool verify_email_code_remote(const std::string& email, const std::string& code) {"email", email}, {"code", code} }; - std::string resp = client.call(json::serialize(jv_req), 5.0); + std::string resp = client.call(json::serialize(jv_req), _TIMEOUT_SMTP_VERIFY); auto jv = json::parse(resp); auto& obj = jv.as_object(); if (!obj.at("success").as_bool()) return false; @@ -728,7 +736,7 @@ http::response handleLogin( client_ip = "direct"; if (!_LOGIN_RATE.check_and_record(client_ip, 5, 300)) - THROW_EX_CUSTOM(HttpException, 2001, "Too many login attampts. Please try again later.", 400, "/login"); + THROW_EX_CUSTOM(HttpException, 2001, "Too many login attempts. Please try again later.", 429, "/login"); // parse json req body json::value jv; @@ -774,10 +782,14 @@ http::response handleLogin( auto jv_db_r = DbC::call_msg(jv_select); auto obj_r = jv_db_r.as_object(); auto success = obj_r["success"].as_bool(); - if (!success) THROW_EX_CUSTOM(HttpException, 2102, "Login (Update Step) Failed", 500, "/login"); + if (!success) THROW_EX_CUSTOM(HttpException, 2102, "Login (Query Step) Failed", 500, "/login"); auto obj_rd = obj_r.at("data").as_array(); - auto obj_ru = obj_rd.at(0).as_object(); - stored_hash = obj_ru.at("password_hash").as_string(); + /* 用户不存在时 data 为空数组:直接 at(0) 会抛异常被 wrapper 兜底成 500, + * 此处保持 stored_hash 为空,统一走下面的 401 分支 */ + if (!obj_rd.empty()) { + auto obj_ru = obj_rd.at(0).as_object(); + stored_hash = obj_ru.at("password_hash").as_string(); + } } @@ -802,6 +814,14 @@ http::response handleLogin( // std::cout << "Migrated password hash for " << username << " to PBKDF2" << std::endl; //} } + else { + /* 用户不存在:执行一次等价的 dummy PBKDF2 校验拉平响应时序, + * 防止通过响应时间差异枚举有效用户名(用户不存在/密码错误一律 401) */ + static const std::string dummy_hash = + "pbkdf2:100000:00000000000000000000000000000000:" + "0000000000000000000000000000000000000000000000000000000000000000"; + PBKDF2Hasher::verify_password(password, dummy_hash); + } if (!auth_ok) THROW_EX_CUSTOM(HttpException, 2101, "Invalid Username or Password", 401, "/login"); @@ -899,7 +919,7 @@ http::response handleCaptchaImage( pmc::net::MsgClient client("127.0.0.1", 8009, "gateway"); std::string resp; try { - resp = client.call(R"({"_route":"image"})", 5.0); + resp = client.call(R"({"_route":"image"})", _TIMEOUT_CAPTCHA); } catch (const std::exception& e) { THROW_EX_CUSTOM(HttpException, 2400, "Captcha service unavailable", 502, "/captcha/image"); } @@ -947,7 +967,7 @@ http::response handleRegister( client_ip = std::string(it_host->value()); if (!_REGISTER_RATE.check_and_record(client_ip, 3, 600)) THROW_EX_CUSTOM(HttpException, 3001, "Too many registration attempts. Please try again later.", - 403, "/register"); + 429, "/register"); // parse the req body json::value jv; @@ -1041,7 +1061,7 @@ http::response handleSendEmailCode( 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"); + 429, "/register/send_code"); // parse the req body json::value jv; @@ -1072,7 +1092,7 @@ http::response handleSendEmailCode( {"_route", "send_email"}, {"email", email} }; - std::string resp = client.call(json::serialize(jv_req), 20.0); + 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())