fix(gateway): 限流错误码统一429、登录用户不存在改401并拉平时序、pymsg超时常量化(v0.5.2)

- 三处限流错误码统一为 429(login 400→429 并修 attampts 拼写;register/send_code 403→429)
- 登录用户不存在不再兜底 500:空结果统一走 401,dummy PBKDF2 校验拉平时序防用户枚举
- db 查询失败消息误名修正:Login (Update Step) Failed → (Query Step)
- 超时常量化:DbC::DEFAULT_TIMEOUT=5.0;_TIMEOUT_CAPTCHA/_SMTP_VERIFY=5.0、_SMTP_SEND=20.0
- 文档:gateway/README 数据库节 email→username 重写、建表归 db/create_sqlite3.sql;根 README 版本号与自动建表描述同步
This commit is contained in:
qing committed 2026-07-29 00:21:13 +08:00
1 parent c2e9cce6a7
commit 6a0551ab34
4 files changed
+50 -23

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.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(前端托管服务)
+12 -9
View File
@@ -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:<salt_hex>:<hash_hex>` |
| 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,与其他步骤一致)。
## 依赖
+5 -1
View File
@@ -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");
+31 -11
View File
@@ -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 <sstream>
@@ -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 <unordered_map>
#include <mutex>
@@ -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<http::string_body> 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<http::string_body> 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<http::string_body> 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<http::string_body> 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<http::string_body> 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<http::string_body> 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<http::string_body> 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())