diff --git a/README.md b/README.md index 811f4f3..218b361 100755 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # micro_servers 一套 DIY 的轻量级微服务架构(UniServe Framework),由 C++、Python、Go 与仓颉(Cangjie) 混合实现。 -`core` 作为进程托管服务负责拉起并守护各业务子进程,`redirect80` 将 80 端口流量重定向到 443,`waf` 作为前置 WAF 网关终止 TLS 并做规则匹配,`gateway` 作为应用层网关统一鉴权与反向代理,`db` 提供通用数据库访问与集中式日志收集,`confs` 提供统一配置分发,`ai_review` 提供 AI 内容审核,`static` 与 `dnfs` 作为业务服务对外提供能力。`db2` / `dnfs2` 分别为 `db` / `dnfs` 的 C++20 重实现,接口完全兼容可平替。此外包含 aibox 的终端前端 `BotOps`、独立的机器人硬件控制模块 `bot4cj`、嵌入式帧缓冲图形界面 `lci` 和嵌入式脚本引擎 `libtry`。 +`core` 作为进程托管服务负责拉起并守护各业务子进程,`redirect80` 将 80 端口流量重定向到 443,`waf` 作为前置 WAF 网关终止 TLS 并做规则匹配,`ca` 作为中央认证服务统一鉴权(waf → ca → gateway 前置拓扑),`gateway` 退化为纯转发反向代理,`db` 提供通用数据库访问与集中式日志收集,`confs` 提供统一配置分发,`ai_review` 提供 AI 内容审核,`static` 与 `dnfs` 作为业务服务对外提供能力。`db2` / `dnfs2` 分别为 `db` / `dnfs` 的 C++20 重实现,接口完全兼容可平替。此外包含 aibox 的终端前端 `BotOps`、独立的机器人硬件控制模块 `bot4cj`、嵌入式帧缓冲图形界面 `lci` 和嵌入式脚本引擎 `libtry`。 ## 架构总览 @@ -29,7 +29,8 @@ ┌─────┴──────┐ │ redirect80 │ :80 → :443(GET/HEAD 301 / 其余 308,重定向到 waf) └────────────┘ - 日志链路: waf / gateway / static / dnfs / ai_review / captcha / smtp / aibox / confs / redirect80 ──(log_client, pymsg)──► db:8005 (Logs 表) + 日志链路: waf / ca / gateway / static / dnfs / ai_review / captcha / smtp / aibox / confs / redirect80 ──(log_client, pymsg)──► db:8005 (Logs 表) + 认证链路(v0.9.0 起): waf:443 ──HTTP──► ca:8443 (认证+前置转发) ──HTTP──► gateway:8445 (纯转发, 仅 loopback) ──► 内部服务 配置链路: static 等业务服务 ──(pymsg get)──► confs:8010(配置统一存放于 confs/configs/,mtime 变化自动重载) ``` @@ -39,9 +40,9 @@ 1. 客户端若以 HTTP 访问 `:80`,由 `redirect80` 重定向到 HTTPS `:443`(GET/HEAD 返回 301,其余方法返回 308 保留 body)。 2. 客户端所有请求先打到 `waf:443`(HTTPS),WAF 终止 TLS、运行 coraza 规则匹配,命中则直接返回 403。 -3. 未命中规则的请求由 WAF 以明文 HTTP 转发到 `gateway:8443`,并设置 `X-Forwarded-For` / `X-Forwarded-Proto` 头。 -4. `gateway` 对 `/login`、`/register` 做账号鉴权(PBKDF2 密码哈希 + RSA 签名 Token + 可选 CAPTCHA + 速率限制;注册需邮箱验证码,经 pymsg/TCP 调 `smtp:8008` 发送与校验),用户数据通过 pymsg/TCP 调用 `db:8005` 存取。 -5. 其余请求携带 Token,经 `gateway` 校验后反向代理到 `Proxy.opened` 中放开的内部服务(`static:9200` / `dnfs:8006`)。 +3. 未命中规则的请求由 WAF 以明文 HTTP 转发到 `ca:8443`,并设置 `X-Forwarded-For` / `X-Forwarded-Proto` 头。 +4. `ca` 对 `/login`、`/register` 做账号鉴权(PBKDF2 密码哈希 + RSA 签名 Token + 可选 CAPTCHA + 速率限制;注册需邮箱验证码,经 pymsg/TCP 调 `smtp:8008` 发送与校验),用户数据通过 pymsg/TCP 调用 `db:8005` 存取。 +5. 其余请求携带 Token,经 `ca` 验签后注入 `X-Auth-User` 身份头转发到 `gateway:8445`(仅 loopback,不验签仅做存在性检查),再由 gateway 反向代理到 `Proxy.opened` 中放开的内部服务(`static:9200` / `dnfs:8006`)。 6. `db:8005` 与 `ai_review:8007` 在 `Proxy.closed` 中,仅允许内部服务间调用,不对外暴露。 7. `dnfs` 处理节点增删改查时,通过 pymsg/TCP 调用 `db` 服务完成数据持久化,新增节点前调用 `ai_review` 进行内容审核。 8. 各服务通过 `log_client.py` SDK 异步批量推送运行日志(pymsg/TCP 写入 db 的 `Logs` 表),实现集中式日志收集(fail-safe,db 不可用时静默丢弃)。 @@ -52,15 +53,16 @@ |------|------|------|------| | [core](core/) | C++20 | 5200 | 进程托管服务(PMC),创建/列举/杀死/执行子进程,读取自启动列表守护业务进程,提供 HTTP 管理接口 | | [redirect80](redirect80/) | Python | 80 | HTTP→HTTPS 重定向服务,将 80 端口请求重定向到 443(GET/HEAD 301 / 其余 308),纯标准库实现 | -| [waf](waf/) | Go | 443 | WAF 网关(入口),终止 TLS、coraza 规则匹配(SecLang)、反向代理到 gateway,支持 SIGHUP 热加载规则 | -| [gateway](gateway/) | C++20 | 8443 | 应用层网关(内部 HTTP),用户注册/登录鉴权、Token 签发与校验、反向代理到内部服务 | +| [waf](waf/) | Go | 443 | WAF 网关(入口),终止 TLS、coraza 规则匹配(SecLang)、反向代理到 ca,支持 SIGHUP 热加载规则 | +| [ca](ca/) | C++20 | 8443 | 中央认证服务(内部 HTTP),用户注册/登录鉴权、Token 签发与校验、身份头注入后前置转发到 gateway | +| [gateway](gateway/) | C++20 | 8445 | 应用层网关(仅 loopback),纯转发反向代理到内部服务,不验签(v0.9.0 起认证迁至 ca) | | [static](static/) | Python(Flask) | 9200 | 前端托管服务,社区首页、静态页面与文件浏览、视频/音频流媒体播放、路径遍历防护 | | [dnfs](dnfs/) | Python(Flask) | 8006 | 树状网络文件系统(Dendrachy Network File-System),基于父子 pid 的节点树增删改查,集成 AI 内容审核 | | [dnfs2](dnfs2/) | C++20 | 8006 | dnfs 的 C++20 重实现(libhttp + libmsg),接口与 Python 版完全兼容,修复 P0/P1 安全问题,可平替 | | [db](db/) | Python(pymsg) | 8005 | 通用数据库服务,基于 pymsg 协议(TCP 长度前缀帧 + JSON)的 SQL-over-TCP,SQLAlchemy Core 参数化查询防注入;内置集中式日志收集(Logs 表 + 自动清理) | | [db2](db2/) | C++20 | 8005 | db 的 C++20 重实现(libmsg + boost::json + libsqlite3),execute 路由/where 语法/响应格式完全对齐 Python 版,可平替 | | [ai_review](ai_review/) | Python(Flask) | 8007 | AI 内容审核服务,DeepSeek LLM + ChromaDB RAG,支持人工反馈纠偏 | -| [captcha](captcha/) | Python(pymsg) | 8009 | 验证码服务(pymsg/TCP),生成图形验证码(5 分钟过期、一次性),经 gateway 的 `GET /captcha/image` 代理给浏览器 | +| [captcha](captcha/) | Python(pymsg) | 8009 | 验证码服务(pymsg/TCP),生成图形验证码(5 分钟过期、一次性),经 ca 的 `GET /captcha/image` 代理给浏览器 | | [smtp](smtp/) | Python(pymsg) | 8008 | 邮件服务,发送/校验邮箱验证码(pymsg 路由 send_email/verify_email/health),注册流程的邮件验证码后端,SMTP 账号配置在 .env | | [confs](confs/) | Python(pymsg) | 8010 | 统一配置服务,集中存放并分发各服务配置文件(pymsg 路由 get/list/reload/health),内存缓存 + mtime 自动重载,路径遍历防护 | | [aibox](aibox/) | Python | 8765 | 独立 AI 智能体系统(DeepSeek + WebSocket),自带技能系统/记忆/任务调度,由 core 拉起但与主干微服务无调用关系 | @@ -73,29 +75,35 @@ - 可执行文件 `nidus.out`,作为 systemd 服务(`Nidus.service`)常驻运行(崩溃守护由 systemd 负责,core 自身不含自动重启逻辑)。 - 支持两种工作模式:命令行子系统调用(`--send/--exec/--list/--kill`)与 HTTP 伺服器模式(`--serve `)。 - 通过命名管道(Pipe)与消息队列(Mq)实现进程间通信。 -- 读取 `init.txt` 自启动列表,按行拉起各业务服务(ai_review / db / dnfs / smtp / static / waf / gateway / captcha / aibox)。 +- 读取 `init.txt` 自启动列表,按行拉起各业务服务(ai_review / db / dnfs / smtp / static / waf / ca / gateway / captcha / aibox)。 ### waf(WAF 网关) - 唯一对外入口,终止 TLS(`certs/full_chain.crt` / `certs/server.key`),监听 443 端口。 - 基于 [coraza](https://github.com/corazawaf/coraza) v3.3.0(ModSecurity SecLang 的 Go 实现),规则匹配命中后返回 403。 -- 未命中规则的请求以明文 HTTP 反向代理到 `gateway:8443`,透传 `X-Forwarded-For` / `X-Forwarded-Proto`。 +- 未命中规则的请求以明文 HTTP 反向代理到 `ca:8443`,透传 `X-Forwarded-For` / `X-Forwarded-Proto`。 - 规则库位于 [waf/rules/](waf/rules/),分两段:本地自定义(ID 100000-199999)与 OWASP CRS(ID 900000-999999,可选)。 - 极客操作:`kill -HUP ` 热加载规则;`waf -list` 列规则;`waf -test ` 校验语法;`waf -check ""` 测试单条输入。 - 配置见 [waf/config.yaml](waf/config.yaml),详见 [waf/README.md](waf/README.md)。 -### gateway(应用层网关 · v0.8.0) +### ca(中央认证服务 · v1.0.0) -- 内部 HTTP 服务,默认监听 127.0.0.1:8443,不再承载 TLS(TLS 已由 WAF 终止)。 -- 通过 `Server.tls = on/off` 配置切换 HTTPS/HTTP 模式([example.ini](gateway/example.ini)),独立部署时仍可启用 TLS。 +- 内部 HTTP 服务,默认监听 127.0.0.1:8443(接管原 gateway 端口,WAF upstream 不变),不再承载 TLS(TLS 已由 WAF 终止)。 - **密码安全**:PBKDF2-HMAC-SHA256(10 万轮 + 16 字节随机盐),密码强度校验(8+ 字符、4 类中至少 3 类),常数时间比较防 timing attack。 -- **用户存储**:用户数据通过 pymsg/TCP 调用 `db:8005`(`DbC::call_msg`)存取,不再直连本地 SQLite;登录成功即更新 `Users.last_login`(`"$now"` 哨兵)。 -- **Token 机制**:`Base64(payload) + "_" + RSA-Sign(payload)`,默认 1 小时过期,验签通过才放行;RSA 密钥对启动时内存生成,重启后旧 Token 全部失效。 -- **Cookie 认证**:登录成功同时签发 `HttpOnly + Secure + SameSite=Lax` 的 `q4_token` Cookie;代理时无 `Authorization` 头则回退读 Cookie 验签,并向下游注入 `Authorization: Bearer`(下游零改动);`POST /logout` 清除 Cookie。公开目标(`Proxy.default`)不做凭证校验,避免旧 Cookie 影响公开页面。 -- **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` 表由 [db/create_sqlite3.sql](db/create_sqlite3.sql) 建立(gateway 不再自动建表),无默认管理员账号,首位注册者即为管理员。 +- **用户存储**:用户数据通过 pymsg/TCP 调用 `db:8005`(`DbC::call_msg`)存取;登录成功即更新 `Users.last_login`(`"$now"` 哨兵)。 +- **Token 机制**:`Base64(payload) + "_" + RSA-Sign(payload)`,默认 1 小时过期;RSA 密钥对启动时内存生成,重启后旧 Token 全部失效;jti 黑名单 + 用户版本号双轨吊销。 +- **Cookie 认证**:登录成功同时签发 `HttpOnly + Secure + SameSite=Lax` 的 `q4_token` Cookie;验签时无 `Authorization` 头则回退读 Cookie,并向下游注入 `Authorization: Bearer`(下游零改动);`POST /logout` 清除 Cookie。 +- **前置转发**:无 `Target-Url` 为公开请求(剥身份头直转 gateway);有 `Target-Url` 为受保护请求(验签后注入 `X-Auth-User`/`X-Auth-Admin` 转发,strip-then-set 契约剥离客户端伪造值);上游不可达统一 502。 +- **CAPTCHA**:`GET /captcha/image` 为 captcha 服务(pymsg/TCP :8009)的图形验证码代理路由;`/login` 强制 CAPTCHA。 +- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟、发码 IP 级 + 邮箱级双窗口,按 `X-Forwarded-For`(WAF 注入)客户端 IP 计数。 +- **用户表**:`Users` 表由 [db/create_sqlite3.sql](db/create_sqlite3.sql) 建立,无默认管理员账号,首位注册者即为管理员。 +- 详见 [ca/README.md](ca/README.md)。 + +### gateway(应用层网关 · v0.9.0) + +- 纯转发服务,仅监听 127.0.0.1:8445(loopback 启动强制自检),不再验签(认证已迁至 ca)。 +- **存在性不变量**:显式 `Target-Url` 请求必须携带 ca 注入的 `X-Auth-User` 头,缺失一律 401。 +- **反向代理**:`Target-Url` 必须在 `Proxy.opened` 白名单中,缺省走 `Proxy.default`;`X-Auth-User`/`X-Auth-Admin`/`Authorization` 原样透传下游。 - 详见 [gateway/README.md](gateway/README.md)。 ### static(前端托管服务) diff --git a/ca/.gitignore b/ca/.gitignore new file mode 100644 index 0000000..8b604b6 --- /dev/null +++ b/ca/.gitignore @@ -0,0 +1,74 @@ +#PRJ +*.db +*.crt +*.key + +# Prerequisites +*.d + +# Compiled Object files +*.slo +*.lo +*.o +*.obj + +# Precompiled Headers +*.gch +*.pch + +# Linker files +*.ilk + +# Debugger Files +*.pdb + +# Compiled Dynamic libraries +*.so +*.dylib +*.dll +*.so.* + + +# Fortran module files +*.mod +*.smod + +# Compiled Static libraries +*.lai +*.la +*.a +*.lib + +# Executables +*.exe +*.out +*.app + +# Build directories +build/ +Build/ +build-*/ + +# CMake generated files +CMakeFiles/ +CMakeCache.txt +cmake_install.cmake +#Makefile +install_manifest.txt +compile_commands.json + +# Temporary files +*.tmp +*.log +*.bak +*.swp + +# vcpkg +vcpkg_installed/ + +# debug information files +*.dwo + +# test output & cache +Testing/ +.cache/ diff --git a/ca/Makefile b/ca/Makefile new file mode 100644 index 0000000..33aa32b --- /dev/null +++ b/ca/Makefile @@ -0,0 +1,19 @@ +VERSION := 1.0.0 +APP_NAME := ca +INSTL_DIR := /usr/local/bin +CPP := g++ +INCS := -I inc/ -I ../libmsg/inc +MY_LIBS := -L/usr/lib -L../libmsg/build -lus_http -lus_httpclient -lus_crypt -lus_msg +LIBS := -pthread -lcurl -lssl -lz -lcrypto -lboost_json -lboost_program_options -Wl,-rpath,$(shell cd ../libmsg/build && pwd) +CPPFLAGS := --std=c++20 -pipe -pthread # -Wall -Werror +BUILD_DIR = build +$(shell mkdir -p $(BUILD_DIR)) + +#-------- +all: $(BUILD_DIR)/$(APP_NAME).out +$(BUILD_DIR)/$(APP_NAME).out: $(BUILD_DIR)/main.o + $(CPP) $^ $(LIBS) $(MY_LIBS) -o $@ +$(BUILD_DIR)/main.o: src/main.cpp inc/DbC.hpp inc/LogC.hpp inc/Conf.hpp inc/ExpBase.hpp inc/LocalTime.hpp + $(CPP) $(CPPFLAGS) -c $< $(INCS) -o $@ +clear: + @rm -rvf build/ diff --git a/ca/README.md b/ca/README.md new file mode 100644 index 0000000..2fb1bbb --- /dev/null +++ b/ca/README.md @@ -0,0 +1,142 @@ +# ca + +UniServe Framework 的中央认证服务(Central Auth),C++20 实现,v1.0.0 起承接原 gateway v0.8.0 的全部认证职责。 + +## 拓扑定位(waf → ca → gateway 前置拓扑) + +``` +客户端 ──HTTPS──▶ waf:443 (终止 TLS + coraza 规则) ──HTTP──▶ ca:8443 (认证+前置转发) ──HTTP──▶ gateway:8445 (纯转发) ──▶ static:9200 / dnfs2:8006 / core:5200 +``` + +- CA 接管原 gateway 的 **8443** 端口,WAF upstream 配置不变(`waf/config.yaml` 仍指向 8443)。 +- gateway 退居 **8445**(仅 loopback),退化为纯转发服务,不再验签。 +- 验签在 CA 本地完成(RSA 密钥同进程),对受保护请求零网络跳。 +- 下游 core / dnfs2 / 前端 / WAF 全部零改动。 + +## 职责 + +- **账号鉴权**:`/register` 注册(需邮箱验证码,经 pymsg 调 smtp:8008 校验)、`/login` 登录,签发 RSA 签名的 Token。 +- **Token 生命周期**:`/logout`(jti 吊销)、`/refresh_token`(宽限期刷新 + 次数上限)、`/whoami`、`/admin/verify`(供 core 等内部服务调用)。 +- **前置转发层**:通配 `GET/POST *` 把请求转发到唯一上游 gateway:8445: + - 无 `Target-Url` 头 = 公开请求:剥离伪造身份头(`X-Auth-User`/`X-Auth-Admin`)后直转。 + - 有 `Target-Url` 头 = 受保护请求:本地验签(RSA + 过期 + jti 黑名单 + 用户版本号),通过后注入 `X-Auth-User`(管理员追加 `X-Auth-Admin: 1`)与 `Authorization: Bearer` 再转发。 + - 上游不可达/超时统一返回 `502 Bad Gateway`(区别于 CA 自身故障的 500)。 +- **WebSocket 反向代理(仅管理员)**:`[WsProxy]` 段配置的路径在 HTTP Upgrade 握手时校验 Token(`is_admin=true`),通过后透明桥接到后端 ws:// 或 wss:// 服务。 +- **CAPTCHA 代理**:`GET /captcha/image` 代理 captcha2 签发图形验证码(PNG + `X-Captcha-Token` 响应头,10 次/分钟/IP,禁缓存)。 +- **邮箱验证码**:`/register/send_code`、`/reset/send_code` 发送 6 位验证码邮件(10 分钟有效);`/reset_password` 重置密码(防枚举恒成功 + 改密后版本号吊销全端 Token)。 +- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟、发验证码 5 次/10 分钟(IP 级)+ 3 次/小时/10 次/天(邮箱级),按 `X-Forwarded-For` 客户端 IP 计数(由 WAF 注入),超限一律返回 429。 +- **OPTIONS 预检**:由 libhttp 处理,`Access-Control-Allow-Origin` 反射请求的 Origin 头(见审计 P2-3)。 + +## 身份头契约(strip-then-set) + +| 头 | 方向 | 语义 | +|----|------|------| +| `X-Auth-User` | CA → gateway → 下游 | 已认证用户名(邮箱)。客户端携带的同名单被 CA 剥离,仅 CA 注入的值可信 | +| `X-Auth-Admin: 1` | CA → gateway → 下游 | 仅当 Token `is_admin=true` 时注入 | +| `Authorization: Bearer ` | CA → gateway → 下游 | Token 来自 Cookie 时由 CA 补齐;来自客户端 Authorization 头时透传 | + +gateway 侧仅做**存在性不变量**检查(显式 `Target-Url` 请求必须携带 `X-Auth-User`,否则 401),不复验签名——信任边界是 loopback。 + +## 安全特性 + +| 特性 | 实现 | +|------|------| +| 密码哈希 | PBKDF2-HMAC-SHA256,100000 轮,16 字节随机盐,32 字节哈希 | +| 密码强度校验 | 至少 8 字符;大写/小写/数字/特殊字符中至少 3 类 | +| Token 机制 | `Base64(payload) + "_" + RSA-Sign(payload)`,验签 + 过期校验(默认 1 小时) | +| Token 吊销 | 双轨纯内存:jti 黑名单(5000 条阈值清理)+ 用户版本号(改密全端吊销) | +| 常数时间比较 | PBKDF2 验证用 `diff |= hash[i] ^ expected[i]` 防 timing attack | +| 防用户枚举 | 登录对不存在用户执行 dummy PBKDF2 拉平时序;重置密码恒返回成功 | +| CAPTCHA | 统一走 captcha2 远端校验(Token 一次性消费);`/login` 强制 | +| TLS 可选 | `Server.tls = off` 时纯 HTTP(配合 WAF),`on` 时用 `full_chain.crt` / `server.key` | +| 下游容错 | pymsg 客户端 `retry_on_refused`:db/confs 未就绪时阻塞等待;日志客户端 fail-safe 静默丢弃 | + +## 接口 + +### `GET /captcha/image` + +代理 captcha2 签发图形验证码:响应体为 PNG,答案 Token 在 `X-Captcha-Token` 响应头(64 位 hex,一次性消费),`Cache-Control: no-store`。限流 10 次/分钟/IP(429)。 + +### `POST /login` + +```json +{ + "username": "user@example.com", + "password": "P@ssw0rd", + "captcha_token": "a1b2c3d4...", + "captcha_answer": "Ab3xy" +} +``` + +成功:`{"success": true, "data": {"token": "_"}}`,同时签发 `HttpOnly + Secure + SameSite=Lax` 的 `q4_token` Cookie。 +错误码:`400`(参数/CAPTCHA 错误)、`401`(账号/密码错)、`429`(限流)。 + +### `POST /register` / `POST /register/send_code` / `POST /reset/send_code` / `POST /reset_password` + +与 gateway v0.8.0 契约完全一致(详见 `docs/reports/auth_service_plan.md` §5 与黑盒 tests/blackbox/test_gateway.py)。 + +### `POST /logout` / `POST /refresh_token` / `POST /whoami` / `POST /admin/verify` + +- `/logout`:吊销当前 Token 的 jti 并清除 Cookie。 +- `/refresh_token`:宽限期(iat + keep_time × refresh_grace_multiplier)内换发新 Token,旧 jti 吊销,刷新次数超 `max_refresh` 后需重新登录。 +- `/whoami`:恒 200,未登录返回 `{"login": false}`。 +- `/admin/verify`:fail-closed,非管理员/无效 Token 一律 401(供 core:5200 管理员接口鉴权)。 + +### `GET/POST *`(前置转发) + +``` +Authorization: Bearer _ // 或 Cookie: q4_token=... +Target-Url: http://127.0.0.1:8006 // 可选;携带即视为受保护请求 +``` + +- 无 `Target-Url`:公开请求,剥身份头直转 gateway(由其 `Proxy.default` 路由)。 +- 有 `Target-Url`:验签通过后注入身份头转发;`Target-Url` 白名单由 gateway 侧 `Proxy.opened` 裁决(403)。 +- 上游故障:`502 {"success": false, "message": "Bad Gateway"}`。 + +### WebSocket 代理(`[WsProxy]` 配置的路径) + +握手阶段从 `Authorization: Bearer` / `Cookie: q4_token` 提取 Token,验签且 `is_admin=true` 才升级连接并桥接后端;否则拒绝。路由精确匹配,优先级高于 HTTP 通配 `*`。 + +## 配置 + +两层加载:**confs 统一配置服务**(启动时 pymsg `get` 拉取 `ca.ini`,`retry_on_refused=true`)→ **环境变量覆盖**(最高优先级)。 + +配置文件:[confs/configs/ca.ini](../confs/configs/ca.ini)。 + +- `Server.address`/`port`/`tls`:监听地址/端口/TLS(默认 `127.0.0.1:8443`,env `LISTEN_ADDR`/`LISTEN_PORT`)。 +- `Upstream.gateway`:唯一上游 gateway 地址(默认 `http://127.0.0.1:8445`,env `UPSTREAM_GATEWAY`)。 +- `Token.*`:`keep_time` / `refresh_grace_multiplier` / `max_refresh`。 +- `Admin.users`:管理员白名单(分号分隔邮箱),登录命中时 Token payload 写入 `is_admin=true`。 +- `CORS.allowed_origins`:已读取但尚未用于预检响应(审计 P2-3)。 +- `WsProxy.routes`:`/path=ws://host:port/target`,分号分隔。 +- `Downstream.*`:db/captcha/smtp 地址(env `DB_*`/`CAPTCHA_*`/`SMTP_*`/`CONFS_*`)。 + +## 编译与运行 + +```bash +bash _set.sh # make clean && make +bash _run.sh # 从 confs 拉取配置启动(先 sleep 2 等待 confs/db) +``` + +命令行参数:`-h/--help`、`-v/--version`。日志经 LogC 异步批量推送 db:8005 `Logs` 表(service 名 `ca`)。 + +## 目录结构 + +``` +ca/ +├── src/ +│ └── main.cpp # 全部实现:认证 handler、Token 机制、限流、前置转发层、WS 代理 +├── inc/ +│ ├── DbC.hpp # db 服务客户端(pymsg,retry_on_refused=true;client 名 "ca") +│ ├── LogC.hpp # 统一日志客户端(异步批量,fail-safe;client 名 "ca-log") +│ ├── Conf.hpp # ConfIni:ini 解析 + env 覆盖 +│ ├── ExpBase.hpp # 异常基类(带 source_location) +│ └── LocalTime.hpp # 本地时间工具 +├── Makefile +├── _set.sh / _run.sh +└── README.md +``` + +## 版本 + +`1.0.0`:认证独立化首发——自 gateway v0.8.0 整体搬迁全部认证资产(逐字保留实现),新增前置转发层(固定上游 + strip-then-set 身份头契约 + 上游 502 语义)。原 gateway 同步瘦身为 v0.9.0 纯转发服务。 diff --git a/ca/_run.sh b/ca/_run.sh new file mode 100755 index 0000000..8c24dde --- /dev/null +++ b/ca/_run.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# ca 启动脚本 +# 配置统一由 confs 服务管理(confs/configs/ca.ini), +# 启动时从 confs(CONFS_HOST:CONFS_PORT,默认 127.0.0.1:8010)拉取配置。 +cd "$(dirname "$0")" || exit + +# 等待依赖服务启动(confs/db) +sleep 2 + +exec ./build/ca.out "$@" diff --git a/ca/_set.sh b/ca/_set.sh new file mode 100755 index 0000000..95ae6ae --- /dev/null +++ b/ca/_set.sh @@ -0,0 +1,3 @@ +#!/bin/bash +cd "$(dirname "$0")" || exit +make clear && make $@ diff --git a/ca/inc/Conf.hpp b/ca/inc/Conf.hpp new file mode 100644 index 0000000..a0b7d82 --- /dev/null +++ b/ca/inc/Conf.hpp @@ -0,0 +1,77 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +namespace property_tree = boost::property_tree; +using ptree = property_tree::ptree; +namespace qing { + + class ConfIni { + public: + /// 默认构造(空配置,后续通过赋值覆盖,仅供 confs 加载流程内部使用) + ConfIni() = default; + + /// 从文件路径解析 ini(保留用于开发/调试模式) + ConfIni(const std::string &filename) { + if (filename.empty()) return; // 空文件名不报错,后续会通过赋值覆盖 + try { + std::cout << "[conf] 从文件加载配置: " << filename << std::endl; + property_tree::ini_parser::read_ini(filename, pt); + } + catch (const boost::property_tree::ini_parser_error& e) { + std::cerr << "[conf] INI 解析错误: " << e.what() << std::endl; + throw IniConfigureFileParseException("INI parsing error!"); + } + } + + /// 从 ini 文本字符串解析(用于从 confs 拉取后解析) + static ConfIni fromString(const std::string &ini_text) { + ConfIni c; + try { + std::istringstream iss(ini_text); + property_tree::ini_parser::read_ini(iss, c.pt); + } + catch (const boost::property_tree::ini_parser_error& e) { + std::cerr << "[conf] INI 文本解析错误: " << e.what() << std::endl; + throw IniConfigureFileParseException("INI parsing error!"); + } + return c; + } + + template + T get(const std::string &path, const T &_defaut) { + return pt.get(path, _defaut); + } + + /// 读取环境变量,不存在则返回默认值 + static std::string env(const char *name, const std::string &default_val = "") { + const char *v = std::getenv(name); + return v ? std::string(v) : default_val; + } + + static int env_int(const char *name, int default_val) { + const char *v = std::getenv(name); + if (!v || !*v) return default_val; + try { return std::stoi(v); } + catch (...) { return default_val; } + } + + class IniConfigureFileParseException: public std::exception { + public: + explicit IniConfigureFileParseException(const std::string &msg) + : message(msg) {} + const char* what() const noexcept override { + return message.c_str(); + } + private: + std::string message; + }; + + private: + ptree pt; + }; +} diff --git a/ca/inc/DbC.hpp b/ca/inc/DbC.hpp new file mode 100644 index 0000000..7d272e0 --- /dev/null +++ b/ca/inc/DbC.hpp @@ -0,0 +1,57 @@ +#pragma once +#include +#include +#include +namespace qing { + namespace json = boost::json; + class DbC { + public: + /* 本机 db 轻量查询的默认超时(秒);慢操作由调用方显式放宽。 + * smtp/captcha 等其他下游的超时常量见 main.cpp。 */ + static constexpr double DEFAULT_TIMEOUT = 5.0; + + /// 初始化 db 服务地址(启动时从配置设置,默认 127.0.0.1:8005) + static void init(const std::string& host, int port) { + _host = host; + _port = port; + } + + // 【保留】旧版 HTTP 接口:通过 HttpClient POST 到 dbv2 HTTP /execute 端点。 + // 保留用于兼容性参考,新代码优先使用 call_msg()(pymsg TCP 协议,更可靠)。 + static json::value call_http(json::value& params) { + + auto client = pmc::net::HttpClient(); + client.set_header("Content-Type: application/json"); + std::string payload = json::serialize(params); + std::string res = ""; + std::string url = "http://" + _host + ":" + std::to_string(_port) + "/execute"; + client.post_stream_sync(url, payload, [&res](const auto& ret) { + res += ret; + }); + return json::parse(res); + } + + // 新版 pymsg 接口:通过 MsgClient(TCP 长度前缀帧协议)调用 dbv2。 + // 自动添加 _route = "execute",返回 dbv2 pymsg 完整 JSON 响应 + // {"success":..., "data":[...]}。 + // 响应格式与 call_http 兼容,调用方可直接替换。 + // db 是强依赖,启用 retry_on_refused:db 未启动时阻塞等待,避免启动顺序问题。 + static json::value call_msg(const json::value& params, + double timeout = DEFAULT_TIMEOUT) { + json::value jv_route = params; + jv_route.as_object()["_route"] = "execute"; + pmc::net::MsgClient client(_host, _port, "ca"); + std::string resp = client.call( + json::serialize(jv_route), timeout, + /*retry_on_refused=*/true); + return json::parse(resp); + } + + static const std::string& host() { return _host; } + static int port() { return _port; } + + private: + static inline std::string _host = "127.0.0.1"; + static inline int _port = 8005; + }; +} diff --git a/ca/inc/ExpBase.hpp b/ca/inc/ExpBase.hpp new file mode 100644 index 0000000..c2509c6 --- /dev/null +++ b/ca/inc/ExpBase.hpp @@ -0,0 +1,411 @@ +#ifndef EXCEPTION_BASE_H +#define EXCEPTION_BASE_H + +#include +#include +#include +#include +#include +#include +#include + +/** + * @brief 通用异常基类 + * + * 特性: + * 1. 完整的错误信息管理(错误码、消息、位置信息) + * 2. 支持异常链(嵌套异常) + * 3. 多态支持(clone、自定义消息) + * 4. 线程安全的 what() 缓存 + * 5. 支持 C++20 source_location + */ +class ExceptionBase : public std::exception { +public: + // ============ 构造函数 ============ + + explicit ExceptionBase( + int code = 0, + const std::string& message = "", + const std::source_location& location = std::source_location::current() + ) noexcept + : m_code(code) + , m_message(message) + , m_file(location.file_name()) + , m_line(location.line()) + , m_function(location.function_name()) + , m_what_cache() + { + } + + // 支持从字符串字面量构造 + explicit ExceptionBase( + const std::string& message, + const std::source_location& location = std::source_location::current() + ) noexcept + : m_code(0) + , m_message(message) + , m_file(location.file_name()) + , m_line(location.line()) + , m_function(location.function_name()) + , m_what_cache() + { + } + + // ============ 拷贝和移动 ============ + + ExceptionBase(const ExceptionBase& other) noexcept + : std::exception(other) + , m_code(other.m_code) + , m_message(other.m_message) + , m_file(other.m_file) + , m_line(other.m_line) + , m_function(other.m_function) + , m_nested(other.m_nested ? other.m_nested->clone() : nullptr) + , m_what_cache() + { + } + + ExceptionBase(ExceptionBase&& other) noexcept + : std::exception(std::move(other)) + , m_code(other.m_code) + , m_message(std::move(other.m_message)) + , m_file(std::move(other.m_file)) + , m_line(other.m_line) + , m_function(std::move(other.m_function)) + , m_nested(std::move(other.m_nested)) + , m_what_cache(std::move(other.m_what_cache)) + { + other.m_code = 0; + other.m_line = 0; + } + + virtual ~ExceptionBase() noexcept = default; + + // ============ 赋值运算符 ============ + + ExceptionBase& operator=(const ExceptionBase& other) noexcept { + if (this != &other) { + m_code = other.m_code; + m_message = other.m_message; + m_file = other.m_file; + m_line = other.m_line; + m_function = other.m_function; + m_nested = other.m_nested ? other.m_nested->clone() : nullptr; + m_what_cache.clear(); + } + return *this; + } + + ExceptionBase& operator=(ExceptionBase&& other) noexcept { + if (this != &other) { + m_code = other.m_code; + m_message = std::move(other.m_message); + m_file = std::move(other.m_file); + m_line = other.m_line; + m_function = std::move(other.m_function); + m_nested = std::move(other.m_nested); + m_what_cache = std::move(other.m_what_cache); + other.m_code = 0; + other.m_line = 0; + } + return *this; + } + + // ============ 访问器 ============ + + int code() const noexcept { return m_code; } + const std::string& message() const noexcept { return m_message; } + const std::string& file() const noexcept { return m_file; } + int line() const noexcept { return m_line; } + const std::string& function() const noexcept { return m_function; } + const ExceptionBase* nested() const noexcept { return m_nested.get(); } + + // ============ 异常链操作 ============ + + void set_nested(std::unique_ptr nested) noexcept { + m_nested = std::move(nested); + m_what_cache.clear(); + } + + template + void set_nested(const ExType& nested_ex) noexcept { + static_assert(std::is_base_of::value, + "ExType must inherit from ExceptionBase"); + m_nested = std::make_unique(nested_ex); + m_what_cache.clear(); + } + + // ============ std::exception 接口 ============ + + const char* what() const noexcept override { + if (m_what_cache.empty()) { + build_what_message(); + } + return m_what_cache.c_str(); + } + + // ============ 多态支持 ============ + + virtual std::unique_ptr clone() const { + return std::make_unique(*this); + } + + // ============ 重新抛出 ============ + + [[noreturn]] void rethrow() const { + throw *this; + } + +protected: + // ============ 可重写的消息构建方法 ============ + + virtual std::string build_message() const { + std::string result; + + // 错误码 + if (m_code != 0) { + result += "[Error " + std::to_string(m_code) + "] "; + } + + // 错误消息 + result += m_message; + + /* ===== 以下「位置信息」与「嵌套异常」暂时以注释方式保留 ===== + * 原因:黑盒检测发现 what() 的返回值会透传到 HTTP 响应体, + * 导致泄露源码文件路径、行号、C++ 模板类型签名(函数名), + * 便于攻击者定位代码、确认技术栈、构造针对性 payload(P0-3)。 + * 排障:完整信息仍由各 handler 通过 std::cerr 输出到服务端日志。 + * 恢复:如需调试可临时取消下方注释;生产环境必须保持注释状态, + * 并确保 what() 不直接进入客户端响应。 + * ==================================================================== + // 位置信息 + if (!m_file.empty()) { + result += " (at " + m_file; + if (m_line > 0) { + result += ":" + std::to_string(m_line); + } + if (!m_function.empty()) { + result += " in " + m_function; + } + result += ")"; + } + + // 嵌套异常 + if (m_nested) { + result += "\n Caused by: " + std::string(m_nested->what()); + } + * ==================================================================== */ + + return result; + } + +private: + // ============ 内部方法 ============ + + void build_what_message() const noexcept { + try { + m_what_cache = build_message(); + } catch (...) { + // 降级处理 + m_what_cache = "Exception: " + m_message; + } + } + +private: + // ============ 成员变量 ============ + + int m_code; + std::string m_message; + std::string m_file; + int m_line; + std::string m_function; + std::unique_ptr m_nested; + mutable std::string m_what_cache; +}; + +// ============================================================================ +// 宏定义 - 方案二完整版 +// ============================================================================ + +/** + * @brief 抛出指定类型的异常(自动添加 source_location) + * + * 适用场景:子类构造函数与 ExceptionBase 相同 + * + * 使用示例: + * THROW_EX(NetworkException, 404, "Server not found") + * THROW_EX(DatabaseException, 100, "Connection timeout") + */ +#define THROW_EX(ExType, code, msg) \ + throw ExType(code, msg, std::source_location::current()) + +/** + * @brief 抛出异常(仅消息,不传错误码) + * + * 适用场景:子类支持只传消息的构造函数 + * + * 使用示例: + * THROW_EX_MSG(ValidationException, "Invalid input data") + */ +#define THROW_EX_MSG(ExType, msg) \ + throw ExType(msg, std::source_location::current()) + +/** + * @brief 抛出异常(支持任意构造函数参数) + * + * 适用场景:子类有额外的构造函数参数 + * + * 使用示例: + * // 子类定义 + * class FileException : public ExceptionBase { + * public: + * FileException(int code, const std::string& msg, + * const std::string& filename, + * const std::source_location& loc = std::source_location::current()) + * : ExceptionBase(code, msg, loc), m_filename(filename) {} + * }; + * + * // 使用 + * THROW_EX_CUSTOM(FileException, 404, "File not found", "/etc/config.txt") + */ +#define THROW_EX_CUSTOM(ExType, ...) \ + throw ExType(__VA_ARGS__, std::source_location::current()) + +/** + * @brief 抛出异常并携带嵌套异常 + * + * 适用场景:需要保留原始异常信息 + * + * 使用示例: + * try { + * connect_to_server(); + * } catch (const NetworkException& e) { + * THROW_EX_NESTED(ServiceException, 500, "Service failed", e) + * } + */ +#define THROW_EX_NESTED(ExType, code, msg, nested_ex) \ + do { \ + auto ex = ExType(code, msg, std::source_location::current()); \ + ex.set_nested(nested_ex); \ + throw ex; \ + } while(0) + +/** + * @brief 抛出异常并携带嵌套异常(支持自定义参数) + * + * 使用示例: + * THROW_EX_NESTED_CUSTOM(FileException, 500, "Read failed", + * original_ex, "/data/file.txt") + */ +#define THROW_EX_NESTED_CUSTOM(ExType, code, msg, nested_ex, ...) \ + do { \ + auto ex = ExType(code, msg, __VA_ARGS__, std::source_location::current()); \ + ex.set_nested(nested_ex); \ + throw ex; \ + } while(0) + +/** + * @brief 检查条件,失败则抛出异常 + * + * 使用示例: + * THROW_IF(ptr == nullptr, NullPointerException, 100, "Pointer is null") + * THROW_IF(data.empty(), ValidationException, 200, "Data cannot be empty") + */ +#define THROW_IF(cond, ExType, code, msg) \ + do { \ + if (cond) { \ + THROW_EX(ExType, code, msg); \ + } \ + } while(0) + +/** + * @brief 检查条件,失败则抛出异常(仅消息) + */ +#define THROW_IF_MSG(cond, ExType, msg) \ + do { \ + if (cond) { \ + THROW_EX_MSG(ExType, msg); \ + } \ + } while(0) + +/** + * @brief 检查条件,失败则抛出异常(自定义参数) + */ +#define THROW_IF_CUSTOM(cond, ExType, ...) \ + do { \ + if (cond) { \ + THROW_EX_CUSTOM(ExType, __VA_ARGS__); \ + } \ + } while(0) + +/** + * @brief 检查条件,失败则抛出异常(带嵌套异常) + */ +#define THROW_IF_NESTED(cond, ExType, code, msg, nested_ex) \ + do { \ + if (cond) { \ + THROW_EX_NESTED(ExType, code, msg, nested_ex); \ + } \ + } while(0) + +/** + * @brief 检查条件,失败则抛出异常(带嵌套异常和自定义参数) + */ +#define THROW_IF_NESTED_CUSTOM(cond, ExType, code, msg, nested_ex, ...) \ + do { \ + if (cond) { \ + THROW_EX_NESTED_CUSTOM(ExType, code, msg, nested_ex, __VA_ARGS__); \ + } \ + } while(0) + +// ============================================================================ +// 辅助宏 - 用于格式化错误消息 +// ============================================================================ + +/** + * @brief 格式化错误消息(使用流式语法) + * + * 使用示例: + * THROW_EX_FMT(NetworkException, 404, "Failed to connect to " << host << ":" << port) + */ +#define THROW_EX_FMT(ExType, code, msg_expr) \ + do { \ + std::ostringstream _oss; \ + _oss << msg_expr; \ + THROW_EX(ExType, code, _oss.str()); \ + } while(0) + +/** + * @brief 格式化错误消息(使用 printf 风格) + * + * 使用示例: + * THROW_EX_PRINTF(NetworkException, 404, "Failed to connect to %s:%d", host.c_str(), port) + */ +#define THROW_EX_PRINTF(ExType, code, fmt, ...) \ + do { \ + char _buf[1024]; \ + snprintf(_buf, sizeof(_buf), fmt, __VA_ARGS__); \ + THROW_EX(ExType, code, std::string(_buf)); \ + } while(0) + +/** + * @brief 条件检查 + 格式化消息 + */ +#define THROW_IF_FMT(cond, ExType, code, msg_expr) \ + do { \ + if (cond) { \ + THROW_EX_FMT(ExType, code, msg_expr); \ + } \ + } while(0) + +/** + * @brief 条件检查 + printf 风格格式化 + */ +#define THROW_IF_PRINTF(cond, ExType, code, fmt, ...) \ + do { \ + if (cond) { \ + THROW_EX_PRINTF(ExType, code, fmt, __VA_ARGS__); \ + } \ + } while(0) + +#endif // EXCEPTION_BASE_H diff --git a/ca/inc/LocalTime.hpp b/ca/inc/LocalTime.hpp new file mode 100644 index 0000000..2883845 --- /dev/null +++ b/ca/inc/LocalTime.hpp @@ -0,0 +1,71 @@ +#pragma once +#include +#include +#include +#include +#include +#include +namespace qing { + +class LocalTime { +public: + // 获取当前本地时间字符串 + static std::string getCurrent(const std::string& format = "%Y-%m-%d %H:%M:%S") { + auto now = std::chrono::system_clock::now(); + std::time_t t = std::chrono::system_clock::to_time_t(now); + std::tm tm_buf{}; + localtime_r(&t, &tm_buf); + char buf[128]; + std::strftime(buf, sizeof(buf), format.c_str(), &tm_buf); + return std::string(buf); + } + + // 解析时间字符串为 system_clock::time_point(本地时间 → UTC) + static std::optional parse( + const std::string& str, + const std::string& format = "%Y-%m-%d %H:%M:%S" + ) { + std::tm tm_buf{}; + std::istringstream iss{str}; + iss >> std::get_time(&tm_buf, format.c_str()); + if (iss.fail()) { + return std::nullopt; + } + // mktime 将本地时间转换为 time_t(UTC) + std::time_t t = std::mktime(&tm_buf); + if (t == -1) { + return std::nullopt; + } + return std::chrono::system_clock::from_time_t(t); + } + + // ----- 过期判断 ----- + static bool isExpired(const std::chrono::system_clock::time_point& tp) { + return std::chrono::system_clock::now() > tp; + } + + template + static bool isExpired(const std::chrono::system_clock::time_point& tp, + const std::chrono::duration& gracePeriod) { + return std::chrono::system_clock::now() > tp + gracePeriod; + } + + static bool isExpired(const std::string& timeStr, + const std::string& format = "%Y-%m-%d %H:%M:%S") { + auto parsed = parse(timeStr, format); + if (!parsed) return true; + return isExpired(*parsed); + } + + template + static bool isExpired(const std::string& timeStr, + const std::chrono::duration& gracePeriod, + const std::string& format = "%Y-%m-%d %H:%M:%S") { + auto parsed = parse(timeStr, format); + if (!parsed) return true; + return isExpired(*parsed, gracePeriod); + } +}; + + +} diff --git a/ca/inc/LogC.hpp b/ca/inc/LogC.hpp new file mode 100644 index 0000000..e58736e --- /dev/null +++ b/ca/inc/LogC.hpp @@ -0,0 +1,206 @@ +#pragma once + +/** LogC — ca 统一日志客户端(C++ 版,对应各 Python 服务的 log_client.py) + * + * 设计要点(与 Python 版对齐): + * - 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表 + * - 异步非阻塞:基于 pmc::net::AsyncQueue 内存队列 + 后台线程批量推送,不拖慢业务 + * - fail-safe:db 服务不可用时静默丢弃(仅 stderr 提示),绝不影响业务 + * - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush + * - 背压保护:队列上限 10000 条,满时丢最旧日志,防止内存无限增长 + * + * 用法: + * #include "LogC.hpp" + * + * qing::LogC::instance().info("ca", "启动完成"); + * qing::LogC::instance().error("ca", "数据库连接失败", + * std::nullopt, R"({"errno":2002})"); + * + * // 进程退出前: + * qing::LogC::instance().shutdown(); // 停止后台线程并 flush 剩余日志(幂等) + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +namespace qing { + +namespace json = boost::json; + +class LogC { +public: + struct Entry { + std::string service; + std::string level; + std::string message; + std::optional trace_id; + std::optional extra; // 已序列化的 JSON 字符串(存 Logs.extra TEXT) + }; + + static LogC& instance() { + static LogC inst; + return inst; + } + + // ---- 公开接口 -------------------------------------------------------- + + // 推送一条日志(非阻塞,立即返回) + void push(const std::string& service, const std::string& level, + const std::string& message, + std::optional trace_id = std::nullopt, + std::optional extra = std::nullopt) { + _queue.push(Entry{service, level, message, + std::move(trace_id), std::move(extra)}); + } + + void debug(const std::string& service, const std::string& message, + std::optional trace_id = std::nullopt, + std::optional extra = std::nullopt) { + push(service, "DEBUG", message, std::move(trace_id), std::move(extra)); + } + void info(const std::string& service, const std::string& message, + std::optional trace_id = std::nullopt, + std::optional extra = std::nullopt) { + push(service, "INFO", message, std::move(trace_id), std::move(extra)); + } + void warning(const std::string& service, const std::string& message, + std::optional trace_id = std::nullopt, + std::optional extra = std::nullopt) { + push(service, "WARNING", message, std::move(trace_id), std::move(extra)); + } + void error(const std::string& service, const std::string& message, + std::optional trace_id = std::nullopt, + std::optional extra = std::nullopt) { + push(service, "ERROR", message, std::move(trace_id), std::move(extra)); + } + void critical(const std::string& service, const std::string& message, + std::optional trace_id = std::nullopt, + std::optional extra = std::nullopt) { + push(service, "CRITICAL", message, std::move(trace_id), std::move(extra)); + } + + // 手动 flush:通知后台线程立即发送当前积攒的批次,并等待其完成 + void flush() { + _queue.flush(); + } + + // 停止后台线程并 flush 剩余日志(进程退出前调用,幂等) + void shutdown() { + _queue.shutdown(); + } + + // ---- 配置(构造后、首次推送前可改)------------------------------------ + void set_endpoint(const std::string& addr, int port) { + std::lock_guard lock(_endpoint_mtx); + _db_addr = addr; + _db_port = port; + } + +private: + LogC() + : _queue( + [this](std::vector&& batch) { this->_send_batch(std::move(batch)); }, + {_BATCH_SIZE, _FLUSH_INTERVAL, _MAX_QUEUE_SIZE, 0.5}, + [](const std::exception& e, std::vector&&) { + std::cerr << "[LogC] 发送失败: " << e.what() << std::endl; + }) + { + _queue.start(); + } + + ~LogC() { shutdown(); } + + LogC(const LogC&) = delete; + LogC& operator=(const LogC&) = delete; + + // 通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表 + void _send_batch(const std::vector& batch) { + std::string addr; + int port; + { + std::lock_guard lock(_endpoint_mtx); + addr = _db_addr; + port = _db_port; + } + + const std::string now = _utc_now_iso(); // 批次共享同一时间戳(对齐 Python 版) + try { + pmc::net::MsgClient client(addr, port, "ca-log"); + for (const auto& e : batch) { + json::object values; + values["ts"] = now; + values["service"] = e.service; + values["level"] = e.level; + values["message"] = e.message; + values["trace_id"] = e.trace_id ? json::value(*e.trace_id) + : json::value(nullptr); + values["extra"] = e.extra ? json::value(*e.extra) + : json::value(nullptr); + + json::object payload; + payload["_route"] = "execute"; + payload["action"] = "insert"; + payload["table"] = "Logs"; + payload["values"] = std::move(values); + + const std::string resp = client.call( + json::serialize(payload), _TIMEOUT, + /*retry_on_refused=*/false); // 日志是 fail-safe,db 不可用时静默丢弃,绝不阻塞重试 + auto obj = json::parse(resp).as_object(); + if (!obj.at("success").as_bool()) { + std::string err = "unknown"; + if (auto* ev = obj.if_contains("error")) { + if (ev->is_string()) err = ev->as_string().c_str(); + } + std::cerr << "[LogC] INSERT 失败: " << err << std::endl; + } + } + } catch (const std::exception& e) { + // fail-safe:db 不可用时静默丢弃,绝不影响业务 + std::cerr << "[LogC] 发送失败: " << e.what() << std::endl; + } + } + + // UTC ISO8601 时间戳(对齐 Python datetime.now(timezone.utc).isoformat()) + // 形如 2026-07-29T08:30:00.123456+00:00,字典序即时间序,便于 db 按 ts 清理 + static std::string _utc_now_iso() { + const auto now = std::chrono::system_clock::now(); + const auto t = std::chrono::system_clock::to_time_t(now); + const auto us = std::chrono::duration_cast( + now.time_since_epoch()).count() % 1000000; + std::tm tm{}; + ::gmtime_r(&t, &tm); + char buf[40]; + std::snprintf(buf, sizeof buf, + "%04d-%02d-%02dT%02d:%02d:%02d.%06d+00:00", + tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, + tm.tm_hour, tm.tm_min, tm.tm_sec, static_cast(us)); + return buf; + } + + // ---- 配置项 ----------------------------------------------------------- + std::string _db_addr = "127.0.0.1"; + int _db_port = 8005; + std::mutex _endpoint_mtx; // 保护 _db_addr/_db_port 读写(set_endpoint 与后台线程并发) + + static constexpr size_t _BATCH_SIZE = 50; // 队列满 50 条时 flush + static constexpr double _FLUSH_INTERVAL = 2.0; // 超时 2 秒强制 flush + static constexpr size_t _MAX_QUEUE_SIZE = 10000; // 队列上限,满时丢最旧 + static constexpr double _TIMEOUT = 5.0; // socket 超时(秒) + + // ---- 核心队列 --------------------------------------------------------- + pmc::net::AsyncQueue _queue; +}; + +} // namespace qing diff --git a/ca/src/main.cpp b/ca/src/main.cpp new file mode 100644 index 0000000..78b7a44 --- /dev/null +++ b/ca/src/main.cpp @@ -0,0 +1,2463 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "ExpBase.hpp" +#include "LocalTime.hpp" +#include "Conf.hpp" +#include "DbC.hpp" +#include "LogC.hpp" + +using namespace pmc::net; +using namespace qing; +namespace json = boost::json; +namespace po = boost::program_options; + +constexpr const char *_VER = "1.0.0"; + +////////////////////////////////// +#include +#include +#include + +/* by suiyu + * + * 有一丢丢消耗资源(qing) */ +std::vector split(const std::string& s, char delim) { + std::vector tokens; + std::istringstream stream(s); + std::string token; + while (std::getline(stream, token, delim)) { + // 跳过空串:避免 "a;b;" 末尾分号产生 "" 元素 + // 防御白名单匹配场景下空串被误判为命中 + if (!token.empty()) { + tokens.push_back(token); + } + } + return tokens; +} + +////////////////////////////////// +// PBKDF2-HMAC-SHA256 password hashing +////////////////////////////////// +class PBKDF2Hasher { +public: + static const int ITERATIONS = 100000; + static const int SALT_LEN = 16; + static const int HASH_LEN = 32; + + static std::string hash_password(const std::string& password) { + unsigned char salt[SALT_LEN]; + RAND_bytes(salt, SALT_LEN); + + unsigned char hash[HASH_LEN]; + PKCS5_PBKDF2_HMAC( + password.c_str(), password.size(), + salt, SALT_LEN, + ITERATIONS, + EVP_sha256(), + HASH_LEN, hash + ); + + // Format: pbkdf2:100000:: + std::string salt_hex = bytes_to_hex(salt, SALT_LEN); + std::string hash_hex = bytes_to_hex(hash, HASH_LEN); + return "pbkdf2:" + std::to_string(ITERATIONS) + ":" + salt_hex + ":" + hash_hex; + } + + static bool verify_password(const std::string& password, const std::string& stored) { + // Parse stored hash + if (stored.substr(0, 7) != "pbkdf2:") { + return false; + } + + auto parts = split(stored, ':'); + if (parts.size() != 4) return false; + + int iterations = std::stoi(parts[1]); + auto salt = hex_to_bytes(parts[2]); + auto expected_hash = hex_to_bytes(parts[3]); + + unsigned char hash[HASH_LEN]; + PKCS5_PBKDF2_HMAC( + password.c_str(), password.size(), + salt.data(), salt.size(), + iterations, + EVP_sha256(), + HASH_LEN, hash + ); + + // Constant-time comparison + if (expected_hash.size() != (size_t)HASH_LEN) return false; + unsigned char diff = 0; + for (int i = 0; i < HASH_LEN; i++) { + diff |= hash[i] ^ expected_hash[i]; + } + return diff == 0; + } + +private: + static std::string bytes_to_hex(const unsigned char* data, size_t len) { + static const char hex[] = "0123456789abcdef"; + std::string result; + result.reserve(len * 2); + for (size_t i = 0; i < len; i++) { + result.push_back(hex[data[i] >> 4]); + result.push_back(hex[data[i] & 0x0F]); + } + return result; + } + + static std::vector hex_to_bytes(const std::string& hex_str) { + std::vector bytes; + bytes.reserve(hex_str.size() / 2); + for (size_t i = 0; i < hex_str.size(); i += 2) { + auto hi = hex_char_to_nibble(hex_str[i]); + auto lo = hex_char_to_nibble(hex_str[i + 1]); + bytes.push_back((hi << 4) | lo); + } + return bytes; + } + + static unsigned char hex_char_to_nibble(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return 10 + (c - 'a'); + if (c >= 'A' && c <= 'F') return 10 + (c - 'A'); + return 0; + } +}; + +////////////////////////////////// +// Password strength validator +////////////////////////////////// +class PasswordValidator { +public: + struct Result { + bool valid; + std::string message; + }; + + static Result validate(const std::string& password) { + if (password.size() < 8) { + return {false, "Password must be at least 8 characters"}; + } + if (password.size() > 128) { + return {false, "Password too long (max 128)"}; + } + + bool has_upper = false, has_lower = false, has_digit = false, has_special = false; + for (char c : password) { + // std::isupper 等函数参数为 int,char 为有符号且非 ASCII 时是 UB + // 必须 static_cast 后再传入(P1-2) + unsigned char uc = static_cast(c); + if (std::isupper(uc)) has_upper = true; + else if (std::islower(uc)) has_lower = true; + else if (std::isdigit(uc)) has_digit = true; + else has_special = true; + } + + int categories = has_upper + has_lower + has_digit + has_special; + if (categories < 3) { + return {false, "Password must contain at least 3 of: uppercase, lowercase, digit, special character"}; + } + + return {true, "OK"}; + } +}; + +////////////////////////////////// +// IP Rate Limiter (in-memory) +////////////////////////////////// +#include +#include +#include + +class RateLimiter { +public: + struct AttemptInfo { + int count; + std::chrono::steady_clock::time_point first_attempt; + }; + + bool check_and_record(const std::string& ip, int max_attempts = 5, int window_seconds = 300) { + std::lock_guard lock(mtx); + auto now = std::chrono::steady_clock::now(); + + auto& info = attempts[ip]; + + // Reset if window expired + auto elapsed = std::chrono::duration_cast(now - info.first_attempt).count(); + if (elapsed > window_seconds) { + info.count = 0; + info.first_attempt = now; + } + + info.count++; + + // Cleanup old entries periodically + if (attempts.size() > 10000) { + for (auto it = attempts.begin(); it != attempts.end(); ) { + auto e = std::chrono::duration_cast(now - it->second.first_attempt).count(); + if (e > window_seconds) it = attempts.erase(it); + else ++it; + } + } + + return info.count <= max_attempts; + } + +private: + std::unordered_map attempts; + std::mutex mtx; +}; + +////////////////////////////////// +/* -------- 数据库异常\数据库操作失败 -------- */ +class DatabaseException: public ExceptionBase { +public: + using ExceptionBase::ExceptionBase; + + std::string build_message() const override { + return "[Database]" + ExceptionBase::build_message(); + } +}; + +/* http异常、非200 */ +class HttpException: public ExceptionBase { +public: + HttpException(int code, const std::string& msg, + int http_status, const std::string& url, + const std::source_location& loc = std::source_location::current()) + : ExceptionBase(code, msg, loc) + , m_http_status(http_status) + , m_url(url) {} + std::string build_message() const override { + return "[Http]" + ExceptionBase::build_message(); + } + int status() const { + return m_http_status; + } + const std::string& url() const { + return m_url; + } +private: + int m_http_status; + std::string m_url; +}; + + +//void db_init(); /* 初始化数据库 */ +void registerRoutes(HttpServer &server); + +/* http视图包装器,主要是将异常回显到浏览器 */ +http::response wrapperHttp( + const http::request& req, + const std::unordered_map& params, + HttpRequestHandler _main); + + +/* 前置转发视图(转发到 gateway),额外接受一个method字符串 */ +http::response handleForward( + const http::request& req, + const std::unordered_map& params, + const char *method +); + +/* */ +http::response handleLogin( + const http::request& req, + const std::unordered_map& params); + +http::response handleRegister( + const http::request& req, + const std::unordered_map& params); + +/* 发送注册邮箱验证码:pymsg 调 smtp 服务 send_email 路由 */ +http::response handleSendEmailCode( + const http::request& req, + const std::unordered_map& params); + +/* 发送重置密码邮箱验证码:pymsg 调 smtp 服务 send_email 路由(定制重置文案) */ +http::response handleResetSendCode( + const http::request& req, + const std::unordered_map& params); + +/* 重置密码:与注册流程一致,但最后一步由创建用户改为更新密码 */ +http::response handleResetPassword( + const http::request& req, + const std::unordered_map& params); + +http::response handleLogout( + const http::request& req, + const std::unordered_map& params); + +http::response handleRefreshToken( + const http::request& req, + const std::unordered_map& params); + +/* 当前用户身份查询:验签 Token 后返回 user / is_admin / Token 生命周期信息 */ +http::response handleWhoami( + const http::request& req, + const std::unordered_map& params); + +/* 内部服务间管理员验签视图:供 core 等内部服务调用,验证 Token 是否为有效管理员 */ +http::response handleAdminVerify( + const http::request& req, + const std::unordered_map& params); + +/* 代理 captcha 图形验证码:pymsg 调 captcha 服务,返回 PNG 图片 */ +http::response handleCaptchaImage( + const http::request& req, + const std::unordered_map& params); + +/* 从 Cookie 头中解析指定名称的值(找不到返回空串) */ +std::string get_cookie_value(const std::string& cookie_header, const std::string& name); + + +/* headers can't processed */ +bool is_special_header(const std::string &header); + +/* 请求路径安全性校验(防路径遍历/协议注入/空字节) */ +bool is_safe_target(const std::string& target); + +/* ===== Token 吊销机制 ===== */ +// Token 验签结果结构体 +struct TokenInfo { + bool valid = false; + std::string username; + std::string jti; // Token 唯一标识(用于单 Token 吊销) + int ver = 1; // 用户 Token 版本号(用于全设备吊销) + bool is_admin = false; + int keep_time = 3600; + int refresh_count = 0; + int max_refresh = 10; + std::string iat; + std::string created_at; + int http_status = 401; // 验签失败时的推荐 HTTP 状态码 +}; + +// 生成 128-bit 随机 JTI(hex 字符串,32 字符) +std::string generate_jti(); + +// 获取用户当前 Token 版本号(不存在则初始化为 1) +int get_user_ver(const std::string& username); + +// 吊销单个 Token(将 jti 加入黑名单直到其过期) +void revoke_jti(const std::string& jti, int keep_time); + +// 吊销用户所有 Token(递增版本号,使该用户所有旧 Token 失效) +void revoke_all_user_tokens(const std::string& username); + +// 从请求中提取原始 Token 字符串(Authorization: Bearer 优先,回退 Cookie q4_token) +// 返回值:原始 "base64_signature" 格式字符串;提取失败返回空串 +// out_cookie_token:如果 Token 来自 Cookie,回填 cookie_token 值(供下游 Authorization 注入) +std::string extract_token_from_request(const http::request& req, std::string& out_cookie_token); + +// 统一 Token 验签:RSA 验签 + 过期检查 + jti 黑名单检查 + 版本号检查 +// 参数 raw_token:"base64data_signature" 格式(不含 "Bearer " 前缀) +TokenInfo verify_token(const std::string& raw_token); + +/* configure items */ +static std::string _ADDR; +static int _PORT; +static bool _TLS = false; +/* 上游 gateway 地址(前置拓扑:ca 认证后转发至此),由 [Upstream] gateway 配置 */ +static std::string _UPSTREAM_GATEWAY = "http://127.0.0.1:8445"; +static int _TOKEN_KEEP_TIME = 3600; // Token 有效期(秒),可由 [Token] keep_time 配置 +static int _TOKEN_REFRESH_GRACE_MULTIPLIER = 2; // Token 刷新宽限期倍数,过期后 iat + keep_time*倍数 窗口内仍可刷新 +static int _TOKEN_MAX_REFRESH = 10; // Token 生命周期内最大刷新次数,可由 [Token] max_refresh 配置 +static std::vector _CORS_ORIGINS; // CORS 允许的 Origin 白名单,可由 [CORS] allowed_origins 配置 +static std::vector _ADMIN_USERS; // 管理员账号白名单,可由 [Admin] users 配置(分号分隔),登录时写入 Token payload 的 is_admin + +/* 下游 pymsg 服务地址(从 confs [Downstream] 段加载,可被环境变量覆盖) */ +static std::string _DB_HOST = "127.0.0.1"; +static int _DB_PORT = 8005; +static std::string _CAPTCHA_HOST = "127.0.0.1"; +static int _CAPTCHA_PORT = 8009; +static std::string _SMTP_HOST = "127.0.0.1"; +static int _SMTP_PORT = 8008; +/* confs 引导配置(不依赖 confs 本身,环境变量 CONFS_HOST/CONFS_PORT 覆盖,默认 127.0.0.1:8010) */ +static std::string _CONFS_HOST = "127.0.0.1"; +static int _CONFS_PORT = 8010; + +/* WebSocket 代理路由:path -> ws://backend-host:port/target + * 仅管理员用户可访问,握手阶段由 ws_proxy 回调验签 Token 中的 is_admin */ +static std::unordered_map _WS_PROXY_ROUTES; + +/* instance items */ +static std::unique_ptr _SERVE; +static std::unique_ptr _KEYGEN; +//static std::unique_ptr _SQLDB; +static RateLimiter _LOGIN_RATE; +static RateLimiter _REGISTER_RATE; +static RateLimiter _EMAIL_CODE_RATE; // 发送邮箱验证码 IP 级限流 +static RateLimiter _REFRESH_TOKEN_RATE; // Token 刷新限流 +static RateLimiter _RESET_PASSWORD_RATE; // 重置密码 IP 级限流 +static RateLimiter _RESET_CODE_RATE; // 发送重置密码验证码 IP 级限流 +static RateLimiter _CAPTCHA_IMAGE_RATE; // 获取验证码图片 IP 级限流(防全局 DoS,P1-1) +// 邮箱级双窗口限流:同一邮箱在 ca 层的发送配额。 +// 配合 IP 限流(IP 可被代理池绕过)与 smtp 同邮箱 60s 冷却(仅防短期重复)。 +// 双窗口设计:小时窗限制短期轰炸,天窗限制长期骚扰。 +static RateLimiter _EMAIL_ADDR_RATE_HOUR; // 3 次/小时/邮箱 +static RateLimiter _EMAIL_ADDR_RATE_DAY; // 10 次/天/邮箱 + +/* 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 + +/* Token 吊销机制(P1:Token 吊销缺失修复) + * + * 双轨纯内存方案: + * 1. jti_denylist:单 Token 吊销(logout),key=jti,val=过期时间(steady_clock),到期自动清理 + * 2. user_ver_map:用户级版本号(改密/封禁),key=username,val=当前版本号,签发新 Token 时读取 + * + * 设计优势: + * - 零 DB 改动:黑名单和版本号全在 ca 进程内存中 + * - 重启自动清空:ca 每次重启 RSA 密钥对重新生成,旧 Token 签名天然验签失败 + * - O(1) 验签:unordered_set/map 查找均摊 O(1),对代理转发性能无影响 + * - 内存有界:jti_denylist 定期清理过期条目;user_ver_map 按需清理 + */ +struct JtiDenylistEntry { + std::chrono::steady_clock::time_point expire_at; +}; +static std::unordered_map _JTI_DENYLIST; +static std::unordered_map _USER_VER_MAP; +static std::mutex _TOKEN_REVOKE_MTX; +static constexpr size_t _JTI_DENYLIST_MAX_SIZE = 5000; // 超过此阈值触发批量清理 + +/* 集中式日志客户端:推送 ca 运行日志到 db 模块 Logs 表(异步批量,fail-safe) */ +static inline LogC& logs() { return LogC::instance(); } + +/* 调用 captcha 服务(pymsg/TCP :8009)校验图形验证码。 + * Token 一次性消费;服务不可用或响应异常一律视为校验失败(fail-closed)。 + * 唯一验证码渠道:内置 _CAPTCHA_STORE 渠道已废弃(审计 A6/A7)—— + * 其接口明文返回答案、无限流、存储无界,且与前端 /captcha/image 渠道不互通。 */ +bool verify_captcha_remote(const std::string& token, const std::string& answer) { + try { + pmc::net::MsgClient client(_CAPTCHA_HOST, _CAPTCHA_PORT, "ca"); + json::value jv_req = { + {"_route", "verify"}, + {"token", token}, + {"answer", answer} + }; + 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; + return obj.at("data").as_object().at("valid").as_bool(); + } catch (const std::exception&) { + return false; + } +} + +/* 调用 smtp 服务(pymsg/TCP)校验邮箱验证码。 + * 验证码一次性消费;服务不可用或响应异常一律视为校验失败(fail-closed)。 */ +bool verify_email_code_remote(const std::string& email, const std::string& code) { + try { + pmc::net::MsgClient client(_SMTP_HOST, _SMTP_PORT, "ca"); + json::value jv_req = { + {"_route", "verify_email"}, + {"email", email}, + {"code", code} + }; + 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; + return obj.at("data").as_object().at("valid").as_bool(); + } catch (const std::exception&) { + return false; + } +} + +/* ===== Token 吊销机制实现 ===== */ + +std::string generate_jti() { + unsigned char jti_bytes[16]; + RAND_bytes(jti_bytes, 16); + std::string jti; + static const char hex[] = "0123456789abcdef"; + jti.reserve(32); + for (int i = 0; i < 16; i++) { + jti += hex[jti_bytes[i] >> 4]; + jti += hex[jti_bytes[i] & 0x0F]; + } + return jti; +} + +int get_user_ver(const std::string& username) { + std::lock_guard lock(_TOKEN_REVOKE_MTX); + auto it = _USER_VER_MAP.find(username); + if (it == _USER_VER_MAP.end()) { + _USER_VER_MAP[username] = 1; + return 1; + } + return it->second; +} + +void revoke_jti(const std::string& jti, int keep_time) { + if (jti.empty()) return; + std::lock_guard lock(_TOKEN_REVOKE_MTX); + auto expire = std::chrono::steady_clock::now() + std::chrono::seconds(keep_time); + _JTI_DENYLIST[jti] = {expire}; + + // 超过阈值时批量清理过期条目 + if (_JTI_DENYLIST.size() > _JTI_DENYLIST_MAX_SIZE) { + auto now = std::chrono::steady_clock::now(); + for (auto it = _JTI_DENYLIST.begin(); it != _JTI_DENYLIST.end(); ) { + if (now > it->second.expire_at) + it = _JTI_DENYLIST.erase(it); + else + ++it; + } + } +} + +void revoke_all_user_tokens(const std::string& username) { + if (username.empty()) return; + std::lock_guard lock(_TOKEN_REVOKE_MTX); + auto it = _USER_VER_MAP.find(username); + if (it == _USER_VER_MAP.end()) { + _USER_VER_MAP[username] = 2; // 初始版本1,吊销后为2 + } else { + it->second++; + } + logs().info("ca", "用户所有Token已吊销: " + username, std::nullopt, + json::serialize(json::value{{"new_ver", _USER_VER_MAP[username]}})); +} + +std::string extract_token_from_request(const http::request& req, std::string& out_cookie_token) { + out_cookie_token.clear(); + std::string raw; + + // 1. 优先从 Authorization: Bearer 头获取 + auto it_auth = req.base().find("Authorization"); + if (it_auth != req.base().end()) { + raw = std::string(it_auth->value()); + } + + // 2. 回退从 Cookie q4_token 获取 + if (raw.empty()) { + auto cit = req.base().find("Cookie"); + if (cit != req.base().end()) { + std::string cookie_token = get_cookie_value(std::string(cit->value()), "q4_token"); + if (!cookie_token.empty()) { + raw = "Bearer " + cookie_token; + out_cookie_token = cookie_token; + } + } + } + + // 去掉 "Bearer " 前缀 + if (raw.size() >= 7 && raw.substr(0, 7) == "Bearer ") { + return raw.substr(7); + } + return raw; // 可能为空或不含前缀,由调用方处理 +} + +// 清理 denylist 中指定 jti(如果已过期则删除) +static void cleanup_jti_if_expired(const std::string& jti) { + auto now = std::chrono::steady_clock::now(); + auto it = _JTI_DENYLIST.find(jti); + if (it != _JTI_DENYLIST.end() && now > it->second.expire_at) { + _JTI_DENYLIST.erase(it); + } +} + +TokenInfo verify_token(const std::string& raw_token) { + TokenInfo info; + info.valid = false; + + if (raw_token.empty()) { + info.http_status = 401; + return info; + } + + // 解析 base64data_signature + size_t idx = raw_token.find("_"); + if (idx == std::string::npos) { + info.http_status = 400; + return info; + } + + std::string data = raw_token.substr(0, idx); + std::string sign = raw_token.substr(idx + 1); + std::string auth; + try { + auth = Base64::base64_decode_to_string(data); + } catch (const std::exception&) { + info.http_status = 400; + return info; + } + + // RSA 验签 + auto publ = MyRSA::Public_Key::from_pem(_KEYGEN->get_public_key_pem()); + if (!publ.Verify(auth, sign)) { + info.http_status = 401; + return info; + } + + // 解析 payload + json::value jv_t; + try { + jv_t = json::parse(auth); + } catch (const std::exception&) { + info.http_status = 400; + return info; + } + if (!jv_t.is_object()) { + info.http_status = 400; + return info; + } + + json::object& obj = jv_t.as_object(); + + // 提取字段(兼容旧 Token 缺失 jti/ver 字段的情况) + try { + info.username = json::value_to(obj.at("user")); + } catch (...) { + info.http_status = 400; + return info; + } + + // 过期检查 + auto it_created = obj.find("created_at"); + if (it_created != obj.end() && it_created->value().is_string()) { + info.created_at = json::value_to(it_created->value()); + } + auto it_keep = obj.find("keep_time"); + if (it_keep != obj.end() && it_keep->value().is_int64()) { + info.keep_time = static_cast(it_keep->value().as_int64()); + } + if (!info.created_at.empty()) { + auto created_tp = LocalTime::parse(info.created_at); + if (created_tp) { + auto now = std::chrono::system_clock::now(); + auto expire_tp = *created_tp + std::chrono::seconds(info.keep_time); + if (now > expire_tp) { + info.http_status = 401; + return info; + } + } + } + + // is_admin + auto it_admin = obj.find("is_admin"); + if (it_admin != obj.end() && it_admin->value().is_bool()) { + info.is_admin = it_admin->value().as_bool(); + } + + // refresh_count / max_refresh / iat + auto it_rc = obj.find("refresh_count"); + if (it_rc != obj.end() && it_rc->value().is_int64()) + info.refresh_count = static_cast(it_rc->value().as_int64()); + auto it_mr = obj.find("max_refresh"); + if (it_mr != obj.end() && it_mr->value().is_int64()) + info.max_refresh = static_cast(it_mr->value().as_int64()); + auto it_iat = obj.find("iat"); + if (it_iat != obj.end() && it_iat->value().is_string()) + info.iat = json::value_to(it_iat->value()); + + // jti 黑名单检查(单 Token 吊销) + auto it_jti = obj.find("jti"); + if (it_jti != obj.end() && it_jti->value().is_string()) { + info.jti = json::value_to(it_jti->value()); + std::lock_guard lock(_TOKEN_REVOKE_MTX); + cleanup_jti_if_expired(info.jti); // 惰性清理 + if (_JTI_DENYLIST.count(info.jti)) { + info.http_status = 401; + return info; + } + } + + // 版本号检查(全设备吊销:改密后版本号递增) + // 兼容旧 Token 无 ver 字段:视为 ver=1 + auto it_ver = obj.find("ver"); + int token_ver = 1; + if (it_ver != obj.end() && it_ver->value().is_int64()) { + token_ver = static_cast(it_ver->value().as_int64()); + } + info.ver = token_ver; + { + std::lock_guard lock(_TOKEN_REVOKE_MTX); + auto it_uv = _USER_VER_MAP.find(info.username); + int current_ver = (it_uv != _USER_VER_MAP.end()) ? it_uv->second : 1; + if (token_ver < current_ver) { + info.http_status = 401; + return info; + } + } + + // 全部通过 + info.valid = true; + info.http_status = 200; + return info; +} + +/* -------- 信号处理 -------- + * + * 安全设计原则: + * 1. 信号处理函数中只做 async-signal-safe 操作:设置 volatile sig_atomic_t 标志 + * 2. 绝不调用 std::cout、malloc、锁、ioc_->stop() 等非安全函数(可能导致死锁) + * 3. 主线程在安全点检测标志后再执行完整的关闭流程 + * 4. 忽略 SIGPIPE(写已关闭 socket 时内核默认发送该信号终止进程) + */ +static volatile std::sig_atomic_t _stop_requested = 0; + +void signalHandler(int signum) { + // 仅设置标志,不做其他操作(write 是 async-signal-safe 的,用于输出提示) + const char msg[] = "\n[ca] 收到关闭信号,准备退出...\n"; + ::write(STDERR_FILENO, msg, sizeof(msg) - 1); + _stop_requested = 1; +} + +void sigpipeHandler(int signum) { + // 忽略 SIGPIPE,write 调用会返回 EPIPE 错误而非终止进程 +} + +/* -------- 注册信号处理钩子 -------- */ +void set_signal_action() { + struct sigaction sa; + + // SIGINT / SIGTERM:安全设置关闭标志 + sa.sa_handler = signalHandler; + sigemptyset(&sa.sa_mask); + sa.sa_flags = 0; + sigaction(SIGINT, &sa, nullptr); + sigaction(SIGTERM, &sa, nullptr); + + // SIGPIPE:忽略,防止写关闭的 socket 时进程意外终止 + struct sigaction sa_pipe; + sa_pipe.sa_handler = sigpipeHandler; + sigemptyset(&sa_pipe.sa_mask); + sa_pipe.sa_flags = 0; + sigaction(SIGPIPE, &sa_pipe, nullptr); +} + +/* -------- 入口 --------*/ +int main(int argc, char **argv) { + + /* 首先处理命令行参数 */ + po::options_description desc("cpp language web ca (central auth)"); + desc.add_options() + ("help,h", "output help message") + ("version,v", "output version message") + ; + + + /* 执行解析(类似) */ + po::variables_map vm; + { + try { + po::store(po::parse_command_line(argc, argv, desc), vm); + po::notify(vm); + } + + catch (const std::exception &e) { + std::cerr << "ERROR: " << e.what() << std::endl; + std::cerr << "using --help to check options message" << std::endl; + throw e; + } + } + + + /* 分支:拉起帮助页面 */ + if (vm.count("help")) { + std::cout << desc << std::endl; + return 0; + } + + /* 分支:输出版本号 */ + if (vm.count("version")) { + std::cout << "CA " << _VER << std::endl; + return 0; + } + + /* ==================== 配置加载(两层:confs 强依赖 → 环境变量覆盖) ==================== + * 与 Python 服务(dnfs/db/static)保持一致的加载模式: + * 1. 引导配置:confs 地址从 CONFS_HOST/CONFS_PORT 环境变量读取,缺省 127.0.0.1:8010 + * 2. 从 confs 拉取 ca.ini 全文(retry_on_refused=true 等待 confs 就绪) + * 3. 用 boost::property_tree 解析 ini + * 4. 环境变量覆盖(最高优先级,Docker 环境需要服务名而非 127.0.0.1) + * 5. 配置完整性校验:必需字段缺失则启动失败 + * 不再支持本地 ini 文件回退(与 static 模块一致,配置统一由 confs 管理)。 + * ==================================================================================== */ + + { + /* 1. 引导配置:confs 连接地址(不依赖 confs 本身,避免循环依赖) */ + _CONFS_HOST = ConfIni::env("CONFS_HOST", "127.0.0.1"); + _CONFS_PORT = ConfIni::env_int("CONFS_PORT", 8010); + + ConfIni cf; + try { + /* 从 confs 拉取 ca.ini */ + std::cout << "[ca] 从 confs (" << _CONFS_HOST << ":" << _CONFS_PORT + << ") 拉取 ca.ini ..." << std::endl; + pmc::net::MsgClient confs_client(_CONFS_HOST, _CONFS_PORT, "ca"); + json::object req; + req["_route"] = "get"; + req["name"] = "ca.ini"; + std::string resp_str = confs_client.call( + json::serialize(req), /*timeout=*/5.0, + /*retry_on_refused=*/true); + auto resp = json::parse(resp_str).as_object(); + if (!resp.at("success").as_bool()) { + std::string err = "unknown"; + if (auto* ev = resp.if_contains("error")) { + if (ev->is_string()) err = ev->as_string().c_str(); + } + std::cerr << "[ca] 致命错误: 从 confs 获取 ca.ini 失败: " << err << std::endl; + return 1; + } + std::string ini_content = resp.at("data").as_object().at("content").as_string().c_str(); + cf = ConfIni::fromString(ini_content); + std::cout << "[ca] 配置来源: confs (ca.ini @ " + << _CONFS_HOST << ":" << _CONFS_PORT << ")" << std::endl; + } + catch (ConfIni::IniConfigureFileParseException &e) { + std::cerr << "ERROR: " << e.what() << std::endl; + std::cerr << "ini parse error" << std::endl; + return 1; + } + catch (const std::exception &e) { + std::cerr << "[ca] 致命错误: 配置加载失败: " << e.what() << std::endl; + return 1; + } + + /* 2. 从 ini 读取配置 */ + _ADDR = cf.get("Server.address", ""); + _PORT = cf.get("Server.port", -1); + _TLS = cf.get("Server.tls", false); + _UPSTREAM_GATEWAY = cf.get("Upstream.gateway", "http://127.0.0.1:8445"); + _TOKEN_KEEP_TIME = cf.get("Token.keep_time", 3600); + _TOKEN_REFRESH_GRACE_MULTIPLIER = cf.get("Token.refresh_grace_multiplier", 2); + _TOKEN_MAX_REFRESH = cf.get("Token.max_refresh", 10); + _CORS_ORIGINS = split(cf.get("CORS.allowed_origins", ""), ';'); + _ADMIN_USERS = split(cf.get("Admin.users", ""), ';'); + + /* 下游服务地址 */ + _DB_HOST = cf.get("Downstream.db_host", "127.0.0.1"); + _DB_PORT = cf.get("Downstream.db_port", 8005); + _CAPTCHA_HOST = cf.get("Downstream.captcha_host", "127.0.0.1"); + _CAPTCHA_PORT = cf.get("Downstream.captcha_port", 8009); + _SMTP_HOST = cf.get("Downstream.smtp_host", "127.0.0.1"); + _SMTP_PORT = cf.get("Downstream.smtp_port", 8008); + + /* [WsProxy] WebSocket 代理路由:格式 /path=ws://host:port/target;... */ + { + std::string ws_cfg = cf.get("WsProxy.routes", ""); + if (!ws_cfg.empty()) { + auto entries = split(ws_cfg, ';'); + for (auto& entry : entries) { + auto eq = entry.find('='); + if (eq != std::string::npos) { + std::string path = entry.substr(0, eq); + std::string url = entry.substr(eq + 1); + auto trim = [](std::string& s) { + while (!s.empty() && std::isspace((unsigned char)s.front())) s.erase(s.begin()); + while (!s.empty() && std::isspace((unsigned char)s.back())) s.pop_back(); + }; + trim(path); trim(url); + if (!path.empty() && !url.empty()) { + _WS_PROXY_ROUTES[path] = url; + } + } + } + } + } + + /* 3. 环境变量覆盖(优先级最高,Docker 环境使用服务名而非 127.0.0.1) */ + std::string env_listen_addr = ConfIni::env("LISTEN_ADDR"); + if (!env_listen_addr.empty()) _ADDR = env_listen_addr; + int env_listen_port = ConfIni::env_int("LISTEN_PORT", -1); + if (env_listen_port > 0) _PORT = env_listen_port; + + std::string env_upstream = ConfIni::env("UPSTREAM_GATEWAY"); + if (!env_upstream.empty()) _UPSTREAM_GATEWAY = env_upstream; + + std::string env_db_host = ConfIni::env("DB_HOST"); + if (!env_db_host.empty()) _DB_HOST = env_db_host; + int env_db_port = ConfIni::env_int("DB_PORT", -1); + if (env_db_port > 0) _DB_PORT = env_db_port; + + std::string env_captcha_host = ConfIni::env("CAPTCHA_HOST"); + if (!env_captcha_host.empty()) _CAPTCHA_HOST = env_captcha_host; + int env_captcha_port = ConfIni::env_int("CAPTCHA_PORT", -1); + if (env_captcha_port > 0) _CAPTCHA_PORT = env_captcha_port; + + std::string env_smtp_host = ConfIni::env("SMTP_HOST"); + if (!env_smtp_host.empty()) _SMTP_HOST = env_smtp_host; + int env_smtp_port = ConfIni::env_int("SMTP_PORT", -1); + if (env_smtp_port > 0) _SMTP_PORT = env_smtp_port; + + /* 4. 配置完整性校验 */ + if (_ADDR.empty() || _PORT == -1) { + std::cerr << "[ca] 致命错误: 配置项 [Server] address/port 缺失" + "(ini 中未配置且无 LISTEN_ADDR/LISTEN_PORT 环境变量覆盖)" << std::endl; + return 1; + } + if (_UPSTREAM_GATEWAY.empty()) { + std::cerr << "[ca] 致命错误: 配置项 [Upstream] gateway 缺失" << std::endl; + return 1; + } + if (_DB_HOST.empty() || _DB_PORT <= 0) { + std::cerr << "[ca] 致命错误: 配置项 [Downstream] db_host/db_port 缺失" << std::endl; + return 1; + } + } + + /* 初始化下游客户端地址 */ + DbC::init(_DB_HOST, _DB_PORT); + logs().set_endpoint(_DB_HOST, _DB_PORT); + + std::cout << "[ca] 监听: " << _ADDR << ":" << _PORT + << " (TLS: " << (_TLS ? "on" : "off") << ")" << std::endl; + std::cout << "[ca] 下游服务: db=" << _DB_HOST << ":" << _DB_PORT + << ", captcha=" << _CAPTCHA_HOST << ":" << _CAPTCHA_PORT + << ", smtp=" << _SMTP_HOST << ":" << _SMTP_PORT << std::endl; + std::cout << "[ca] 上游 gateway: " << _UPSTREAM_GATEWAY << std::endl; + + // 根据 Server.tls 选择 HTTPS(带证书)或 HTTP(无证书)构造函数 + // HTTP 模式用于 WAF 终止 TLS 后的内部转发链路 + if (_TLS) { + _SERVE = std::make_unique (_ADDR, _PORT, "full_chain.crt", "server.key", 4); + } else { + _SERVE = std::make_unique (_ADDR, _PORT, 4); + } + _KEYGEN = std::make_unique (); + //_SQLDB = std::make_unique (_DB_PATH); + + /* 迁移到了外部 */ + //db_init(); + + /* */ + registerRoutes(*_SERVE); + + /* */ + set_signal_action(); + + std::cout << "CA " << _VER << " starting..." << std::endl; + + /* 在独立线程中运行服务器(start + run) + * 原因: + * 1. 信号处理函数只能安全设置 atomic 标志,不能直接调用 stop() + * 2. 主线程轮询检测关闭信号后,在安全上下文调用 stop() + * 3. 避免在信号处理函数中操作非 async-signal-safe 的 io_context、锁、iostream + */ + std::atomic server_exited{false}; + std::thread server_thread([&]() { + try { + _SERVE->run(); // run() 内部会调用 start() 启动 listener 和 worker 线程 + } catch (const std::exception& e) { + std::cerr << "[ca] Server thread exception: " << e.what() << std::endl; + } + server_exited = true; + }); + + // 等待服务器启动完成(简单延迟,确保started日志打印后再输出提示) + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + std::cout << "CA " << _VER << " started" << std::endl; + logs().info("ca", std::string("CA ") + _VER + " started"); + + /* 主线程:等待关闭信号,带超时轮询 */ + while (!_stop_requested && !server_exited) { + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + } + + /* 收到信号或服务器异常退出:安全执行关闭流程 */ + if (!server_exited && _SERVE) { + std::cerr << "[ca] 正在停止服务器..." << std::endl; + + // 给服务器 5 秒时间优雅关闭,防止挂死 + std::thread stop_thread([&]() { + try { + _SERVE->stop(); + } catch (const std::exception& e) { + std::cerr << "[ca] Stop exception: " << e.what() << std::endl; + } + }); + + // 最多等待 8 秒,超时则 detach 让系统回收(极端情况下保证进程退出) + if (stop_thread.joinable()) { + auto future = std::async(std::launch::async, &std::thread::join, &stop_thread); + if (future.wait_for(std::chrono::seconds(8)) == std::future_status::timeout) { + std::cerr << "[ca] 警告:服务器停止超时,强制退出" << std::endl; + stop_thread.detach(); + std::exit(1); // 极端情况:8秒还没停完,强制退出(日志可能丢失但进程不挂死) + } + } + } + + if (server_thread.joinable()) { + server_thread.join(); + } + + /* 收到停止信号后的收尾:记录并 flush 剩余日志(最多等待3秒) */ + std::cerr << "[ca] 正在刷新日志..." << std::endl; + logs().info("ca", "CA shutdown"); + { + std::thread log_flush_thread([]() { + logs().shutdown(); + }); + auto future = std::async(std::launch::async, &std::thread::join, &log_flush_thread); + if (future.wait_for(std::chrono::seconds(3)) == std::future_status::timeout) { + std::cerr << "[ca] 警告:日志刷新超时,退出" << std::endl; + log_flush_thread.detach(); + } + /* 非超时分支不得再 join:async 已完成 join,线程已不可 join, + * 重复 join 抛 std::system_error 导致 terminate(审计 A1) */ + } + + std::cerr << "[ca] 已退出" << std::endl; + _SERVE.reset(); + _KEYGEN.reset(); +} + +//void db_init() { +// +// std::string sql = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='Users'"; +// +// auto flag = false; +// { +// auto stmt = _SQLDB->stmt(sql); +// if (stmt.step()) { +// auto num = stmt.read_int(0); +// if (num == 0) { +// flag = true; +// } +// } +// } +// +// if (flag) { +// std::cout << "Start database init..." << std::endl; +// std::string sql_crtt = +// "CREATE TABLE Users (\n" +// "id INTEGER PRIMARY KEY AUTOINCREMENT,\n" +// "email TEXT UNIQUE NOT NULL,\n" +// "password_hash TEXT NOT NULL,\n" +// "create_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n" +// "last_login DATETIME,\n" +// "is_active BOOLEAN DEFAULT 1\n" +// ");\n\n" +// "CREATE INDEX idx_users_email ON users(email);\n"; +// +// _SQLDB->exec(sql_crtt); +// +// // No hardcoded admin - first registered user becomes admin +// std::cout << "done (no default admin - register manually)" << std::endl; +// } +// +// else { +// std::cout << "The database already init" << std::endl; +// } +//} + + +/* WebSocket 代理握手鉴权:从 HTTP Upgrade 请求中提取 Token 并验证 is_admin。 + * 返回 true 表示鉴权通过,out_target 填入后端 ws:// URL;返回 false 拒绝连接。 */ +static bool verifyWsAdminToken(const http::request& req, + const std::string& target_url, + std::string& out_target) { + out_target.clear(); + try { + /* 提取 Token(Authorization 优先,回退 Cookie) */ + std::string cookie_token; + std::string raw_token = extract_token_from_request(req, cookie_token); + if (raw_token.empty()) return false; + + /* 统一验签(RSA + 过期 + jti黑名单 + 版本号) */ + TokenInfo ti = verify_token(raw_token); + if (!ti.valid) return false; + + // 必须是管理员 + if (!ti.is_admin) { + logs().warning("ca", "WS proxy rejected: non-admin user", + std::nullopt, std::string(req.target())); + return false; + } + + out_target = target_url; + return true; + } + catch (const std::exception& e) { + std::cerr << "[WS proxy auth error] " << e.what() << std::endl; + return false; + } +} + +/* 注册http路由 */ +void registerRoutes(HttpServer &server) { + + /* const http::request& req, const std::unordered_map& params */ + server.post("/login", [](const auto& req, const auto& params) -> http::response { + return wrapperHttp(req, params, + [](const auto& req, const auto& params) {return handleLogin(req, params);}); + }); + + server.post("/register", [](const auto& req, const auto& params) -> http::response { + return wrapperHttp(req, params, + [](const auto& req, const auto& params) { return handleRegister(req, params); }); + }); + + /* 发送注册邮箱验证码(须在 post("*") 通配符之前注册) */ + server.post("/register/send_code", [](const auto& req, const auto& params) -> http::response { + return wrapperHttp(req, params, + [](const auto& req, const auto& params) { return handleSendEmailCode(req, params); }); + }); + + /* 发送重置密码邮箱验证码(须在 post("*") 通配符之前注册) */ + server.post("/reset/send_code", [](const auto& req, const auto& params) -> http::response { + return wrapperHttp(req, params, + [](const auto& req, const auto& params) { return handleResetSendCode(req, params); }); + }); + + /* 重置密码(须在 post("*") 通配符之前注册) */ + server.post("/reset_password", [](const auto& req, const auto& params) -> http::response { + return wrapperHttp(req, params, + [](const auto& req, const auto& params) { return handleResetPassword(req, params); }); + }); + + server.post("/logout", [](const auto& req, const auto& params) -> http::response { + return wrapperHttp(req, params, + [](const auto& req, const auto& params) { return handleLogout(req, params); }); + }); + + server.post("/refresh_token", [](const auto& req, const auto& params) -> http::response { + return wrapperHttp(req, params, + [](const auto& req, const auto& params) { return handleRefreshToken(req, params); }); + }); + + server.post("/whoami", [](const auto& req, const auto& params) -> http::response { + return wrapperHttp(req, params, + [](const auto& req, const auto& params) { return handleWhoami(req, params); }); + }); + + /* 内部服务间管理员验签视图(供 core 等调用,须在 post("*") 通配符之前注册) */ + server.post("/admin/verify", [](const auto& req, const auto& params) -> http::response { + return wrapperHttp(req, params, + [](const auto& req, const auto& params) { return handleAdminVerify(req, params); }); + }); + + /* 代理 captcha 图形验证码(须在 get("*") 通配符之前注册) */ + server.get("/captcha/image", [](const auto& req, const auto& params) -> http::response { + return wrapperHttp(req, params, + [](const auto& req, const auto& params) { return handleCaptchaImage(req, params); }); + }); + + server.get("*", [](const auto& req, const auto& params) -> http::response { + return wrapperHttp(req, params, + [](const auto& req, const auto& params) { return handleForward(req, params, "get"); }); + }); + + server.post("*", [](const auto& req, const auto& params) { + return wrapperHttp(req, params, + [](const auto& req, const auto& params) { return handleForward(req, params, "post"); }); + }); + + /* WebSocket 反向代理(仅管理员): + * ws_proxy 回调在 HTTP Upgrade 握手阶段触发, + * 返回非空 ws:// URL 表示鉴权通过并桥接到该后端;返回空串则拒绝连接。 */ + for (const auto& [path, url] : _WS_PROXY_ROUTES) { + server.ws_proxy(path, [url](const http::request& upgradeReq) -> std::string { + std::string target; + if (verifyWsAdminToken(upgradeReq, url, target)) { + return target; + } + return ""; + }); + std::cout << " WS-PROXY " << path << " -> " << url << " (admin only)" << std::endl; + } + +} + +/* Http视图包装器 + * + * 该函数收集视图函数抛出的异常 将信息发送给浏览器。这样视图函数有问题就可以直接抛出异常。 */ +http::response wrapperHttp( + const http::request& req, + const std::unordered_map& params, + HttpRequestHandler _main) + +try { return _main(req, params); } + + +/* 主动抛出的异常 */ +catch (const HttpException& e) { + http::response res; + res.version(req.version()); + // 详细错误信息(e.what() 含源码路径/行号/C++ 模板类型)暂时以注释方式保留(同 handleLogin) + std::cerr << "[wrapperHttp HttpException] " << e.what() << std::endl; + // 推送集中式日志:5xx 服务端错误记 ERROR,其余(400/401/403/429 等业务错误)记 WARNING + if (e.status() >= 500) + logs().error("ca", e.what(), std::nullopt, + json::serialize(json::value{{"url", e.url()}, {"status", e.status()}})); + else + logs().warning("ca", e.what(), std::nullopt, + json::serialize(json::value{{"url", e.url()}, {"status", e.status()}})); + json::value jv_r = { + {"success", false}, + /* 客户端只给纯消息(e.message());e.what() 的 [Http][Error NNNN] 内部前缀 + * 仅保留在服务端 stderr/集中日志,不外泄(审计 A8) */ + {"message", e.message()} + }; + res.set(http::field::content_type, "application/json"); + res.result(e.status()); + res.body() = json::serialize(jv_r); + res.prepare_payload(); + return res; +} + +/* 预料之外的异常 */ +catch (const std::exception& e) { + + http::response res; + res.version(req.version()); + const auto& ti = typeid(e); + // 详细错误信息(ti.name() 含 mangled 类型名、e.what() 含内部细节)暂时以注释方式保留 + // 原因:此处为 wrapperHttp 外层 catch,会捕获 handler 内未处理的 std::exception + // 透传到客户端会泄露 C++ 类型信息与内部路径(P0-3 遗漏点,已补全) + // 排障:完整信息仍输出到服务端 stderr + std::cerr << "[wrapperHttp std::exception] <" << ti.name() << "> " << e.what() << std::endl; + logs().error("ca", std::string("<") + ti.name() + "> " + e.what()); + json::value jv_r = { + {"success", false}, + /* {"message", std::string("<") + ti.name() + "> " + e.what()}, */ // 泄露源码信息,已禁用 + {"message", "Internal Server Error"} + }; + res.set(http::field::content_type, "application/json"); + res.result(http::status::internal_server_error); + res.body() = json::serialize(jv_r); + res.prepare_payload(); + return res; +} + +/* 处理登陆 + * + * /login */ +http::response handleLogin( + const http::request& req, + const std::unordered_map& params) +{ + http::response res; + res.version(req.version()); + + // IP rate limit check + std::string client_ip = ""; + auto it_host = req.base().find("X-Forwarded-For"); + if (it_host != req.base().end()) + client_ip = std::string(it_host->value()); + else + client_ip = "direct"; + + if (!_LOGIN_RATE.check_and_record(client_ip, 5, 300)) + THROW_EX_CUSTOM(HttpException, 2001, "Too many login attempts. Please try again later.", 429, "/login"); + + // parse json req body + json::value jv; + try { jv = json::parse(req.body()); } + catch (const boost::wrapexcept& e) { + THROW_EX_CUSTOM(HttpException, 2001, "Invalid_json_Argument", 400, "/login"); + } + + if (!jv.is_object()) THROW_EX_CUSTOM(HttpException, 2002, "Invalid_json_object_Argument", 400, "/login"); + auto& obj = jv.as_object(); + /* 缺字段时 obj.at() 抛 std::out_of_range,须就地捕获返回 400, + * 否则被 wrapper 兜底成 500(审计 A3) */ + std::string username, password; + try { + username = std::string(obj.at("username").as_string()); + password = std::string(obj.at("password").as_string()); + } catch (const std::exception&) { + THROW_EX_CUSTOM(HttpException, 2002, "Bad_Request", 400, "/login"); + } + + // CAPTCHA verification 验证码(对接 captcha pymsg 服务,图形码 Token 一次性消费) + auto captcha_it = obj.find("captcha_token"); + auto captcha_ans_it = obj.find("captcha_answer"); + if (captcha_it == obj.end() || captcha_ans_it == obj.end()) + THROW_EX_CUSTOM(HttpException, 2002, "Bad_Request", 400, "/login"); + std::string ct = std::string(captcha_it->value().as_string()); + std::string ca = std::string(captcha_ans_it->value().as_string()); + if (!verify_captcha_remote(ct, ca)) THROW_EX_CUSTOM(HttpException, 2201, "Invalid CAPTCHA", 400, "/login"); + + std::string stored_hash = ""; + { + // Fetch stored hash - support both PBKDF2 and legacy SHA256 + //std::string sql = "SELECT password_hash FROM Users WHERE email=?;"; + //auto stmt = _SQLDB->stmt(sql); + //stmt.bind_text(1, username); + //if (stmt.step()) { + // stored_hash = std::string(reinterpret_cast(stmt.read_text(0))); + //} + + // 新版调用db模块(通过 MsgClient pymsg TCP 协议) + // 【保留】旧版 HTTP 调用方式: + // auto jv_db_r = DbC::call_http(jv_select); + json::value jv_select = { + {"action", "select"}, + {"table", "Users"}, + {"where", json::object{ + {"username", username}, + }} + }; + 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 failed, please try again later", 500, "/login"); + auto obj_rd = obj_r.at("data").as_array(); + /* 用户不存在时 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(); + } + } + + + + bool auth_ok = false; + if (!stored_hash.empty()) { + if (stored_hash.substr(0, 7) == "pbkdf2:") + // New PBKDF2 hash + auth_ok = PBKDF2Hasher::verify_password(password, stored_hash); + //else { + // Legacy SHA256 hash - verify then auto-migrate + //auto legacy_hash = SHA256::sha256(password); + //if (legacy_hash == stored_hash) { + // auth_ok = true; + // Auto-migrate to PBKDF2 + // auto new_hash = PBKDF2Hasher::hash_password(password); + // std::string sql_migrate = "UPDATE Users SET password_hash=? WHERE email=?;"; + // auto stmt_mig = _SQLDB->stmt(sql_migrate); + // stmt_mig.bind_text(1, new_hash); + // stmt_mig.bind_text(2, username); + // stmt_mig.step(); + // 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"); + + // Update last_login + //std::string sql_update = "UPDATE Users SET last_login=datetime('now','localtime') WHERE email=?;"; + //auto stmt_upd = _SQLDB->stmt(sql_update); + //stmt_upd.bind_text(1, username); + //stmt_upd.step(); + // + // 新版调用db模块:values 中的 "$now" 哨兵由 db 日期适配层替换为服务端当前 UTC 时间 + //(此前因 db 无法处理日期列而注释,dbv2 增加 _coerce_value 后可用)。 + json::value jv_upd = { + {"action", "update"}, + {"table", "Users"}, + {"values", json::object{ + {"last_login", "$now"}, + }}, + {"where", json::object{ + {"username", username}, + }} + }; + auto jv_upd_r = DbC::call_msg(jv_upd); + if (!jv_upd_r.as_object()["success"].as_bool()) + THROW_EX_CUSTOM(HttpException, 2102, "Login failed, please try again later", 500, "/login"); + + + /* 生成令牌 */ + auto _PRIV = MyRSA::Private_Key::from_pem(_KEYGEN->get_private_key_pem()); + + /* 管理员判定:账号命中 [Admin] users 白名单即在 payload 中签发 is_admin, + * 下游服务(dnfs 等)直接读取该字段豁免所有权校验,无需各自维护名单 */ + bool is_admin = std::find(_ADMIN_USERS.begin(), _ADMIN_USERS.end(), username) != _ADMIN_USERS.end(); + + /* Token 吊销支持:签发新 jti + 当前版本号 */ + std::string jti = generate_jti(); + int user_ver = get_user_ver(username); + + json::value jv_t = { + {"user", username}, + {"jti", jti}, + {"ver", user_ver}, + {"iat", LocalTime::getCurrent()}, + {"created_at", LocalTime::getCurrent()}, + {"keep_time", _TOKEN_KEEP_TIME}, + {"is_admin", is_admin}, + {"refresh_count", 0}, + {"max_refresh", _TOKEN_MAX_REFRESH} + }; + std::string data = json::serialize(jv_t); + std::string auth = Base64::base64_encode(data); + std::string sign = _PRIV.Sign(data); + std::string token = auth + "_" + sign; + + + /* 生成返回json TODO: 专门写个类做这事 */ + json::value jv_rr = { + {"success", true}, + {"data", json::object{{ + "token", token}} + } + }; + res.set("Connection", "close"); + + /* 签发 HttpOnly Cookie(防 XSS 窃取 Token;JS 不再经手 Token) */ + { + std::string ck = "q4_token=" + token + + "; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=" + + std::to_string(_TOKEN_KEEP_TIME); + res.set("Set-Cookie", ck); + } + + logs().info("ca", "登录成功: " + username, std::nullopt, + json::serialize(json::value{{"ip", client_ip}})); + + res.result(http::status::ok); + res.body() = json::serialize(jv_rr); + res.prepare_payload(); + return res; +} + +/* 退出登录 /logout:吊销当前 Token(加入 jti 黑名单)并清除 q4_token Cookie */ +http::response handleLogout( + const http::request& req, + const std::unordered_map& params) +{ + http::response res; + res.version(req.version()); + + /* 提取并吊销当前 Token:将 jti 加入黑名单(单设备退出) */ + std::string cookie_token; + std::string raw_token = extract_token_from_request(req, cookie_token); + if (!raw_token.empty()) { + TokenInfo ti = verify_token(raw_token); + if (ti.valid && !ti.jti.empty()) { + revoke_jti(ti.jti, ti.keep_time); + logs().info("ca", "Token 已吊销: " + ti.username, std::nullopt, + json::serialize(json::value{{"jti_prefix", ti.jti.substr(0, 8)}})); + } + } + + res.set("Set-Cookie", + "q4_token=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"); + res.set("Connection", "close"); + res.result(http::status::ok); + res.body() = R"({"success": true, "message": "logged out"})"; + res.prepare_payload(); + return res; +} + +/* 当前用户身份查询 /whoami + * + * 验签 Token 后返回 user / is_admin / Token 生命周期信息(iat / created_at / keep_time)。 + * Token 来源:Authorization: Bearer 优先,回退 Cookie q4_token。 + * 无 Token 或验签失败返回 {"login": false},与 dnfs 旧接口契约一致。 */ +http::response handleWhoami( + const http::request& req, + const std::unordered_map& params) +{ + http::response res; + res.version(req.version()); + + res.set(http::field::content_type, "application/json"); + res.set("Connection", "close"); + + /* 提取 Token(Authorization 优先,回退 Cookie) */ + std::string cookie_token; + std::string raw_token = extract_token_from_request(req, cookie_token); + + if (raw_token.empty()) { + res.result(http::status::ok); + res.body() = R"({"login": false})"; + res.prepare_payload(); + return res; + } + + /* 统一验签(RSA + 过期 + jti黑名单 + 版本号) */ + TokenInfo ti = verify_token(raw_token); + if (!ti.valid) { + res.result(http::status::ok); + res.body() = R"({"login": false})"; + res.prepare_payload(); + return res; + } + + json::value jv_r = { + {"login", true}, + {"user", ti.username}, + {"is_admin", ti.is_admin} + }; + + /* 附加 Token 生命周期信息,供前端 Token 管理器估算过期时间 */ + if (!ti.iat.empty()) jv_r.as_object()["iat"] = ti.iat; + if (!ti.created_at.empty()) jv_r.as_object()["created_at"] = ti.created_at; + if (ti.keep_time > 0) jv_r.as_object()["keep_time"] = ti.keep_time; + + res.result(http::status::ok); + res.body() = json::serialize(jv_r); + res.prepare_payload(); + return res; +} + +/* 内部服务间管理员验签视图:POST /admin/verify + * + * 供 core 等内部服务调用,验证请求携带的 Token 是否为有效且未过期的管理员。 + * Token 来源:Authorization: Bearer 优先,回退 Cookie q4_token。 + * ca 是 Token 签发与验证的唯一权威,core 通过本接口完成鉴权,避免密钥共享。 + * + * 响应:{ success, is_admin, user } + * - success=true, is_admin=true → 有效管理员(放行) + * - success=true, is_admin=false → 非管理员(拒绝) + * - success=false → 无 Token / 验签失败 / 过期(拒绝) + * 验签失败一律返回 HTTP 401(fail-closed),供内部服务快速判拒绝。 */ +http::response handleAdminVerify( + const http::request& req, + const std::unordered_map& params) +{ + http::response res; + res.version(req.version()); + res.set(http::field::content_type, "application/json"); + res.set("Connection", "close"); + + /* 提取 Token(Authorization 优先,回退 Cookie) */ + std::string cookie_token; + std::string raw_token = extract_token_from_request(req, cookie_token); + + if (raw_token.empty()) { + res.result(http::status::unauthorized); + res.body() = R"({"success": false, "is_admin": false})"; + res.prepare_payload(); + return res; + } + + /* 统一验签(RSA + 过期 + jti黑名单 + 版本号) */ + TokenInfo ti = verify_token(raw_token); + if (!ti.valid) { + res.result(http::status::unauthorized); + res.body() = R"({"success": false, "is_admin": false})"; + res.prepare_payload(); + return res; + } + + json::value jv_r = { + {"success", true}, + {"is_admin", ti.is_admin}, + {"user", ti.username} + }; + res.result(http::status::ok); + res.body() = json::serialize(jv_r); + res.prepare_payload(); + return res; +} + +/* 刷新 Token:POST /refresh_token + * + * 接受当前有效 Token(即使已过期但在宽限期内),签发新 Token。 + * 仅更新 created_at 与 refresh_count;iat 保持不变以限制整条链的总生命周期。 + * + * 宽限期:iat + keep_time * refresh_grace_multiplier + * 例:keep_time=3600, multiplier=2 → 首次签发后 2 小时内可刷新 + * 刷新上限:refresh_count < max_refresh + * 限流:5 次 / 5 分钟 / IP + * Token 来源:Authorization: Bearer 优先,回退 Cookie q4_token + * + * 响应:{ success, data:{ token, refresh_count, refreshes_remaining } } + */ +http::response handleRefreshToken( + const http::request& req, + const std::unordered_map& params) +{ + http::response res; + res.version(req.version()); + + /* IP 速率限制:5 次 / 300 秒 */ + 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 (!_REFRESH_TOKEN_RATE.check_and_record(client_ip, 5, 300)) + THROW_EX_CUSTOM(HttpException, 4001, "Too many refresh attempts. Please try again later.", + 429, "/refresh_token"); + + /* 获取并验证 Token(含 jti 黑名单 + 版本号检查) */ + std::string cookie_token; + std::string raw_token = extract_token_from_request(req, cookie_token); + if (raw_token.empty()) + THROW_EX_CUSTOM(HttpException, 4002, "No token provided", 400, "/refresh_token"); + + /* 正常过期(非吊销/版本错误)才允许宽限期刷新: + * - jti 在黑名单(logout/refresh后旧token)→ 401 拒绝 + * - 版本号落后(改密后旧token)→ 401 拒绝 + * 注意:verify_token 不区分"过期"和"吊销",统一返回 valid=false。 + * 我们需要区分"正常过期可刷新"和"被吊销不可刷新"。 + * 策略:先做 RSA+黑名单+版本号检查(通过则可刷新),过期检查在后面宽限期逻辑中处理。 */ + + // 重新做一次不含过期检查的验签(判断是否被吊销) + bool token_revoked = false; + std::string old_jti; + std::string username; + std::string iat_str; + int keep_time = _TOKEN_KEEP_TIME; + int refresh_count = 0; + int max_refresh = _TOKEN_MAX_REFRESH; + bool is_admin = false; + int user_ver = 1; + { + if (raw_token.find("_") == std::string::npos) + THROW_EX_CUSTOM(HttpException, 4002, "Invalid token format", 400, "/refresh_token"); + size_t idx2 = raw_token.find("_"); + std::string data = raw_token.substr(0, idx2); + std::string sign = raw_token.substr(idx2 + 1); + std::string auth; + try { auth = Base64::base64_decode_to_string(data); } + catch (const std::exception&) { + THROW_EX_CUSTOM(HttpException, 4002, "Invalid token format", 400, "/refresh_token"); + } + auto _PUBL = MyRSA::Public_Key::from_pem(_KEYGEN->get_public_key_pem()); + if (!_PUBL.Verify(auth, sign)) + THROW_EX_CUSTOM(HttpException, 4003, "Invalid token signature", 401, "/refresh_token"); + json::value jv_t; + try { jv_t = json::parse(auth); } + catch (const std::exception&) { + THROW_EX_CUSTOM(HttpException, 4004, "Bad token payload", 400, "/refresh_token"); + } + if (!jv_t.is_object()) + THROW_EX_CUSTOM(HttpException, 4004, "Bad token payload", 400, "/refresh_token"); + json::object& obj = jv_t.as_object(); + username = json::value_to(obj.at("user")); + iat_str = json::value_to(obj.at("iat")); + keep_time = json::value_to(obj.at("keep_time")); + refresh_count = json::value_to(obj.at("refresh_count")); + max_refresh = json::value_to(obj.at("max_refresh")); + auto it_admin = obj.find("is_admin"); + if (it_admin != obj.end() && it_admin->value().is_bool()) + is_admin = it_admin->value().as_bool(); + // 检查 jti 黑名单 + auto it_jti = obj.find("jti"); + if (it_jti != obj.end() && it_jti->value().is_string()) { + old_jti = json::value_to(it_jti->value()); + std::lock_guard lock(_TOKEN_REVOKE_MTX); + if (_JTI_DENYLIST.count(old_jti)) + token_revoked = true; + } + // 检查版本号 + auto it_ver = obj.find("ver"); + int token_ver = 1; + if (it_ver != obj.end() && it_ver->value().is_int64()) + token_ver = static_cast(it_ver->value().as_int64()); + { + std::lock_guard lock(_TOKEN_REVOKE_MTX); + auto it_uv = _USER_VER_MAP.find(username); + user_ver = (it_uv != _USER_VER_MAP.end()) ? it_uv->second : 1; + if (token_ver < user_ver) + token_revoked = true; + } + } + + if (token_revoked) + THROW_EX_CUSTOM(HttpException, 4003, "Token has been revoked. Please re-login.", + 401, "/refresh_token"); + + /* 刷新次数检查 */ + if (refresh_count >= max_refresh) + THROW_EX_CUSTOM(HttpException, 4005, "Refresh limit exceeded. Please re-login.", + 400, "/refresh_token"); + + /* 宽限期检查:iat + keep_time * grace_multiplier > now */ + auto iat_tp = LocalTime::parse(iat_str); + if (!iat_tp) + THROW_EX_CUSTOM(HttpException, 4004, "Bad token iat", 400, "/refresh_token"); + + auto now = std::chrono::system_clock::now(); + auto grace_expire = *iat_tp + std::chrono::seconds(keep_time * _TOKEN_REFRESH_GRACE_MULTIPLIER); + if (now > grace_expire) + THROW_EX_CUSTOM(HttpException, 4006, "Token expired beyond refresh window. Please re-login.", + 401, "/refresh_token"); + + /* 吊销旧 Token 的 jti(防止旧 refresh_token 被重放) */ + if (!old_jti.empty()) { + revoke_jti(old_jti, keep_time * _TOKEN_REFRESH_GRACE_MULTIPLIER); + } + + /* 签发新 Token:iat 不变,created_at 更新,refresh_count++,新 jti,当前版本号 */ + std::string new_jti = generate_jti(); + auto _PRIV = MyRSA::Private_Key::from_pem(_KEYGEN->get_private_key_pem()); + json::value jv_new = { + {"user", username}, + {"jti", new_jti}, + {"ver", user_ver}, + {"iat", iat_str}, + {"created_at", LocalTime::getCurrent()}, + {"keep_time", keep_time}, + {"is_admin", is_admin}, + {"refresh_count", refresh_count + 1}, + {"max_refresh", max_refresh} + }; + std::string new_data = json::serialize(jv_new); + std::string new_auth = Base64::base64_encode(new_data); + std::string new_sign = _PRIV.Sign(new_data); + std::string new_token = new_auth + "_" + new_sign; + + /* Set-Cookie */ + { + std::string ck = "q4_token=" + new_token + + "; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=" + + std::to_string(keep_time); + res.set("Set-Cookie", ck); + } + + logs().info("ca", "Token refreshed", std::nullopt, + json::serialize(json::value{ + {"user", username}, + {"ip", client_ip}, + {"refresh_count", refresh_count + 1}, + {"refreshes_remaining", max_refresh - (refresh_count + 1)} + })); + + json::value jv_rr = { + {"success", true}, + {"data", json::object{ + {"token", new_token}, + {"refresh_count", refresh_count + 1}, + {"refreshes_remaining", max_refresh - (refresh_count + 1)} + }} + }; + res.set("Connection", "close"); + res.result(http::status::ok); + res.set(http::field::content_type, "application/json"); + res.body() = json::serialize(jv_rr); + res.prepare_payload(); + return res; +} + +/* 代理 captcha 图形验证码:GET /captcha/image + * + * captcha 服务为 pymsg/TCP(:8009),浏览器无法直接访问, + * 由本路由做 HTTP → pymsg 转换:调 image 路由拿到 base64 PNG 与 token, + * 解码后以 image/png + X-Captcha-Token 响应头返回(与旧直出接口契约一致)。 + */ +http::response handleCaptchaImage( + const http::request& req, + const std::unordered_map& params) +{ + http::response res; + res.version(req.version()); + + /* IP 级速率限制(P1-1 修复):单 IP 每分钟最多 10 次, + 防止单一攻击者打满全局限流导致全站验证码不可用 */ + 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 (!_CAPTCHA_IMAGE_RATE.check_and_record(client_ip, 10, 60)) + THROW_EX_CUSTOM(HttpException, 2403, + "Too many captcha requests. Please try again later.", 429, "/captcha/image"); + + /* 调 captcha 服务(pymsg/TCP),连接失败/超时视为 502 */ + pmc::net::MsgClient client(_CAPTCHA_HOST, _CAPTCHA_PORT, "ca"); + std::string resp; + try { + resp = client.call(R"({"_route":"image"})", _TIMEOUT_CAPTCHA); + } catch (const std::exception& e) { + THROW_EX_CUSTOM(HttpException, 2400, "Captcha service unavailable", 502, "/captcha/image"); + } + + /* 解析响应(格式错误同样视为 502) */ + std::string token; + std::string png; + try { + auto jv = json::parse(resp); + auto& obj = jv.as_object(); + if (!obj.at("success").as_bool()) { + // P3-5: 依据 error 信息映射准确 HTTP 状态码 + std::string err_msg = json::value_to(obj.at("error")); + if (err_msg.find("rate_limit") != std::string::npos) + THROW_EX_CUSTOM(HttpException, 2401, "Too many captcha requests", 429, "/captcha/image"); + if (err_msg.find("store") != std::string::npos || err_msg.find("full") != std::string::npos) + THROW_EX_CUSTOM(HttpException, 2401, "Captcha service overloaded", 503, "/captcha/image"); + THROW_EX_CUSTOM(HttpException, 2401, "Captcha service error", 502, "/captcha/image"); + } + auto& data = obj.at("data").as_object(); + token = json::value_to(data.at("token")); + auto image_b64 = json::value_to(data.at("image")); + png = Base64::base64_decode_to_string(image_b64); + } catch (const HttpException&) { + throw; + } catch (const std::exception& e) { + THROW_EX_CUSTOM(HttpException, 2402, "Bad captcha response", 502, "/captcha/image"); + } + + res.set(http::field::content_type, "image/png"); + res.set("X-Captcha-Token", token); + res.set("Cache-Control", "no-store"); /* 验证码图片禁止缓存 */ + res.set("Connection", "close"); + res.result(http::status::ok); + res.body() = std::move(png); + res.prepare_payload(); + return res; +} + +/* 处理注册 /register */ +http::response handleRegister( + const http::request& req, + const std::unordered_map& params) +{ + http::response 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 (!_REGISTER_RATE.check_and_record(client_ip, 3, 600)) + THROW_EX_CUSTOM(HttpException, 3001, "Too many registration attempts. Please try again later.", + 429, "/register"); + + // parse the req body + json::value jv; + try { jv = json::parse(req.body()); } + catch (const boost::wrapexcept& e) { + THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/register"); + } + + if (!jv.is_object()) + THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/register"); + + auto& obj = jv.as_object(); + /* 缺字段时 obj.at() 抛 std::out_of_range,须就地捕获返回 400(审计 A3) */ + std::string username, password; + try { + username = std::string(obj.at("username").as_string()); + password = std::string(obj.at("password").as_string()); + } catch (const std::exception&) { + THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 400, "/register"); + } + + // Strong password check + auto pw_result = PasswordValidator::validate(password); + if (!pw_result.valid) + THROW_EX_CUSTOM(HttpException, 3003, pw_result.message, 401, "/register"); + + // Email format check + std::regex email_regex(R"(^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$)"); + if (!std::regex_match(username, email_regex)) + THROW_EX_CUSTOM(HttpException, 3004, "Invalid email format", 400, "/register"); + + // CAPTCHA verification(可选;提交则强制校验,走 captcha 服务远端渠道,审计 A6) + auto captcha_it = obj.find("captcha_token"); + auto captcha_ans_it = obj.find("captcha_answer"); + if (captcha_it != obj.end() && captcha_ans_it != obj.end()) { + std::string ct = std::string(captcha_it->value().as_string()); + std::string ca = std::string(captcha_ans_it->value().as_string()); + if (!verify_captcha_remote(ct, ca)) + 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); + + //std::string sql = "INSERT INTO Users (email, password_hash, last_login) VALUES (?, ?, datetime('now', 'localtime'));"; + //auto stmt = _SQLDB->stmt(sql); + //stmt.bind_text(1, username); + //stmt.bind_text(2, password_hash); + //stmt.step(); + // + // 新版调用db模块(通过 MsgClient pymsg TCP 协议) + // 【保留】旧版 HTTP 调用方式: + // auto jv_result = DbC::call_http(jv_insert); + json::value jv_insert = { + {"action", "insert"}, + {"table", "Users"}, + {"values", json::object{ + {"username", username}, + {"password_hash", password_hash} + }} + }; + auto jv_result = DbC::call_msg(jv_insert); + auto obj_r = jv_result.as_object(); + auto success = obj_r["success"].as_bool(); + if (!success) THROW_EX_CUSTOM(HttpException, 2202, "Registration failed, please try again later", 500, "/register"); + auto jv_r = json::value{{"success", success}}; + + logs().info("ca", "注册成功: " + username, std::nullopt, + json::serialize(json::value{{"ip", client_ip}})); + + res.result(http::status::ok); + res.body() = json::serialize(jv_r); + res.prepare_payload(); + return res; +} + +/* 处理发送注册邮箱验证码 /register/send_code + * + * 经 pymsg 调 smtp 服务 send_email 路由,向目标邮箱发送 6 位验证码。 + * 限流 5 次/10 分钟/IP;smtp 不可用返回 502(fail-closed)。 */ +http::response handleSendEmailCode( + const http::request& req, + const std::unordered_map& params) +{ + http::response 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.", + 429, "/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"); + + // CAPTCHA 强制校验(防邮件轰炸:挡住自动化脚本,见审计报告 P0-1) + // captcha_token / captcha_answer 必填,经 captcha:8009 pymsg 校验,Token 一次性消费。 + // fail-closed:captcha 服务不可用或响应异常一律视为校验失败。 + auto captcha_token_it = obj.find("captcha_token"); + auto captcha_answer_it = obj.find("captcha_answer"); + if (captcha_token_it == obj.end() || captcha_answer_it == obj.end()) + THROW_EX_CUSTOM(HttpException, 3007, "CAPTCHA required", 400, "/register/send_code"); + if (!verify_captcha_remote(std::string(captcha_token_it->value().as_string()), + std::string(captcha_answer_it->value().as_string()))) + THROW_EX_CUSTOM(HttpException, 2201, "Invalid CAPTCHA", 400, "/register/send_code"); + + // 邮箱级双窗口限流(CAPTCHA 通过后才计入配额,避免无效请求消耗限流额度) + // 3 次/小时 + 10 次/天;IP 限流可被代理池绕过,邮箱维度是更可靠的限制。 + if (!_EMAIL_ADDR_RATE_HOUR.check_and_record(email, 3, 3600)) + THROW_EX_CUSTOM(HttpException, 3104, + "Too many code requests for this email. Please try again later.", + 429, "/register/send_code"); + if (!_EMAIL_ADDR_RATE_DAY.check_and_record(email, 10, 86400)) + THROW_EX_CUSTOM(HttpException, 3105, + "Daily email limit exceeded. Please try again tomorrow.", + 429, "/register/send_code"); + + // 调 smtp 服务(pymsg/TCP :8008)发送验证码 + bool sent = false; + int retry_after = 0; // smtp 同邮箱发送冷却的剩余秒数,0 表示非冷却拒绝 + std::string smtp_err; // 冷却拒绝时 smtp 返回的提示文案(仅冷却场景透传客户端) + try { + pmc::net::MsgClient client(_SMTP_HOST, _SMTP_PORT, "ca"); + json::value jv_req = { + {"_route", "send_email"}, + {"email", email} + }; + 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()) { + 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(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) { + // 同邮箱发送冷却:按限流语义返回 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("ca", "验证码邮件发送成功: " + email, std::nullopt, + json::serialize(json::value{{"ip", client_ip}})); + + 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; +} + +/* 发送重置密码邮箱验证码 /reset/send_code + * + * 与注册发码(/register/send_code)结构一致,区别在定制"重置密码"主题与文案: + * 经 pymsg 调 smtp 服务 send_email 路由(subject/content 字段覆盖默认注册文案)。 + * 限流 5 次/10 分钟/IP;CAPTCHA 强制校验;smtp 不可用返回 502(fail-closed)。 */ +http::response handleResetSendCode( + const http::request& req, + const std::unordered_map& params) +{ + http::response 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 (!_RESET_CODE_RATE.check_and_record(client_ip, 5, 600)) + THROW_EX_CUSTOM(HttpException, 3201, "Too many code requests. Please try again later.", + 429, "/reset/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, "/reset/send_code"); + } + + if (!jv.is_object()) + THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/reset/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, "/reset/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, "/reset/send_code"); + + // CAPTCHA 强制校验(防邮件轰炸,与 /register/send_code 一致) + // 经 captcha:8009 pymsg 校验,Token 一次性消费;fail-closed。 + auto captcha_token_it = obj.find("captcha_token"); + auto captcha_answer_it = obj.find("captcha_answer"); + if (captcha_token_it == obj.end() || captcha_answer_it == obj.end()) + THROW_EX_CUSTOM(HttpException, 3007, "CAPTCHA required", 400, "/reset/send_code"); + if (!verify_captcha_remote(std::string(captcha_token_it->value().as_string()), + std::string(captcha_answer_it->value().as_string()))) + THROW_EX_CUSTOM(HttpException, 2201, "Invalid CAPTCHA", 400, "/reset/send_code"); + + // 邮箱级双窗口限流(CAPTCHA 通过后才计入配额,避免无效请求消耗限流额度) + // 与注册发码共用同一配额,防止重置/注册双通道叠加绕过邮箱轰炸防护 + if (!_EMAIL_ADDR_RATE_HOUR.check_and_record(email, 3, 3600)) + THROW_EX_CUSTOM(HttpException, 3104, + "Too many code requests for this email. Please try again later.", + 429, "/reset/send_code"); + if (!_EMAIL_ADDR_RATE_DAY.check_and_record(email, 10, 86400)) + THROW_EX_CUSTOM(HttpException, 3105, + "Daily email limit exceeded. Please try again tomorrow.", + 429, "/reset/send_code"); + + // 调 smtp 服务(pymsg/TCP :8008)发送重置密码验证码(定制文案) + bool sent = false; + int retry_after = 0; // smtp 同邮箱发送冷却的剩余秒数,0 表示非冷却拒绝 + std::string smtp_err; // 冷却拒绝时 smtp 返回的提示文案(仅冷却场景透传客户端) + try { + pmc::net::MsgClient client(_SMTP_HOST, _SMTP_PORT, "ca"); + json::value jv_req = { + {"_route", "send_email"}, + {"email", email}, + {"subject", "QSONT.me - Password Reset"}, + {"content", "您正在重置 QSONT.me 账号密码,验证码为 {code},10 分钟内有效。如非本人操作,请忽略本邮件。"} + }; + 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()) { + 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(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, "/reset/send_code"); + } + + 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, "/reset/send_code"); + THROW_EX_CUSTOM(HttpException, 3103, "Failed to send verification code", 500, "/reset/send_code"); + } + + logs().info("ca", "重置密码验证码邮件发送成功: " + email, std::nullopt, + json::serialize(json::value{{"ip", client_ip}})); + + 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; +} + +/* 处理重置密码 /reset_password + * + * 与注册流程一致,最后一步由"创建用户"改为"更新密码": + * 校验顺序:密码强度 → 邮箱格式 → CAPTCHA(可选)→ 邮箱验证码(smtp verify_email,一次性) + * → 生成 PBKDF2 哈希 → UPDATE Users SET password_hash WHERE username + * 防枚举:用户不存在(update 匹配 0 行)时仍统一返回成功,不泄露邮箱是否已注册。 + * CAPTCHA 走 captcha:8009 远端校验(与前端 /captcha/image 的 token 匹配)。 + */ +http::response handleResetPassword( + const http::request& req, + const std::unordered_map& params) +{ + http::response 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 (!_RESET_PASSWORD_RATE.check_and_record(client_ip, 5, 600)) + THROW_EX_CUSTOM(HttpException, 3301, "Too many reset attempts. Please try again later.", + 429, "/reset_password"); + + // 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, "/reset_password"); + } + + if (!jv.is_object()) + THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/reset_password"); + + auto& obj = jv.as_object(); + /* 缺字段时 obj.at() 抛 std::out_of_range,须就地捕获返回 400(审计 A3) */ + std::string username, password; + try { + username = std::string(obj.at("username").as_string()); + password = std::string(obj.at("password").as_string()); + } catch (const std::exception&) { + THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 400, "/reset_password"); + } + + // Strong password check + auto pw_result = PasswordValidator::validate(password); + if (!pw_result.valid) + THROW_EX_CUSTOM(HttpException, 3003, pw_result.message, 401, "/reset_password"); + + // Email format check + std::regex email_regex(R"(^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$)"); + if (!std::regex_match(username, email_regex)) + THROW_EX_CUSTOM(HttpException, 3004, "Invalid email format", 400, "/reset_password"); + + // CAPTCHA verification(可选,与注册一致;走 captcha 服务远端校验) + auto captcha_it = obj.find("captcha_token"); + auto captcha_ans_it = obj.find("captcha_answer"); + if (captcha_it != obj.end() && captcha_ans_it != obj.end()) { + std::string ct = std::string(captcha_it->value().as_string()); + std::string ca = std::string(captcha_ans_it->value().as_string()); + if (!verify_captcha_remote(ct, ca)) + THROW_EX_CUSTOM(HttpException, 2201, "Invalid CAPTCHA", 400, "/reset_password"); + } + + // 邮箱验证码校验(smtp 服务,一次性消费;须放在所有本地校验通过之后、写库之前) + auto ecode_it = obj.find("email_code"); + if (ecode_it == obj.end()) + THROW_EX_CUSTOM(HttpException, 3005, "Email verification code required", 400, "/reset_password"); + 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, "/reset_password"); + + // generate the hash of password + auto password_hash = PBKDF2Hasher::hash_password(password); + + // 更新密码:UPDATE Users SET password_hash=? WHERE username=?(经 db 服务 pymsg execute) + json::value jv_update = { + {"action", "update"}, + {"table", "Users"}, + {"values", json::object{ + {"password_hash", password_hash} + }}, + {"where", json::object{ + {"username", username} + }} + }; + auto jv_result = DbC::call_msg(jv_update); + auto obj_r = jv_result.as_object(); + if (!obj_r["success"].as_bool()) + THROW_EX_CUSTOM(HttpException, 2202, "Password reset failed, please try again later", 500, "/reset_password"); + + /* 重置密码成功:吊销该用户所有已签发的 Token(版本号递增,全设备失效,需重新登录) */ + revoke_all_user_tokens(username); + + // 防枚举:用户不存在(rowcount=0)时仍统一返回成功,不泄露邮箱是否已注册 + logs().info("ca", "重置密码成功: " + username, std::nullopt, + json::serialize(json::value{{"ip", client_ip}})); + + auto jv_r = json::value{{"success", true}}; + res.result(http::status::ok); + res.body() = json::serialize(jv_r); + res.prepare_payload(); + return res; +} + +/* 前置转发层(waf → ca → gateway 拓扑) + * + * 语义: + * - 无 Target-Url 头 = 公开请求(前端静态资源等):剥离伪造身份头后直转 gateway + * - 有 Target-Url 头 = 受保护 API 请求:本地验签(RSA 密钥同进程,零网络跳), + * 通过后注入可信身份头 X-Auth-User / X-Auth-Admin / Authorization 再转发 + * - X-Auth-* 采用 strip-then-set 契约:is_special_header 先剥离客户端伪造值 + * - 上游 gateway 不可达统一返回 502(区别于 CA 自身故障的 500) + */ +http::response handleForward( + const http::request& req, + const std::unordered_map& params, + const char *method) +{ + http::response res; + res.version(req.version()); + + /* 显式携带 Target-Url 头(任意大小写)的视为受保护 API 请求 */ + bool is_protected = + req.base().find("Target-Url") != req.base().end() || + req.base().find("target-url") != req.base().end(); + + // 路径规范化校验(P1-1):防止路径遍历、协议注入、空字节等 + // 校验在 url 拼接前完成,避免恶意 target 污染下游请求 + std::string target_path = std::string(req.target()); + if (!is_safe_target(target_path)) + THROW_EX_CUSTOM(HttpException, 2300, "Invalid request path", 400, "*"); + + /* 固定上游:前置拓扑下所有流量统一转发到 gateway(Target-Url 头原样透传, + * 由 gateway 按自身白名单做内部路由) */ + std::string url = _UPSTREAM_GATEWAY; + /* 避免 target_path 以 "/" 开头时产生双斜杠(如 http://host:port//list), + * 下游服务(core 等)使用精确路径匹配,双斜杠会导致 404 */ + if (target_path.empty() || target_path[0] != '/') + url += "/"; + url += target_path; + + /* 提取 Token(Authorization 优先,回退 Cookie) */ + std::string cookie_token = ""; /* 从 Cookie 提取的 Token(用于向下游注入 Authorization) */ + std::string raw_token = extract_token_from_request(req, cookie_token); + + TokenInfo ti; + if (is_protected) { + /* 受保护请求:统一验签(RSA + 过期 + jti黑名单 + 版本号) */ + if (raw_token.empty()) + THROW_EX_CUSTOM(HttpException, 2301, "Unauthorization", 401, "*"); + + ti = verify_token(raw_token); + if (!ti.valid) { + if (ti.http_status == 400) + THROW_EX_CUSTOM(HttpException, 2302, "Bad token", 400, "*"); + THROW_EX_CUSTOM(HttpException, 2301, "Unauthorization", 401, "*"); + } + } else { + /* 公开请求不做凭证校验,也不向下游注入身份头 */ + cookie_token = ""; + } + + /* + * HTTP客户端回调函数 + * 响应体大小限制由 HttpClient 默认 10MB 上限保证(libhttp P1-2 已修复) */ + auto callback = [&res](const auto& ret) { + res.body() += ret; + }; + + /* 复制(几乎)所有的请求头 */ + auto client = HttpClient(); + for (auto const& field: req) { + + if (is_special_header(field.name_string())) continue; + auto h = std::string(); + h += field.name_string(); + h += ": "; + h += field.value(); + client.set_header(h); + } + + // 主动设置可信的 X-Forwarded-For(P1-1) + // 说明:is_special_header 已剥离客户端可能伪造的 XFF/X-Real-IP/Forwarded + // WAF → Gateway 为可信内网链路,收到的 XFF 即客户端真实 IP + // 下游服务应信任此值做限流/审计,不再被客户端伪造 + auto xff_it = req.base().find("X-Forwarded-For"); + if (xff_it != req.base().end()) { + client.set_header(std::string("X-Forwarded-For: ") + std::string(xff_it->value())); + } else { + // WAF 未传 XFF 时,标记为 unknown,避免下游拿到空值 + client.set_header("X-Forwarded-For: unknown"); + } + + /* 验签通过:注入可信身份头(strip-then-set 契约,gateway/下游直接信任) */ + if (is_protected) { + client.set_header("X-Auth-User: " + ti.username); + if (ti.is_admin) + client.set_header("X-Auth-Admin: 1"); + /* Token 来自 Cookie 时,向下游补齐 Authorization 头(dnfs 等下游仍按 Bearer 解析,无需改动); + * 来自 Authorization 头时上面请求头复制环节已透传 */ + if (!cookie_token.empty()) { + client.set_header("Authorization: Bearer " + cookie_token); + } + } + + /* 转发请求;上游 gateway 不可达/超时统一 502(区别于 CA 自身故障的 500) */ + try { + if (method == "get") + client.get_stream_sync(url, callback); + + else if (method == "post") { + std::string payload = req.body(); + client.post_stream_sync(url, payload, callback); + } + } catch (const std::exception& e) { + logs().error("ca", "上游 gateway 转发失败: " + url, std::nullopt, e.what()); + THROW_EX_CUSTOM(HttpException, 2304, "Bad Gateway", 502, "*"); + } + + /* 复制所有的响应头 */ + for (const auto& pair: client.get_response_headers() ) + res.set(pair.first, pair.second); + + + res.set("Connection", "close"); + res.result(client.get_response_code()); + + res.prepare_payload(); + return res; +} + +bool is_special_header(const std::string &header) { + + // 原过滤:传输层/编码相关,由 HttpClient 自行处理 + // 新增过滤(P1-1):客户端可伪造的 IP 相关头 + // - X-Forwarded-For / X-Real-IP / Forwarded + // 剥离后由 ca 主动设置可信值(见 handleForward 转发逻辑) + // 防止客户端伪造 IP 绕过下游限流/审计 + // A5 修复:Beast name_string() 保留线上原始大小写,比较前统一转小写, + // 否则小写 x-forwarded-for 等变体绕过剥离,与注入的可信 XFF 并存到达下游 + // CA 新增:x-auth-user / x-auth-admin 为 CA 专有注入的身份头, + // 必须剥离客户端伪造值(strip-then-set 契约) + std::string h; + h.reserve(header.size()); + for (unsigned char c: header) + h.push_back(static_cast(std::tolower(c))); + return h == "host" || h == "connection" + || h == "accept-encoding" + || h == "x-forwarded-for" + || h == "x-real-ip" + || h == "forwarded" + || h == "x-auth-user" + || h == "x-auth-admin"; +} + +std::string get_cookie_value(const std::string& cookie_header, const std::string& name) { + // Cookie 头格式:"k1=v1; k2=v2; q4_token=xxx" + size_t pos = 0; + while (pos <= cookie_header.size()) { + size_t end = cookie_header.find(';', pos); + if (end == std::string::npos) end = cookie_header.size(); + std::string pair = cookie_header.substr(pos, end - pos); + size_t eq = pair.find('='); + if (eq != std::string::npos) { + std::string k = pair.substr(0, eq); + size_t b = k.find_first_not_of(" \t"); + size_t e = k.find_last_not_of(" \t"); + if (b != std::string::npos) k = k.substr(b, e - b + 1); + if (k == name) return pair.substr(eq + 1); + } + if (end == cookie_header.size()) break; + pos = end + 1; + } + return ""; +} + +bool is_safe_target(const std::string& target) { + // 请求路径安全性校验(P1-1) + // 防御目标:路径遍历、协议注入、空字节、非绝对路径形式 + + if (target.empty()) return false; + + // 必须以 '/' 开头(拒绝 "http://evil.com/..." 等绝对 URI 形式) + if (target[0] != '/') return false; + + // 拒绝空字节(防止截断绕过) + if (target.find('\0') != std::string::npos) return false; + + // 拒绝路径遍历:明文 ".." + if (target.find("..") != std::string::npos) return false; + + // 拒绝路径遍历:URL 编码形式(%2e / %2E,含 %2f / %5c 的编码变体) + std::string lower_target; + lower_target.reserve(target.size()); + for (char c : target) { + lower_target.push_back(static_cast(std::tolower(static_cast(c)))); + } + if (lower_target.find("%2e") != std::string::npos) return false; + if (lower_target.find("%2f") != std::string::npos) return false; + if (lower_target.find("%5c") != std::string::npos) return false; + + return true; +} diff --git a/confs/configs/ca.ini b/confs/configs/ca.ini new file mode 100644 index 0000000..3c8c190 --- /dev/null +++ b/confs/configs/ca.ini @@ -0,0 +1,51 @@ +[Server] +# CA(Central Auth)前置认证服务:接管原 gateway 的 8443 端口, +# WAF upstream 不变(waf → ca → gateway 前置拓扑)。 +# - tls = off 启用 HTTP 模式(配合 waf 模块使用) +# - tls = on 启用 HTTPS 模式(独立部署,证书为 full_chain.crt / server.key) +address = 127.0.0.1 +port = 8443 +tls = off + +[Upstream] +# 前置拓扑的唯一上游:认证通过后转发到 gateway(仅 loopback) +# 环境变量 UPSTREAM_GATEWAY 可覆盖 +gateway = http://127.0.0.1:8445 + +[Token] +# Token 有效期(秒),默认 3600 = 1 小时 +keep_time = 3600 +# Token 刷新宽限期倍数:过期后 iat + keep_time * 倍数 窗口内仍可刷新,默认 2 +refresh_grace_multiplier = 2 +# Token 生命周期内最大刷新次数,超过后需重新登录,默认 10 +max_refresh = 10 + +[Admin] +# 管理员账号白名单(分号分隔)。登录时命中即在 Token payload 写入 is_admin=true, +# 下游服务(dnfs 等)凭该字段豁免所有权校验,无需各自维护名单。 +# 修改后需重启 ca 生效;旧 Token 无此字段,按非管理员处理。 +users = 1776177662@qq.com;277665039@qq.com; + +[CORS] +# 允许跨域的 Origin 白名单(分号分隔) +# 仅命中的 Origin 才会回填 Access-Control-Allow-Origin;缺省为空(不回填) +allowed_origins = https://qsont.me;https://www.qsont.me;http://127.0.0.1:8443;http://localhost:8443 + +[WsProxy] +# WebSocket 反向代理路由(仅管理员可访问) +# 格式:routes = /path=ws://host:port/target;/path2=ws://host2:port2/target2 +# 握手阶段会校验 HTTP Upgrade 请求中的 Token(Authorization: Bearer xxx 或 Cookie: q4_token=xxx), +# 仅 Token 中 is_admin=true 的用户才能升级连接;非管理员或无 Token 直接拒绝。 +# 支持 ws:// 和 wss:// 后端 +# 示例:routes = /ws/aibox=ws://127.0.0.1:8765;/ws/other=wss://example.com/ws +routes = /ws/aibox=ws://127.0.0.1:8765; + +[Downstream] +# 下游 pymsg 服务地址(其他微服务的 host:port) +# 环境变量可覆盖:DB_HOST/DB_PORT, CAPTCHA_HOST/CAPTCHA_PORT, SMTP_HOST/SMTP_PORT, CONFS_HOST/CONFS_PORT +db_host = 127.0.0.1 +db_port = 8005 +captcha_host = 127.0.0.1 +captcha_port = 8009 +smtp_host = 127.0.0.1 +smtp_port = 8008 diff --git a/confs/configs/gateway.ini b/confs/configs/gateway.ini index 18b972e..001877c 100644 --- a/confs/configs/gateway.ini +++ b/confs/configs/gateway.ini @@ -1,9 +1,11 @@ [Server] -# 当 WAF 在前面终止 TLS 时,gateway 退化为内部 HTTP 服务: -# - tls = off 启用 HTTP 模式(配合 waf 模块使用) -# - tls = on 启用 HTTPS 模式(独立部署,证书为 full_chain.crt / server.key) +# v0.9.0:认证独立化(waf → ca → gateway 前置拓扑) +# gateway 退化为纯转发服务,仅 loopback 监听(启动强制自检,非 loopback 拒绝启动): +# - 8443 由 ca 接管(WAF upstream 不变),gateway 退居 8445 +# - 认证/验签/限流/WS 代理鉴权已全部迁至 ca(confs/configs/ca.ini) +# - tls = off 启用 HTTP 模式(ca 前置后的内部链路) address = 127.0.0.1 -port = 8443 +port = 8445 tls = off [Proxy] @@ -11,43 +13,8 @@ default = http://127.0.0.1:9200 closed = http://127.0.0.1:8005;http://127.0.0.1:8007; opened = http://127.0.0.1:9200;http://127.0.0.1:8006;http://127.0.0.1:5200; -[Token] -# Token 有效期(秒),默认 3600 = 1 小时 -keep_time = 3600 -# Token 刷新宽限期倍数:过期后 iat + keep_time * 倍数 窗口内仍可刷新,默认 2 -refresh_grace_multiplier = 2 -# Token 生命周期内最大刷新次数,超过后需重新登录,默认 10 -max_refresh = 10 - -[Admin] -# 管理员账号白名单(分号分隔)。登录时命中即在 Token payload 写入 is_admin=true, -# 下游服务(dnfs 等)凭该字段豁免所有权校验,无需各自维护名单。 -# 修改后需重启 gateway 生效;旧 Token 无此字段,按非管理员处理。 -users = 1776177662@qq.com;277665039@qq.com; - -[CORS] -# 允许跨域的 Origin 白名单(分号分隔) -# 仅命中的 Origin 才会回填 Access-Control-Allow-Origin;缺省为空(不回填) -allowed_origins = https://qsont.me;https://www.qsont.me;http://127.0.0.1:8443;http://localhost:8443 - -[Database] -path = sqlite3.db - -[WsProxy] -# WebSocket 反向代理路由(仅管理员可访问) -# 格式:routes = /path=ws://host:port/target;/path2=ws://host2:port2/target2 -# 握手阶段会校验 HTTP Upgrade 请求中的 Token(Authorization: Bearer xxx 或 Cookie: q4_token=xxx), -# 仅 Token 中 is_admin=true 的用户才能升级连接;非管理员或无 Token 直接拒绝。 -# 支持 ws:// 和 wss:// 后端 -# 示例:routes = /ws/aibox=ws://127.0.0.1:8765;/ws/other=wss://example.com/ws -routes = /ws/aibox=ws://127.0.0.1:8765; - [Downstream] -# 下游 pymsg 服务地址(其他微服务的 host:port) -# 环境变量可覆盖:DB_HOST/DB_PORT, CAPTCHA_HOST/CAPTCHA_PORT, SMTP_HOST/SMTP_PORT, CONFS_HOST/CONFS_PORT +# 日志推送端点(db pymsg 服务) +# 环境变量可覆盖:DB_HOST/DB_PORT, CONFS_HOST/CONFS_PORT db_host = 127.0.0.1 db_port = 8005 -captcha_host = 127.0.0.1 -captcha_port = 8009 -smtp_host = 127.0.0.1 -smtp_port = 8008 diff --git a/core/init.txt b/core/init.txt index 1a8105f..a739ccd 100644 --- a/core/init.txt +++ b/core/init.txt @@ -5,6 +5,7 @@ bash /opt/micro_servers/dnfs2/_run.sh bash /opt/micro_servers/smtp/_run.sh bash /opt/micro_servers/fsys/_run.sh bash /opt/micro_servers/waf/_run.sh +bash /opt/micro_servers/ca/_run.sh bash /opt/micro_servers/gateway/_run.sh bash /opt/micro_servers/captcha2/_run.sh bash /opt/micro_servers/aibox/_run.sh diff --git a/docs/reports/auth_service_plan.md b/docs/reports/auth_service_plan.md index 7489be8..2aead12 100644 --- a/docs/reports/auth_service_plan.md +++ b/docs/reports/auth_service_plan.md @@ -1,136 +1,146 @@ -# 权限系统独立化计划书 +# 认证独立化(CA 模块)开发文档 -## 一、现状分析 +**日期**: 2026-08-09(v2 重写) +**背景**: gateway v0.8.0 单文件 [main.cpp](../../gateway/src/main.cpp)(2470 行),认证逻辑(约 65%)与反向代理耦合;core/dnfs2 通过 HTTP 回调 gateway 验签,gateway 既是流量入口又是认证权威,形成环形依赖 +**范围**: `ca`(新模块)、`gateway`(瘦身为纯代理);**WAF / core / dnfs2 / static / 前端零改动** +**前版**: 2026-08-08 初版计划书(auth 双栈方案)。本版按已选定拓扑重写:**数据流 `waf → ca → gateway`,gateway 不再验签**。原双栈/后置方案见 §2.2 决策记录 -### 1.1 当前架构 +--- + +## 0. 摘要 + +新建 CA(Central Auth)模块:C++20 服务,接管 gateway 当前监听的 `127.0.0.1:8443`(WAF upstream 不变),承载**全部认证面**(登录/注册/Token 生命周期/CAPTCHA 代理/限流/WS 代理),并作为**认证前置层**:对业务请求本地验签后注入可信身份头,转发给退居 `127.0.0.1:8445` 的 gateway。gateway 删除全部认证代码,不再验签,仅保留白名单反代。 + +核心收益:验签在 CA 本地完成(RSA 密钥同进程,**零新增网络跳**);core/dnfs2 仍调 `8443` 的 `/admin/verify`、`/whoami`,**无感知切换**;前端 URL 不变;现有黑盒 34 断言原样充当验收门。 + +--- + +## 1. 现状分析 + +### 1.1 当前调用链 ``` -客户端 ──HTTPS──▶ WAF:443 ──HTTP──▶ gateway:8443 ──HTTP──▶ 下游服务 - ( auth + proxy + captcha + rate-limit + ws-proxy ) - │ - ┌───────────────┼───────────────┐ - ▼ ▼ ▼ - db:8005 captcha:8009 smtp:8008 - (Users表) (图形验证码) (邮件发送) +客户端 ──HTTPS──▶ WAF:443 ──HTTP──▶ gateway:8443 ──HTTP──▶ static:9200 / dnfs2:8006 / core:5200 + │ ├─ 本地验签(RSA 内存密钥)+ Cookie 回退 + Authorization 注入 + │ ├─ /login /register /logout /refresh_token /whoami /admin/verify + │ ├─ /register/send_code /reset/send_code /reset_password + │ ├─ GET /captcha/image(pymsg 代理 captcha2:8009) + │ └─ WS 代理 /ws/aibox(握手验签 is_admin) + └── pymsg: db:8005 / captcha2:8009 / smtp3:8008 / confs:8010 -下游鉴权调用链: - core:5200 ──HTTP POST──▶ gateway:/admin/verify (管理员验签) - dnfs2:8006 ──HTTP POST──▶ gateway:/whoami (Token 验签) +下游验签回调(HTTP 回环): + core:5200 ──HTTP──▶ gateway:8443/admin/verify (HttpTask.cpp,GATEWAY_HOST/GATEWAY_PORT 可配,fail-closed) + dnfs2:8006 ──HTTP──▶ gateway:8443/whoami (gateway_url 配置项,fail-closed) ``` -### 1.2 Gateway 职责清单(main.cpp 2508 行,单文件) +### 1.2 gateway 职责盘点与归属 -| 职责域 | 函数/组件 | 行数占比 | -|--------|-----------|---------| -| **用户认证** | handleLogin, handleRegister, handleLogout, handleResetPassword, handleSendEmailCode, handleResetSendCode, PBKDF2Hasher, PasswordValidator | ~40% | -| **Token 管理** | verify_token, generate_jti, revoke_jti, revoke_all_user_tokens, handleRefreshToken, handleWhoami, handleAdminVerify, TokenInfo, JtiDenylist, UserVerMap | ~25% | -| **CAPTCHA** | handleCaptcha, handleCaptchaImage, CaptchaEntry, generate_captcha_text, verify_captcha | ~8% | -| **速率限制** | 8 个 RateLimiter 实例(login/register/email/refresh/reset 等) | ~5% | -| **HTTP 反向代理** | handleProxy, is_special_header, is_safe_target | ~12% | -| **WebSocket 代理** | verifyWsAdminToken, ws_proxy 路由注册 | ~5% | -| **配置/基础设施** | main(), registerRoutes(), ConfIni, DbC, LogC, 信号处理 | ~5% | +| 职责域 | 函数/组件 | 归属 | +|--------|-----------|------| +| 用户认证 | `handleLogin` / `handleRegister` / `handleLogout` / `handleResetPassword` / `handleSendEmailCode` / `handleResetSendCode` | → CA | +| Token 管理 | `verify_token` / `generate_jti` / `revoke_jti` / `revoke_all_user_tokens` / `handleRefreshToken` / `handleWhoami` / `handleAdminVerify` / jti 黑名单 / 用户版本号表 / RSA 密钥对 | → CA | +| 密码体系 | `PBKDF2Hasher` / `PasswordValidator` / 防枚举 dummy 校验 | → CA | +| 验证码/邮箱 | `handleCaptchaImage` / `verify_captcha_remote` / `verify_email_code_remote` | → CA | +| 速率限制 | 全部 RateLimiter 实例(登录 5/5min、注册 3/10min、发码 5/10min + 邮箱级 3/h+10/d、验证码 10/min 等) | → CA | +| WS 代理 | `verifyWsAdminToken` + `_WS_PROXY_ROUTES` 注册循环 | → CA(验签+桥接一体) | +| Cookie | `q4_token` 签发/清除、`extract_token_from_request`(Cookie 回退)、代理时 `Authorization: Bearer` 注入 | → CA | +| HTTP 反代 | `handleProxy`(白名单/路径安全/头转发/XFF) | 留 gateway(去验签化) | +| 头卫生 | `is_special_header` / `is_safe_target` | 留 gateway | +| 基础设施 | `wrapperHttp` / `ConfIni` / `DbC` / `LogC` / 信号处理 / 优雅关闭 | 两侧各持一份(模板见 smtp3) | ### 1.3 核心问题 -1. **职责严重耦合**:auth(~65%代码量)与 proxy(~17%)捆绑在同一进程,任一域的重构/扩展都影响全局 -2. **不可独立扩展**:认证密集型场景(批量登录/注册)和代理密集型场景(大文件传输)无法分别扩容 -3. **安全隔离缺失**:密码哈希、RSA 私钥、Token 黑名单等敏感数据与代理逻辑共存于同一进程内存空间 -4. **下游耦合 gateway**:core/dnfs2 通过 HTTP 回调 gateway 做验签,gateway 既是流量入口又是认证权威,形成环形依赖 -5. **单文件维护瓶颈**:2508 行 C++ 单文件,所有功能交叉耦合,测试和 code review 成本高 -6. **Token 状态纯内存**:jti 黑名单和版本号在 gateway 重启后丢失(虽然 RSA 密钥同步重建可兜底,但限制了多实例部署) +1. **职责耦合**:认证与代理同进程,任一域改动爆炸半径为全文件 +2. **环形依赖**:gateway 是流量入口又是验签权威,core/dnfs2 须 HTTP 回调入口 +3. **敏感面混杂**:RSA 私钥、密码哈希校验、jti 黑名单与转发逻辑共用内存空间 +4. **不可独立扩展**:认证密集与代理密集负载无法分别扩容;认证状态纯内存,绑定单实例 --- -## 二、目标架构 +## 2. 目标架构 + +### 2.1 拓扑 ``` -客户端 ──HTTPS──▶ WAF:443 ──HTTP──▶ gateway:8443 ──HTTP──▶ 下游服务 - ( 纯反向代理 + Token 透传 ) - │ - ▼ - auth:8444 (独立权限服务) - ┌────┼────┐ - ▼ ▼ ▼ - db:8005 captcha smtp - -下游鉴权调用链(改造后): - core:5200 ──pymsg──▶ auth:8444:/verify (管理员验签) - dnfs2:8006 ──pymsg──▶ auth:8444:/whoami (Token 验签) - gateway ──pymsg──▶ auth:8444:/verify (代理转发前的 Token 校验) +客户端 ──HTTPS──▶ WAF:443 ──HTTP──▶ CA:8443 ──HTTP──▶ gateway:8445 ──▶ static:9200 / dnfs2:8006 / core:5200 + │ + ├─ 本地处理:/login /register /logout /refresh_token /whoami /admin/verify + │ /register/send_code /reset/send_code /reset_password + │ GET /captcha/image + ├─ WS:/ws/aibox 握手验签(is_admin) → 直接桥接 ws://127.0.0.1:8765 + ├─ 其余路径:本地验签 → 注入 X-Auth-User/Authorization → 转发 gateway:8445 + └─ pymsg: db:8005 / captcha2:8009 / smtp3:8008 / confs:8010 + +端口安排:CA 接管 8443(WAF upstream 配置不变);gateway 迁 8445(仅 loopback,仅 CA 可达)。 ``` -### 2.1 职责划分 +职责划分:**CA = 认证权威 + 认证前置代理**;**gateway = 纯路由/转发策略**(opened 白名单、路径安全、XFF 透传)。 -| 模块 | 改造后职责 | -|------|-----------| -| **auth(新)** | 用户注册/登录/注销、Token 签发/验签/刷新/吊销、CAPTCHA、密码重置、邮箱验证码、速率限制、管理员验证 | -| **gateway(精简)** | HTTP/WebSocket 反向代理、Target-Url 白名单过滤、XFF 注入、CORS 预检;认证路由(/login, /register 等)作为代理目标指向 auth | -| **core / dnfs2** | 验签调用目标由 gateway 改为 auth(pymsg 协议,消除 HTTP 回调) | +### 2.2 决策记录 + +| 方案 | 拓扑 | 结论 | +|------|------|------| +| **选定:CA 前置** | `waf → ca → gateway` | ✅ 验签在 CA 本地(零网络跳);core/dnfs2 调 8443 不变;WAF/前端不变;gateway 无需新增路径路由机制 | +| 否决 A:CA 后置双栈 | `waf → gateway → ca`(HTTP+pymsg 双栈) | gateway 需新增路径级路由表;CORS/Set-Cookie/错误映射下沉 CA;两服务改动面都大 | +| 否决 B:CA 后置纯 pymsg | `waf → gateway →(pymsg) ca` | gateway 保留 HTTP↔pymsg 翻译层与 Cookie 职责,认证面仍在入口进程内,瘦身不彻底;且每请求多一跳 pymsg 验签或引入吊销延迟的缓存 | +| 否决 C:验签缓存 | gateway 缓存验签结果 60s | logout/改密后 Token 在窗口内仍可用,吊销语义弱化(随拓扑选定自然消失) | + +### 2.3 信任模型(重要) + +1. **loopback 信任基线不变**:本架构 loopback 内部服务本就不互鉴权(db:8005 execute 无认证即先例)。gateway:8445 仅绑 `127.0.0.1`,与本基线一致。 +2. **身份头契约(strip-then-set)**:CA 在边缘剥离客户端伪造的 `X-Auth-User` / `X-Auth-Admin`(Beast fields 大小写不敏感,`erase` 后 `set`),验签通过才注入。模式 A 下 CA 的唯一上游是 WAF(loopback)。 +3. **gateway 存在性不变量**:显式 `Target-Url` 请求必须携带 `X-Auth-User`,否则 401(非验签,仅防线编程,防本地误直连/SSR​​F 兜底)。 +4. **模式 B 警示迁移**:CA 直连公网(无 WAF)时 XFF 可被伪造、限流可绕过——原 gateway README 的 A4 降级提示随限流一并迁入 CA README;根治方案 [libhttp_remote_addr_dev_doc.md](libhttp_remote_addr_dev_doc.md) 中的 `trust_xff`/`rate_limit_key()` 配套改在 CA 落地。 --- -## 三、实施阶段 +## 3. CA 模块设计 -### 阶段一:auth 服务搭建(核心剥离) +### 3.1 目录与构建(沿用 smtp3/captcha2 约定) -**目标**:将认证逻辑整体迁入独立 C++ 服务 - -**新建目录结构**: ``` -auth/ -├── src/ -│ └── main.cpp # 入口 + 路由注册 +ca/ +├── src/main.cpp # 入口 + HTTP 路由 + 认证逻辑 + 前置转发(迁移代码为主) ├── inc/ -│ ├── AuthService.hpp # 核心认证逻辑(PBKDF2、Token 签发/验签/吊销) -│ ├── RateLimiter.hpp # 速率限制器(从 gateway 迁移) -│ ├── CaptchaService.hpp # CAPTCHA 生成/验证 -│ ├── DbC.hpp # 复用 gateway 的 DbC(提取为公共库或复制) -│ ├── LogC.hpp # 复用 LogC -│ ├── Conf.hpp # 复用 ConfIni -│ ├── LocalTime.hpp # 复用 -│ └── ExpBase.hpp # 复用 -├── Makefile -├── _run.sh -├── _set.sh +│ ├── Conf.hpp # ConfIni(confs 拉取 + env 覆盖 + 完整性校验) +│ ├── DbC.hpp # 自 gateway 随迁(已基于 libmsg us/Msg.hpp;client 名 "gateway"→"ca") +│ ├── LogC.hpp # 异步日志(smtp3 长连接版) +│ ├── ExpBase.hpp # 异常基类 +│ └── LocalTime.hpp # 时间工具 +├── Makefile # 链接 us_http / us_msg / us_crypt / ssl / crypto / boost_json / curl +├── _set.sh / _run.sh └── README.md ``` -**迁移清单**(从 gateway/src/main.cpp 提取): +- 二进制 `ca.bin`;版本从 `1.0.0` 起;日志服务名 `ca` +- 依赖:libhttp(HttpServer/HttpClient/ws_proxy)、libmsg(MsgClient/RpcClient)、libcrypt(MyRSA/Base64)、OpenSSL(PBKDF2/RAND_bytes)、boost::json/program_options/property_tree、libcurl +- **不依赖 libsqlite3**:Users 表访问全部经 db:8005(现状即如此) -| 组件 | 源位置 | 目标文件 | -|------|--------|---------| -| PBKDF2Hasher | main.cpp L62-85 | AuthService.hpp | -| PasswordValidator | main.cpp L87-160 | AuthService.hpp | -| TokenInfo / generate_jti / verify_token / revoke_* | main.cpp L340-760 | AuthService.hpp | -| handleLogin | main.cpp L1346-1525 | main.cpp (auth) | -| handleRegister | main.cpp L1891-1981 | main.cpp (auth) | -| handleLogout | main.cpp L1527-1554 | main.cpp (auth) | -| handleWhoami | main.cpp L1556-1606 | main.cpp (auth) | -| handleAdminVerify | main.cpp L1608-1657 | main.cpp (auth) | -| handleRefreshToken | main.cpp L1672-1837 | main.cpp (auth) | -| handleSendEmailCode | main.cpp ~L1983-2080 | main.cpp (auth) | -| handleResetSendCode | main.cpp ~L2080-2170 | main.cpp (auth) | -| handleResetPassword | main.cpp ~L2170-2308 | main.cpp (auth) | -| handleCaptcha / handleCaptchaImage | main.cpp ~L1158,1845-1890 | CaptchaService.hpp | -| RateLimiter 实例(8 个) | main.cpp L417-427 | RateLimiter.hpp | -| CAPTCHA_STORE | main.cpp L438-499 | CaptchaService.hpp | -| JTI_DENYLIST / USER_VER_MAP | main.cpp L447-465 | AuthService.hpp | +### 3.2 配置(`confs/configs/ca.ini` 新增) -**新增配置**(`confs/configs/auth.ini`): ```ini [Server] address = 127.0.0.1 -port = 8444 +port = 8443 # 接管 gateway 现端口,WAF upstream 不变 -[Token] +[Upstream] +gateway = http://127.0.0.1:8445 # 业务请求唯一上游 + +[Token] # 自 gateway.ini 原样迁入 keep_time = 3600 refresh_grace_multiplier = 2 max_refresh = 10 -[Admin] +[Admin] # 原样迁入 users = 1776177662@qq.com;277665039@qq.com; -[Downstream] +[WsProxy] # 自 gateway.ini 迁入 +routes = /ws/aibox=ws://127.0.0.1:8765; + +[CORS] # 原样迁入(P2-3:预检反射由 libhttp 处理,白名单暂仍不生效) +allowed_origins = https://qsont.me;https://www.qsont.me;http://127.0.0.1:8443;http://localhost:8443 + +[Downstream] # 原样迁入 db_host = 127.0.0.1 db_port = 8005 captcha_host = 127.0.0.1 @@ -139,235 +149,207 @@ smtp_host = 127.0.0.1 smtp_port = 8008 ``` -**对外接口**(pymsg TCP 协议 + HTTP 双栈): +环境变量覆盖(与 gateway 现机制一致):`LISTEN_ADDR`/`LISTEN_PORT`、`UPSTREAM_GATEWAY`、`DB_*`、`CAPTCHA_*`、`SMTP_*`、`CONFS_HOST`/`CONFS_PORT`。配置加载:confs `get` 拉取(retry_on_refused 阻塞)→ env 覆盖 → 完整性校验。 -| 路由 | 协议 | 说明 | +### 3.3 HTTP 路由表(对外契约与原 gateway 完全一致) + +| 路由 | 处理 | 说明 | |------|------|------| -| POST /login | HTTP | 登录(兼容前端直访) | -| POST /register | HTTP | 注册 | -| POST /logout | HTTP | 注销 | -| POST /refresh_token | HTTP | Token 刷新 | -| POST /whoami | HTTP | 身份查询 | -| POST /register/send_code | HTTP | 发送注册验证码 | -| POST /reset/send_code | HTTP | 发送重置验证码 | -| POST /reset_password | HTTP | 重置密码 | -| POST /captcha | HTTP | 生成验证码 | -| GET /captcha/image | HTTP | 验证码图片代理 | -| verify | pymsg | 内部验签(替代 /admin/verify,供 core/dnfs2/gateway 调用) | -| whoami | pymsg | 内部身份查询(供 dnfs2 调用) | +| POST `/login` | 本地 | 强制远端 CAPTCHA;dummy PBKDF2 防枚举;签发 Token + `q4_token` Cookie | +| POST `/register` | 本地 | 密码强度→邮箱格式→CAPTCHA→邮箱验证码(smtp fail-closed)→写 Users | +| POST `/register/send_code`、`/reset/send_code` | 本地 | IP+邮箱级限流、强制 CAPTCHA、smtp 60s 冷却 429 透传 | +| POST `/reset_password` | 本地 | 防枚举恒成功文案;改密后版本号递增全设备吊销 | +| POST `/logout` | 本地 | jti 吊销 + 清 Cookie | +| POST `/refresh_token` | 本地 | 宽限期刷新、旧 jti 吊销、max_refresh 上限 | +| POST `/whoami` | 本地 | 恒 200,`{login:false}` 不泄露细节;core/dnfs2 依赖此契约 | +| POST `/admin/verify` | 本地 | fail-closed 401;core 依赖此契约 | +| GET `/captcha/image` | 本地 | pymsg 调 captcha2,PNG + `X-Captcha-Token` + `no-store`,10/min/IP | +| WS `[WsProxy]` 路径 | 本地 | 握手验签 `is_admin=true` → 桥接后端(ws/wss),双向透传 | +| GET/POST `*`(通配) | **转发** | 见 §3.4 | -### 阶段二:gateway 瘦身 +### 3.4 前置转发层(通配路由 → gateway:8445) -**目标**:gateway 仅保留反向代理 + WebSocket 代理 +迁移 `handleProxy` 的转发骨架,认证语义前置: -**保留内容**: -- `handleProxy`(HTTP 反代 + Token 透传) -- WebSocket 代理路由注册 + `verifyWsAdminToken`(改为调 auth 的 pymsg verify 接口) -- `is_special_header` / `is_safe_target` / `get_cookie_value` -- 配置加载、信号处理、LogC +1. **路径安全**:`is_safe_target()` 校验(`..`/`%2e`/`%2f`/`%5c`/空字节 → 400),CA/gateway 双层各执行一次(防线叠加,gateway 侧保留原校验)。 +2. **公开 vs 受保护**:无 `Target-Url` 头 → 公开目标(Proxy.default 语义在 gateway),**剥离**客户端 `X-Auth-User`/`X-Auth-Admin` 后直接转发,不注入任何凭证。 +3. **受保护**:有 `Target-Url` → 提取 Token(`Authorization` 优先,回退 `q4_token` Cookie)→ `verify_token` 本地验签(RSA→过期→jti→版本号)→ 失败按现契约 400/401,**不转发**;成功则注入: + - `Authorization: Bearer `(Cookie 来源也统一补齐,dnfs2 等下游零改动) + - `X-Auth-User: `、`X-Auth-Admin: true`(仅管理员;供 gateway 不变量与下游未来免回调使用) +4. **头卫生**:剥离客户端自带的 `X-Auth-User`/`X-Auth-Admin`(strip-then-set);XFF 透传 WAF 值(缺省 `unknown`);`Host`/`connection`/`Accept-Encoding` 等逐跳头处理沿用现 `is_special_header` 集合 + `x-auth-user`/`x-auth-admin` 新增。 +5. **转发失败语义**:gateway:8445 不可达 → 502(与现 captcha/smtp fail-closed 风格一致),记 warning 日志。 +6. **响应**:状态码与响应头原样回传(现 `handleProxy` 行为),`Connection: close`。 -**删除内容**: -- 所有 auth handler(handleLogin ~ handleResetPassword) -- PBKDF2Hasher / PasswordValidator / TokenInfo / JTI_DENYLIST / USER_VER_MAP -- CAPTCHA 相关代码 -- 8 个 RateLimiter 实例(迁移到 auth,auth 路由不再经过 gateway 限流) -- RSA 密钥对生成(`_KEYGEN`,迁移到 auth) +### 3.5 迁移资产清单(gateway `main.cpp` → CA `main.cpp`) + +代码以**整体搬迁**为主,保持实现逐字不变(降低回归面),仅做两处适配:日志服务名 `gateway`→`ca`;DbC client 名同上。 + +| 资产 | 备注 | +|------|------| +| PBKDF2Hasher / PasswordValidator / 常数时间比较 | 含 SHA256 旧哈希迁移注释块(保持注释状态) | +| TokenInfo / verify_token / generate_jti / revoke_jti / revoke_all_user_tokens / jti 黑名单(5000 上限清理)/ 用户版本号表 | RSA 密钥对启动内存生成(重启即全吊销,语义不变) | +| 8 个 RateLimiter 实例 + 邮箱级双窗口限流 | 限流键仍取 XFF(模式 A 可信;模式 B 警示见 §2.3-4) | +| handleLogin ~ handleResetPassword 全部 handler | 错误文案/状态码契约逐字保留(黑盒断言依赖) | +| handleWhoami / handleAdminVerify / handleRefreshToken / handleLogout | 同上 | +| handleCaptchaImage / verify_captcha_remote / verify_email_code_remote | fail-closed 语义保留 | +| verifyWsAdminToken + ws_proxy 注册 | 桥接目标直接为后端(不再经 gateway) | +| handleProxy 转发骨架 / is_safe_target / is_special_header / get_cookie_value / extract_token_from_request | 按 §3.4 改造 | +| wrapperHttp / 信号处理 / 优雅关闭(8s+3s 超时)/ ExpBase / ConfIni / LocalTime / DbC / LogC | 基础设施随迁 | + +### 3.6 不变量自检(启动时) + +- `[Upstream] gateway` 非空且为 `http://127.0.0.1:*`(防误配到公网) +- 模式 B 检测:`Server.address` 非 loopback 时启动日志打印 A4 安全降级警告 + +--- + +## 4. gateway 瘦身设计(v0.9.0) + +### 4.1 删除清单(预计 -1700 行,2470 → ~750 行) + +- 全部认证 handler 与 Token/PBKDF2/RSA/jti/版本号/限流/CAPTCHA/邮箱验证码代码 +- `q4_token` Cookie 读写、`extract_token_from_request`、代理内验签与 Authorization 注入 +- `[Token]`/`[Admin]`/`[WsProxy]`/`[Database]` 配置读取;下游 captcha/smtp 配置(db 保留供 LogC) +- `/login` 等 9 条认证路由 + `/captcha/image` 路由 + WS 代理注册循环 + +### 4.2 保留清单 + +- `handleProxy`(去验签化)+ `is_safe_target` + `is_special_header` + `wrapperHttp` +- 存在性不变量:显式 `Target-Url` 且无 `X-Auth-User` → 401(§2.3-3) +- 公开目标规则:无 `Target-Url` → `Proxy.default`,不校验身份头 +- XFF 处理:透传 CA 值(缺省 `unknown`) +- 配置加载、信号处理、优雅关闭、LogC + +### 4.3 精简后 `confs/configs/gateway.ini` -**gateway.ini 精简**: ```ini [Server] address = 127.0.0.1 -port = 8443 +port = 8445 # 迁出 8443;仅 CA 可达 tls = off [Proxy] default = http://127.0.0.1:9200 -opened = http://127.0.0.1:9200;http://127.0.0.1:8006;http://127.0.0.1:5200; +closed = http://127.0.0.1:8005;http://127.0.0.1:8007; +opened = http://127.0.0.1:9200;http://127.0.0.1:8006;http://127.0.0.1:5200; -[Auth] -host = 127.0.0.1 -port = 8444 - -[WsProxy] -routes = /ws/aibox=ws://127.0.0.1:8765; - -[CORS] -allowed_origins = https://qsont.me;https://www.qsont.me +[Downstream] # 仅供 LogC 日志落库 +db_host = 127.0.0.1 +db_port = 8005 ``` -**认证路由处理方案**(二选一): +### 4.4 启动自检 -- **方案 A(推荐):gateway 代理认证路由** - - gateway 将 `/login`、`/register` 等路径加入代理白名单,Target-Url 自动指向 auth 服务 - - 前端请求路径不变:`POST /login` → WAF → gateway → auth - - 优势:前端零改动,gateway 仍是唯一入口 - -- **方案 B:auth 直暴露** - - WAF 配置分流规则,认证路径直接转发到 auth:8444 - - 需改动 WAF 配置,增加路由复杂度 - -### 阶段三:下游服务适配 - -**目标**:core 和 dnfs2 的验签调用目标从 gateway 切换为 auth - -#### 3.1 core 适配 - -**当前**(`core/src/main/HttpTask.cpp` L155-204): -```cpp -// HTTP POST → gateway:8443/admin/verify -std::string url = "http://" + host + ":" + std::to_string(port) + "/admin/verify"; -pmc::net::HttpClient client; -client.set_header("Authorization: " + raw); -client.post_stream_sync(url, "", ...); -``` - -**改造后**: -```cpp -// pymsg → auth:8444 verify 路由(消除 HTTP 回调,统一内部协议) -pmc::net::MsgClient client(auth_host, auth_port, "core"); -json::value req = {{"_route", "verify"}, {"token", raw_token}}; -std::string resp = client.call(json::serialize(req), 5.0); -``` - -#### 3.2 dnfs2 适配 - -**当前**(`dnfs2/src/dnfs2.cpp` L446-495): -```cpp -// HTTP POST → gateway:8443/whoami -http.post_stream_sync(_cfg.gateway_url, "{}", ...); -``` - -**改造后**: -```cpp -// pymsg → auth:8444 whoami 路由 -RpcClient auth_client(_cfg.auth_host, _cfg.auth_port, "dnfs2", timeout); -json::value result = auth_client.call("whoami", {{"token", token}}); -``` - -**配置文件变更**(`confs/configs/dnfs.ini`): -```ini -[auth] # 新增,替代 [gateway] -host = 127.0.0.1 -port = 8444 -timeout = 5 -``` - -### 阶段四:启动编排与部署 - -**core/init.txt 更新**: -``` -bash /opt/micro_servers/db2/_run.sh -bash /opt/micro_servers/confs/_run.sh -bash /opt/micro_servers/auth/_run.sh # 新增,须在 gateway 之前启动 -bash /opt/micro_servers/ai_review/_run.sh -bash /opt/micro_servers/dnfs2/_run.sh -bash /opt/micro_servers/smtp/_run.sh -bash /opt/micro_servers/static/_run.sh -bash /opt/micro_servers/waf/_run.sh -bash /opt/micro_servers/gateway/_run.sh -bash /opt/micro_servers/captcha/_run.sh -bash /opt/micro_servers/aibox/_run.sh -bash /opt/micro_servers/redirect80/_run.sh -``` - -**docker-compose.yml 新增 auth 服务**: -```yaml -auth: - build: - context: . - dockerfile: auth/Dockerfile - container_name: micro-auth - ports: - - "127.0.0.1:8444:8444" - environment: - - LISTEN_ADDR=0.0.0.0 - - LISTEN_PORT=8444 - - DB_HOST=db - - DB_PORT=8005 - depends_on: - db: - condition: service_healthy -``` +- `Server.address` 必须为 loopback(`127.0.0.1`/`::1`):gateway 不再验签后,绑定公网等于裸奔,**非 loopback 直接拒绝启动**并打印致命错误。 --- -## 四、接口契约设计 +## 5. 接口契约 -### 4.1 auth 内部 pymsg 接口(供其他微服务调用) +### 5.1 对外 HTTP(不变量) -#### verify 路由(替代 /admin/verify) -```json -// 请求 -{"_route": "verify", "token": "base64payload_signature", "require_admin": true} +`https:///login`、`/register`、`/whoami` 等全部 URL、请求/响应 JSON 结构、状态码(400/401/403/429/500/502)、`q4_token` Cookie 属性、`X-Captcha-Token` 头——**逐字不变**。前端、WAF、core、dnfs2 零改动的依据即此。 -// 响应(成功) -{"success": true, "user": "admin@example.com", "is_admin": true} +### 5.2 内部头契约(CA ↔ gateway ↔ 下游) -// 响应(失败) -{"success": false, "error": "token expired"} -``` +| 头 | 方向 | 语义 | +|----|------|------| +| `X-Auth-User` | CA→gateway→下游 | 已验签身份;CA strip-then-set;gateway 对显式 Target-Url 要求其存在 | +| `X-Auth-Admin` | CA→gateway→下游 | `true` 表示管理员;供下游免回调豁免(可选消费) | +| `Authorization` | CA→gateway→下游 | Cookie 来源统一补齐为 Bearer;下游解析不变 | +| `X-Forwarded-For` | WAF→CA→gateway→下游 | 逐层透传,缺省 `unknown` | +| `Target-Url` | 客户端→CA→gateway | 路由策略头,语义不变 | -#### whoami 路由(替代 /whoami) -```json -// 请求 -{"_route": "whoami", "token": "base64payload_signature"} +### 5.3 Token 格式(不变) -// 响应(已登录) -{"success": true, "login": true, "user": "user@example.com", "is_admin": false} +`Base64(payload)_RSA-Sign(payload)`;payload 字段(user/jti/ver/iat/created_at/keep_time/is_admin/refresh_count/max_refresh)不变。切换瞬间旧 Token 全部失效(CA 新密钥),用户重新登录——一次性事件,cutover 通告中说明。 -// 响应(未登录/Token 无效) -{"success": true, "login": false} -``` +### 5.4 下游零改动验证点 -### 4.2 Token 格式不变 - -保持现有 `Base64(payload)_RSA-Sign(payload)` 格式,payload 字段不变: -```json -{ - "user": "email@example.com", - "jti": "random_hex_32", - "ver": 1, - "iat": "2026-08-08 12:00:00", - "created_at": "2026-08-08 12:00:00", - "keep_time": 3600, - "is_admin": true, - "refresh_count": 0, - "max_refresh": 10 -} -``` +| 消费方 | 现状调用 | 切换后 | +|--------|----------|--------| +| core `verify_admin_token` | `http://127.0.0.1:8443/admin/verify` | 同 URL 由 CA 服务,契约不变 | +| dnfs2 Token 验签 | `http://127.0.0.1:8443/whoami` | 同上 | +| static `/api/check_user` | 直连 db 查 Users(账号旁路) | 不受影响;遗留项另行收口 | --- -## 五、风险评估与缓解 +## 6. 上线步骤(cutover 与回滚) -| 风险 | 影响 | 缓解措施 | -|------|------|---------| -| auth 服务不可用导致全系统鉴权失败 | 高 | auth 的 pymsg 客户端启用 `retry_on_refused`;gateway 对 auth 路由的代理失败返回 503(而非静默放行) | -| 前端请求路径变化 | 中 | 采用方案 A(gateway 代理认证路由),前端零改动 | -| Token 验签增加一跳网络延迟 | 低 | pymsg TCP 本机通信延迟 <1ms;gateway 代理层可增加 Token 缓存(TTL 60s) | -| RSA 密钥迁移 | 中 | auth 启动时自生成密钥对(与当前 gateway 行为一致);过渡期可共享密钥文件 | -| 过渡期双系统并存 | 中 | 分阶段切换:先 auth 上线 → gateway 认证路由代理到 auth → 删除 gateway 内置 auth 代码 | -| Token 黑名单持久化需求 | 低 | 初期保持纯内存方案(与现状一致);后续可选 Redis 持久化 | +| 步骤 | 内容 | +|------|------| +| 1 | CA 开发完成,`LISTEN_PORT=18443` 并行实例联调(不影响现网 8443) | +| 2 | gateway 瘦身版(v0.9.0)构建,`gateway.ini` 改 8445 | +| 3 | cutover(秒级窗口):停 gateway:8443 → 启 CA:8443 → 启 gateway:8445 → 黑盒全量验证 | +| 4 | `core/init.txt` 增加 `ca/_run.sh`(gateway 之前);README/INDEX 文档同步 | +| 5 | 观察 24h:CA/gateway 日志、db Logs 表 4xx/5xx 基线对比 | + +**回滚**:停 CA → 旧 gateway 二进制 + 旧配置回 8443 → 重启。Token 全部重签(用户重新登录),无数据迁移。 --- -## 六、时间估算 +## 7. 测试计划 -| 阶段 | 工作量 | 说明 | -|------|--------|------| -| 阶段一:auth 服务搭建 | 2-3 天 | 代码迁移 + 新配置 + 测试 | -| 阶段二:gateway 瘦身 | 1 天 | 删除代码 + 代理路由配置 + 回归测试 | -| 阶段三:下游适配 | 1 天 | core/dnfs2 验签调用改造 + 集成测试 | -| 阶段四:部署编排 | 0.5 天 | init.txt + docker-compose + 端到端测试 | -| **合计** | **4.5-5.5 天** | 含测试缓冲 | +### 7.1 验收门(零改动复用) + +- `tests/blackbox/test_gateway.py` 34 断言**原样**对 8443 执行:认证路径打到 CA、代理路径经 CA→gateway,外部契约不变则全绿(当前基线 32 passed / 2 env-skipped) +- 修复期两个红测试(A3 缺字段 400)已在 v0.8.0 转绿,迁移后必须保持 + +### 7.2 新增探针(test_ca.py,黑盒补充) + +| 探针 | 预期 | +|------|------| +| 客户端伪造 `X-Auth-User: admin@x` + 显式 Target-Url(无 Token) | CA 剥离后 401,不到下游 | +| 伪造 `X-Auth-User` + 有效非管理员 Token 访问 WS 路由 | 403(以验签结果为准,不以头为准) | +| 直连 gateway:8445,显式 Target-Url、无 `X-Auth-User` | 401(存在性不变量) | +| 经 CA 全链路(8443)带 Token 访问 dnfs2 | 下游收到 `X-Auth-User` 与 `Authorization` | +| CA 存活、gateway:8445 停止 | 业务路径 502(不静默放行),认证路径正常 | +| gateway 存活、CA 停止 | WAF 502(入口不可达,符合预期 fail-closed) | +| 公开目标(无 Target-Url)带伪造身份头 | 下游收不到 `X-Auth-User`/`Authorization` | + +### 7.3 集成回归 + +- core HTTP 管理接口(`/admin/verify` 链路)实测;dnfs2 笔记 CRUD(`/whoami` 链路 + AI 审核)实测 +- WS 代理端到端:管理员 Token 升级 `/ws/aibox` 双向帧;非管理员 403 +- 限流全谱在 CA 复测(随机 XFF 技巧沿用);cutover/回滚演练各一次 + +### 7.4 测试资产演进(后续) + +- `test_gateway.py` 按进程边界拆分为 `test_ca.py`(A/B/C/E 段)+ 精简 `test_gateway.py`(D 段代理矩阵 + 8445 直连探针),README/INDEX 同步 +- 认证 happy path 仍依赖 captcha2/smtp 测试钩子(既有遗留,CA 落地后可仅在 CA 层加 `[Security] captcha_required` 开关,单点解锁) --- -## 七、验收标准 +## 8. 风险与缓解 -1. auth 服务独立启动,通过 pymsg 和 HTTP 双栈提供全部认证接口 -2. gateway 代码量从 ~2500 行降至 ~800 行,仅含代理逻辑 -3. 前端所有认证操作(登录/注册/注销/刷新/重置密码)路径不变、行为不变 -4. core/dnfs2 通过 pymsg 调用 auth 验签,延迟 ≤ 5ms(本机) -5. auth 服务重启不影响 gateway 代理转发(已认证请求在 Token 过期前仍有效) -6. 所有现有安全特性(PBKDF2、RSA 签名、常数时间比较、jti 黑名单、版本号吊销)完整保留 +| 风险 | 等级 | 缓解 | +|------|------|------| +| CA 不可用 → 全站入口中断 | 高 | CA 职责内聚后可用性等同于现 gateway;core 守护 + 黑盒监控;回滚方案秒级 | +| 迁移遗漏导致认证行为漂移 | 中 | 代码逐字搬迁 + 34 断言验收门 + §7.2 探针矩阵 | +| 身份头伪造/泄露 | 中 | strip-then-set + gateway 存在性不变量 + 公开目标不注入 + 直连探针回归 | +| gateway:8445 被公网暴露 | 高 | 启动强制 loopback 自检(§4.4);部署审计 checklist 增加端口扫描项 | +| cutover 窗口 Token 全失效 | 低 | 预期内(RSA 换钥),通告用户重新登录 | +| 双重转发延迟 | 低 | 本机 HTTP 转发 <1ms;CA→gateway 短连接可后续优化为 keep-alive | +| 模式 B 限流绕过(A4 迁移) | 中 | CA README 降级警示;根治按 libhttp_remote_addr_dev_doc(配套改落 CA) | + +--- + +## 9. 工作量估算 + +| 部分 | 规模 | 估时 | +|------|------|------| +| CA 脚手架(目录/Makefile/_set.sh/_run.sh/LogC 等) | 模板复制 + 适配 | 0.5 天 | +| 认证资产搬迁 + 前置转发层改造 | ~1700 行搬迁 + ~150 行改造 | 1.5 天 | +| gateway 瘦身 + 启动自检 | -1700 行 + ~20 行 | 0.5 天 | +| 配置/init.txt/文档(README×2、INDEX、confs) | — | 0.5 天 | +| 黑盒验收 + §7.2 探针 + 集成回归 + cutover 演练 | — | 1 天 | +| **合计** | | **4 天**(含缓冲) | + +--- + +## 10. 与既有文档的关系 + +- 本文档**替代** 2026-08-08 初版计划书中的双栈拓扑与阶段划分(初版的职责盘点/迁移清单仍然有效,已并入 §1.2/§3.5) +- [libhttp_remote_addr_dev_doc.md](libhttp_remote_addr_dev_doc.md)(A4 根治):实施时 `trust_xff`/`rate_limit_key()` 配套由 gateway 改为落 CA;`is_special_header` 两侧同步加 `x-remote-addr` +- [gateway_SECURITY_AUDIT_20260809.md](../security/gateway_SECURITY_AUDIT_20260809.md):A2(白名单 403)断言、P2-3(CORS 白名单)随转发层/配置迁入 CA;P1-7(CSRF Token)在 CA 层评估 +- gateway README / 项目 README 的架构图、端口表、请求链路在上线步骤第 4 步同步更新 diff --git a/gateway/Makefile b/gateway/Makefile index 8b4415d..2bad387 100644 --- a/gateway/Makefile +++ b/gateway/Makefile @@ -1,10 +1,10 @@ -VERSION := 0.8.0 +VERSION := 0.9.0 APP_NAME := gateway INSTL_DIR := /usr/local/bin CPP := g++ INCS := -I inc/ -I ../libmsg/inc MY_LIBS := -L/usr/lib -L../libmsg/build -lus_http -lus_httpclient -lus_crypt -lus_msg -LIBS := -pthread -lcurl -lssl -lsqlite3 -lz -lcrypto -lboost_json -lboost_program_options -Wl,-rpath,$(shell cd ../libmsg/build && pwd) +LIBS := -pthread -lcurl -lssl -lz -lcrypto -lboost_json -lboost_program_options -Wl,-rpath,$(shell cd ../libmsg/build && pwd) CPPFLAGS := --std=c++20 -pipe -pthread # -Wall -Werror BUILD_DIR = build $(shell mkdir -p $(BUILD_DIR)) @@ -13,7 +13,7 @@ $(shell mkdir -p $(BUILD_DIR)) all: $(BUILD_DIR)/$(APP_NAME).out $(BUILD_DIR)/$(APP_NAME).out: $(BUILD_DIR)/main.o $(CPP) $^ $(LIBS) $(MY_LIBS) -o $@ -$(BUILD_DIR)/main.o: src/main.cpp inc/DbC.hpp inc/LogC.hpp inc/Conf.hpp inc/ExpBase.hpp inc/LocalTime.hpp +$(BUILD_DIR)/main.o: src/main.cpp inc/LogC.hpp inc/Conf.hpp inc/ExpBase.hpp $(CPP) $(CPPFLAGS) -c $< $(INCS) -o $@ clear: @rm -rvf build/ diff --git a/gateway/README.md b/gateway/README.md index 1f58a3f..0cd9673 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -2,301 +2,101 @@ UniServe Framework 的应用层网关,C++20 实现。 -在引入 [waf](../waf/) 模块后,gateway 退化为内部 HTTP 服务:TLS 由 WAF 终止,gateway 专注做账号鉴权与反向代理。通过 `Server.tls` 配置可一键切换独立部署(HTTPS)或与 WAF 协作(HTTP)两种模式。 +**v0.9.0 起为纯转发服务**:认证职责(Token 签发/验签、登录/注册/重置、限流、WS 代理鉴权)已独立为 [ca](../ca/) 模块。请求链路变为 `waf:443 → ca:8443 → gateway:8445 → 内部服务`,gateway 仅监听 loopback,不再验签。 ## 职责 -- **账号鉴权**:`/register` 注册(需邮箱验证码,经 pymsg 调 smtp:8008 校验)、`/login` 登录,签发 RSA 签名的 Token。 -- **HTTP 反向代理**:携带 Token 的请求校验后转发到 `Proxy.opened` 中放开的内部服务。 -- **WebSocket 反向代理(仅管理员)**:`[WsProxy]` 段配置的路径在 HTTP Upgrade 握手时校验 Token(`is_admin=true`),通过后透明桥接到后端 ws:// 或 wss:// 服务,支持文本/二进制帧双向转发。 -- **CAPTCHA 接口**:`/captcha` 生成验证码 token + 文本(内存存储,5 分钟过期,一次性使用)。 -- **邮箱验证码**:`/register/send_code` 发送 6 位验证码邮件(10 分钟有效);注册时校验,一次性消费。 -- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟、发验证码 5 次/10 分钟(IP 级)+ 3 次/小时/10 次/天(邮箱级),按 `X-Forwarded-For` 客户端 IP 计数(由 WAF 注入),超限一律返回 429。 -- **邮件轰炸防护**:`/register/send_code` 强制 CAPTCHA 校验 + 邮箱级双窗口限流(3 次/小时 + 10 次/天),smtp 层兜底同样限流 + 全局熔断(默认 100 封/小时,可配)。 -- **OPTIONS 预检**:由 libhttp 处理,`Access-Control-Allow-Origin` 反射请求的 Origin 头(任意来源均被放行);`CORS.allowed_origins` 白名单已读取但尚未用于预检响应(见审计 P2-3)。 +- **HTTP 反向代理**:`GET/POST *` 通配路由,按 `Target-Url` 头转发到 `Proxy.opened` 白名单内的内部服务;缺省走 `Proxy.default`(前端静态资源)。 +- **存在性不变量(非验签)**:显式携带 `Target-Url` 的受保护请求必须带有 ca 注入的 `X-Auth-User` 头,缺失一律 401——用于拦截"未经 ca 直连 8445"的误配/调试流量,不是安全边界(安全边界是 loopback 监听 + ca 的 strip-then-set)。 +- **身份头透传**:`X-Auth-User` / `X-Auth-Admin` / `Authorization` 不在 `is_special_header` 剥离列表中,原样转发给下游(dnfs2/core 等零改动)。 +- **XFF 覆写**:剥离客户端伪造的 `X-Forwarded-For`/`X-Real-IP`/`Forwarded`,以收到的 XFF(ca 链路注入)为准重新设置,下游直接信任。 -## 安全特性 +## 信任模型 -| 特性 | 实现 | -|------|------| -| 密码哈希 | PBKDF2-HMAC-SHA256,100000 轮,16 字节随机盐,32 字节哈希 | -| 密码强度校验 | 至少 8 字符;大写/小写/数字/特殊字符中至少 3 类 | -| 邮箱格式校验 | 正则匹配 `^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$` | -| 旧哈希自动迁移 | 登录时若发现 SHA256 旧哈希,验证通过后自动迁移为 PBKDF2(⚠️ 当前源码中该迁移逻辑已被注释禁用,见 `main.cpp` L1102-1109,未生效) | -| Token 机制 | `Base64(payload) + "_" + RSA-Sign(payload)`,验签 + 过期校验(默认 1 小时) | -| 常数时间比较 | PBKDF2 验证用 `diff |= hash[i] ^ expected[i]` 防 timing attack | -| CAPTCHA | 一次性、5 分钟过期,登录与注册均可选启用 | -| 速率限制 | 内存计数器,自动清理超过 1 万条的老条目 | -| TLS 可选 | `Server.tls = off` 时纯 HTTP(配合 WAF),`on` 时用 `full_chain.crt` / `server.key` | -| 下游服务容错 | pymsg 客户端 `retry_on_refused` 机制:db 等强依赖服务未启动时自动阻塞重试,避免启动顺序问题;日志客户端 fail-safe 静默丢弃 | +``` +waf:443 ──HTTP──▶ ca:8443 ──(注入 X-Auth-User)──▶ gateway:8445 ──▶ static:9200 / dnfs2:8006 / core:5200 + ▲ 安全边界 ▲ 仅 loopback +``` -## pymsg 内部通信协议 - -gateway 通过项目内的 [pymsg](../pymsg/)(长度前缀帧 TCP 协议)与内部微服务通信,C++ 实现在 `inc/Msg.hpp`,与 Python 端字节级兼容。 - -**线协议**:`[4字节大端uint32长度][UTF-8载荷]`,最大 64 MiB。 - -**JSON RPC 约定**:请求形如 `{"_route": "<路由名>", ...}`,响应形如 `{"success": true, "data": ...}` 或 `{"success": false, "error": "..."}`。 - -**下游服务调用方式**: - -| 下游 | 默认端口 | `_route` | 调用位置 | 重试策略 | -|------|---------|----------|---------|---------| -| db | 8005 | `execute` | `DbC::call_msg()`(所有数据库 CRUD) | `retry_on_refused=true`,db 未就绪时阻塞等待 | -| db | 8005 | `execute` | `LogC::_send()`(日志异步批量推送) | `retry_on_refused=false`,fail-safe 静默丢弃 | -| captcha | 8009 | `verify` / `image` | `verify_captcha_remote()`、`/captcha/image` 代理 | 默认不重试,连接失败 fail-closed 返回 502/false | -| smtp | 8008 | `verify_email` / `send_email` | `verify_email_code_remote()`、`/register/send_code` | 默认不重试,连接失败 fail-closed 返回 502/false | -| confs | 8010 | `get` | 启动时拉取 `gateway.ini` 配置 | `retry_on_refused=true`,confs 未就绪时阻塞等待 | - -**retry_on_refused 机制**(`MsgClient::call()`,对应 Python 端 `MsgClient.call(retry_on_refused=True)`): -- 仅对 `ECONNREFUSED`(目标端口无服务监听)重试,超时、连接重置、业务错误立即抛出 -- 固定 1 秒间隔重试;首次失败立即打印 stderr 提示,之后每 5 秒打印一次等待日志(节制日志,避免刷屏) -- 用于解决微服务启动顺序不确定问题:gateway 启动时 db 未就绪,首次 db 调用自动阻塞等待直到 db 上线 +- gateway 不持有 RSA 密钥、不验签;身份完全信任 ca 注入的头。 +- 因此 **启动强制 loopback 自检**:`Server.address` 非 `127.0.0.1`/`::1`/`localhost` 时拒绝启动(防止误配导致外部直连伪造身份)。 +- `db:8005` 的 `execute` 路由本就无认证,loopback 信任模型与既有基线一致。 ## 接口 -### `POST /captcha` - -生成验证码。返回: - -```json -{ - "token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", - "text": "Ab3xy" -} -``` - -### `POST /login` - -请求体: - -```json -{ - "username": "user@example.com", - "password": "P@ssw0rd", - "captcha_token": "a1b2c3d4...", // 可选 - "captcha_answer": "Ab3xy" // 可选 -} -``` - -成功响应: - -```json -{ - "success": true, - "data": { "token": "_" } -} -``` - -失败响应: - -```json -{ "success": false, "message": "Invalid Username or Password" } -``` - -错误码:`400`(参数/CAPTCHA 错误)、`401`(账号/密码错或 Token 过期)、`429`(速率超限)。 - -### `POST /register` - -请求体在 `/login` 基础上增加 `email_code`(必填,6 位数字邮箱验证码): - -```json -{ "username": "user@example.com", "password": "...", "email_code": "123456" } -``` - -校验顺序:密码强度 → 邮箱格式 → CAPTCHA(可选)→ **邮箱验证码**(经 pymsg 调 smtp:8008 `verify_email`,一次性消费,连续失败 5 次自动作废,fail-closed),全部通过后写入 `Users` 表。 - -错误码:`400`(密码/邮箱/CAPTCHA/邮箱验证码不合规)、`429`(速率超限)、`500`(数据库异常)。 - -### `POST /register/send_code` - -向目标邮箱发送注册验证码(经 pymsg 调 smtp:8008 `send_email`,10 分钟有效)。 - -**请求体**(CAPTCHA 必填,防邮件轰炸): - -```json -{ - "email": "user@example.com", - "captcha_token": "a1b2c3d4...", - "captcha_answer": "Ab3xy" -} -``` - -**防护层次**: -1. IP 限流:5 次/10 分钟(`X-Forwarded-For`) -2. CAPTCHA 强制校验:经 captcha:8009 pymsg 校验,Token 一次性消费 -3. 邮箱级双窗口限流:3 次/小时 + 10 次/天 -4. smtp 同邮箱 60 秒发送冷却(冷却期拒绝时返回 `retry_after` 剩余秒数与提示文案,gateway 按 429 透传;已发出且在有效期内的验证码不受影响,发送失败不计入冷却) -5. smtp 邮箱级长期限流兜底 + 全局熔断(默认 100 封/小时,`SMTP_HOURLY_LIMIT` 可配) - -错误码:`400`(邮箱格式错误/CAPTCHA 缺失或错误)、`429`(IP 速率超限/邮箱级限流超限/同邮箱发送冷却)、`502`(smtp 服务不可用)、`500`(发送失败)。 - ### `GET/POST *`(反向代理) -携带 header: - ``` -Authorization: Bearer _ -Target-Url: http://127.0.0.1:9200 // 可选,缺省取 Proxy.default +X-Auth-User: user@example.com // 受保护请求必需(由 ca 注入) +Target-Url: http://127.0.0.1:8006 // 可选,缺省取 Proxy.default ``` -- `Target-Url` 缺省时走 `Proxy.default`(不需 Token)。 -- 自定义 `Target-Url` 必须在 `Proxy.opened` 白名单中,否则返回 403。 -- 验签通过且未过期(默认 1 小时)才转发,否则返回 401。 -- `Proxy.closed`(如 db:8005、ai_review:8007)不在 `opened` 中,外部无法直访。 +- `Target-Url` 缺省时走 `Proxy.default`(公开,不检查 `X-Auth-User`)。 +- 自定义 `Target-Url` 必须在 `Proxy.opened` 白名单中,否则 403。 +- 显式 `Target-Url` 且无 `X-Auth-User` → 401。 +- `Proxy.closed`(如 db:8005、ai_review:8007)不在 `opened` 中,无法经 gateway 直访。 -### `OPTIONS *` - -返回 CORS 预检响应:`Access-Control-Allow-Origin` 反射请求 Origin 头(等价于允许任意来源,见审计 P2-3)、`Access-Control-Allow-Methods: GET, POST, OPTIONS`、`Max-Age: 86400`。 - -### WebSocket 代理(`[WsProxy]` 配置的路径) - -握手阶段鉴权规则: - -1. 从 `Authorization: Bearer ` 头读取 Token;若缺失则回退到 `Cookie: q4_token=`。 -2. RSA 验签 + 过期校验(与 HTTP 代理同样的 Token 机制)。 -3. Token payload 中必须 `is_admin=true`(即登录账号在 `Admin.users` 白名单内)。 -4. 鉴权失败 → 返回 HTTP 403 Forbidden,连接被拒绝。 -5. 鉴权通过 → 升级为 WebSocket 并透明桥接到配置的后端 ws:// 或 wss:// 地址,双向文本/二进制帧原样转发。 - -客户端使用方式与普通 WebSocket 一致,只需携带有效管理员 Token: - -```javascript -// 浏览器(同源会自动带 q4_token Cookie) -const ws = new WebSocket("wss://qsont.me/ws/aibox"); - -// 或显式带 Authorization(构造子协议或连接后首条消息均可,这里由网关在 Upgrade 头校验) -``` - -注意:WS 代理路由必须精确匹配(与 `ws()` 一致,非通配),且优先级高于 HTTP 通配 `*` 路由。 - -## 数据库 - -用户数据存于 db 服务(pymsg/TCP :8005)的 SQLite 库,表 `Users`(由 [db/create_sqlite3.sql](../db/create_sqlite3.sql) 建立,gateway 不再自动建表): - -| 字段 | 类型 | 说明 | -|------|------|------| -| id | INTEGER PK AUTOINCREMENT | | -| 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 | | - -gateway 不直连数据库(早期直连本地 sqlite 的代码已注释保留),所有读写经 `DbC::call_msg` 调 db 的 `execute` 路由。无默认管理员账号——首位注册者即为管理员。 +错误响应统一脱敏:`HttpException` 仅返回纯消息(内部前缀留服务端日志),其余异常一律 `500 {"success": false, "message": "Internal Server Error"}`。 ## 配置 -gateway 配置采用**两层加载模式**,与其他 Python 服务(dnfs/db/static)保持一致: +两层加载:confs 拉取(pymsg `get`,`retry_on_refused=true`)→ 环境变量覆盖。 -1. **confs 统一配置服务**:启动时从 confs(`127.0.0.1:8010`,可通过 `CONFS_HOST`/`CONFS_PORT` 环境变量覆盖)通过 pymsg `get` 路由拉取 `gateway.ini`,`retry_on_refused=true` 阻塞等待 confs 就绪。 -2. **环境变量覆盖**(最高优先级,Docker 兼容):`LISTEN_ADDR`/`LISTEN_PORT`、`DB_HOST`/`DB_PORT`、`CAPTCHA_HOST`/`CAPTCHA_PORT`、`SMTP_HOST`/`SMTP_PORT` 覆盖 ini 中对应值。 -3. **完整性校验**:必需配置项缺失则启动失败。 +配置文件:[confs/configs/gateway.ini](../confs/configs/gateway.ini)。 -配置文件统一存放于 [confs/configs/gateway.ini](../confs/configs/gateway.ini),gateway 目录下不再保留本地配置文件。 - -配置项说明: -- `Server.address`/`port`/`tls`:监听地址、端口、TLS 开关;`address`/`port` 可被 `LISTEN_ADDR`/`LISTEN_PORT` 环境变量覆盖。 -- `Proxy.default`:未带 `Target-Url` header 时的默认转发地址(不需 Token)。 -- `Proxy.opened`:允许外部带 Token 访问的内部服务白名单(分号分隔)。 -- `Proxy.closed`:仅文档记录用途,实际靠"不在 opened 中即拒绝"实现隔离。 -- `Token.*`:Token 有效期(`keep_time`)、刷新宽限期倍数(`refresh_grace_multiplier`)、最大刷新次数(`max_refresh`)。 -- `Admin.users`:管理员白名单(分号分隔邮箱),登录命中时 Token payload 写入 `is_admin=true`。 -- `CORS.allowed_origins`:允许跨域的 Origin 白名单(分号分隔)。⚠️ 当前已读取但**尚未用于预检响应**——OPTIONS 预检由 libhttp 反射请求 Origin,白名单暂不生效(见审计 P2-3)。 -- `WsProxy.routes`:WebSocket 代理路由,格式 `/path=ws://host:port/target`,分号分隔。 -- `Downstream.*`:下游 pymsg 服务地址(db/captcha/smtp),可被 `DB_HOST`/`DB_PORT`、`CAPTCHA_HOST`/`CAPTCHA_PORT`、`SMTP_HOST`/`SMTP_PORT` 环境变量覆盖。 - -## 部署模式 - -### 模式 A:与 WAF 协作(推荐) - -``` -客户端 ──HTTPS──▶ waf:443 (终止 TLS + coraza 规则) ──HTTP──▶ gateway:8443 -``` - -```ini -address = 127.0.0.1 -port = 8443 -tls = off -``` - -证书放在 `waf/certs/`。RateLimiter 从 WAF 注入的 `X-Forwarded-For` 取真实客户端 IP。 - -### 模式 B:独立部署 - -``` -客户端 ──HTTPS──▶ gateway:443 (终止 TLS + 鉴权 + 反代) -``` - -```ini -address = 0.0.0.0 -port = 443 -tls = on -``` - -证书 `full_chain.crt` / `server.key` 放在 gateway 目录。RateLimiter 取不到 `X-Forwarded-For`,所有请求记为 `"direct"`。 +- `Server.address`/`port`/`tls`:默认 `127.0.0.1:8445`(v0.9.0 起 8443 归 ca);env `LISTEN_ADDR`/`LISTEN_PORT`。 +- `Proxy.default` / `Proxy.opened` / `Proxy.closed`:转发目标与白名单(分号分隔)。 +- `Downstream.db_host`/`db_port`:日志推送端点(env `DB_HOST`/`DB_PORT`)。 ## 编译与运行 -依赖系统库(由根目录 [_setup.deb.sh](../_setup.deb.sh) 或 [_setup.rhel.sh](../_setup.rhel.sh) 安装): - -- `libboost_json`、`libboost_program_options` -- `libcurl` -- `libssl` / `libcrypto`(OpenSSL,PBKDF2 + RSA + RAND_bytes) -- `libsqlite3` -- 项目内库:`libus_http`、`libus_crypt`(由 [libhttp](../libhttp/)、[libcrypt](../libcrypt/) 编译安装) - ```bash bash _set.sh # make clean && make bash _run.sh # 从 confs 拉取配置启动 ``` -无需传参,配置统一由 confs 管理。命令行参数:`-h/--help`、`-v/--version`。 +命令行参数:`-h/--help`、`-v/--version`。日志经 LogC 异步批量推送 db:8005 `Logs` 表(service 名 `gateway`)。 ## 目录结构 ``` gateway/ ├── src/ -│ └── main.cpp # 全部实现:配置加载(confs → 环境变量覆盖)、PBKDF2、RateLimiter、路由/handler +│ └── main.cpp # 全部实现:配置加载、存在性不变量、反向代理(约 560 行) ├── inc/ -│ ├── Msg.hpp # pymsg C++ 实现:长度前缀帧协议、MsgClient(含 retry_on_refused)、MsgException 族 -│ ├── DbC.hpp # db 服务客户端:通过 pymsg 调 dbv2(地址可配置,retry_on_refused=true) -│ ├── LogC.hpp # 统一日志客户端:异步批量队列 + pymsg INSERT(fail-safe) -│ ├── Conf.hpp # ConfIni:boost::property_tree ini 解析,支持 fromString/env/env_int +│ ├── LogC.hpp # 统一日志客户端(异步批量,fail-safe) +│ ├── Conf.hpp # ConfIni:ini 解析 + env 覆盖 │ ├── ExpBase.hpp # 异常基类(带 source_location) -│ └── LocalTime.hpp # 本地时间工具(getCurrent / parse) +│ ├── DbC.hpp # (保留备用)db 服务客户端 +│ └── LocalTime.hpp # (保留备用)本地时间工具 ├── Makefile ├── _set.sh / _run.sh └── README.md ``` -配置文件统一在 [confs/configs/gateway.ini](../confs/configs/gateway.ini),gateway 目录无本地配置文件。 - ## 版本 -`0.8.0`(见 [src/main.cpp:35](src/main.cpp#L35))。启动时打印: +`0.9.0`(见 `src/main.cpp`)。启动时打印: ``` -Gateway 0.8.0 started +Gateway 0.9.0 started ``` 近期变更: +- `0.9.0`:**认证独立化瘦身**——认证资产(Token 签发/验签、`/login` `/register` `/logout` `/refresh_token` `/whoami` `/admin/verify` `/captcha/image` `/reset_*` 全部 handler、限流、WS 代理鉴权、PBKDF2/RSA)整体迁至新模块 [ca](../ca/)(v1.0.0,逐字保留实现);gateway 退居 `127.0.0.1:8445` 纯转发(8443 由 ca 接管,WAF upstream 不变);新增启动 loopback 强制自检与 `X-Auth-User` 存在性不变量(显式 `Target-Url` 无该头 → 401);移除对 libcrypt/OpenSSL/sqlite3 的运行时依赖(编译仍链接 libus_crypt 库)。黑盒 tests/blackbox/test_gateway.py 34 断言对 8443(现为 ca)原样全绿。 - `0.8.0`:**P0 安全问题全部修复**(安全评级 B+ → A-)——HttpException 错误消息脱敏(4 处内部步骤消息替换为用户友好文案,修正注册失败状态码 400→500);**进程退出 bug 修复**——信号处理改为 async-signal-safe(仅设 `volatile sig_atomic_t` 标志 + `write()` 输出)、新增 SIGPIPE 忽略、stop() 添加 8 秒超时保护 + 日志 3 秒超时(保证 11 秒内必定退出)、服务器事件循环改在独立线程运行;Token 双轨吊销机制(jti 黑名单 + 用户版本号)、错误信息脱敏(不返回源码路径/C++ 类型名)、JTI 黑名单内存有界保护(5000 条阈值 + 过期清理)、反向代理响应大小限制(libhttp 默认 10MB)、集中式异步日志客户端 LogC、WebSocket 代理(管理员权限校验)、多层速率限制(IP 级 + 邮箱级)、PBKDF2-HMAC-SHA256 密码哈希(10 万轮)、RSA Token 签名验签、HttpOnly+Secure+SameSite Cookie、密码强度校验。 -- `0.7.0`:配置体系完全迁移到 confs 统一配置服务——配置文件统一存放于 `confs/configs/gateway.ini`(原 example.ini 内容 + `[Downstream]` 段),删除本地 example.ini,移除 `-i/--input` 命令行参数(不再支持本地 ini 文件回退,与 static 模块一致);启动时从 confs(`CONFS_HOST`/`CONFS_PORT`,默认 `127.0.0.1:8010`)通过 pymsg `get` 路由拉取配置(`retry_on_refused=true`);`ConfIni` 增加 `fromString()`/`env()`/`env_int()`;环境变量覆盖支持 `LISTEN_ADDR`/`LISTEN_PORT`/`DB_HOST`/`DB_PORT`/`CAPTCHA_HOST`/`CAPTCHA_PORT`/`SMTP_HOST`/`SMTP_PORT`;配置完整性校验;`DbC` 增加 `init(host, port)`,main.cpp 所有硬编码 captcha/smtp/db 地址改为配置变量,`logs().set_endpoint()` 启动时初始化;`--version/-v` 选项补全实现;版本升至 0.7.0。 +- `0.7.0`:配置体系完全迁移到 confs 统一配置服务——配置文件统一存放于 `confs/configs/gateway.ini`,删除本地 example.ini,移除 `-i/--input` 命令行参数;启动时从 confs 通过 pymsg `get` 路由拉取配置(`retry_on_refused=true`);`ConfIni` 增加 `fromString()`/`env()`/`env_int()`;环境变量覆盖;配置完整性校验;`--version/-v` 选项补全实现。 - `0.6.1`:pymsg 客户端增加 `retry_on_refused` 重试机制——新增 `MsgConnectionRefusedException`;`MsgClient::call()` 新增 `retry_on_refused`/`retry_interval` 参数;`DbC::call_msg()` 启用重试等待 db 就绪;`LogC::_send()` 显式不重试(fail-safe)。 -- `0.6.0`:WebSocket 反向代理(仅管理员)——新增 `[WsProxy]` 配置段,支持 `routes = /path=ws://host:port/target;...` 精确路径映射;握手阶段校验 Token 中 `is_admin=true`(基于 `Admin.users` 白名单),通过后双向透明桥接文本/二进制帧到后端,支持 `ws://` 和 `wss://`;后端连接错误/鉴权失败均优雅关闭。依赖 libhttp v3.1+ 的 `ws_proxy()` 接口。顺带修复 `LocalTime.hpp` 在 Apple clang 下 C++20 tzdb 不可用的编译问题(改用 `strftime`/`get_time`/`mktime`)。 -- `0.5.4`:修复邮件轰炸漏洞(审计报告 P0-1)——`/register/send_code` 强制 CAPTCHA 校验(经 captcha:8009,Token 一次性消费),新增邮箱级双窗口限流(3 次/小时 + 10 次/天);smtp 层同步增加邮箱级长期限流兜底 + 全局熔断(`SMTP_HOURLY_LIMIT` 环境变量,默认 100 封/小时);前端 `register.html`/`register.js` 同步接入图形验证码(复用 `/captcha/image` 代理路由,方案 A:仅在「获取验证码」时要求 CAPTCHA,注册提交不加)。 -- `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,与其他步骤一致)。 +- `0.6.0`:WebSocket 反向代理(仅管理员)——新增 `[WsProxy]` 配置段;握手阶段校验 Token 中 `is_admin=true`,通过后双向透明桥接文本/二进制帧到后端。依赖 libhttp v3.1+ 的 `ws_proxy()` 接口。(v0.9.0 起迁至 ca) +- `0.5.4`:修复邮件轰炸漏洞(审计报告 P0-1)——`/register/send_code` 强制 CAPTCHA 校验,新增邮箱级双窗口限流;smtp 层同步增加邮箱级长期限流兜底 + 全局熔断。(v0.9.0 起迁至 ca) +- `0.5.3`:配合 smtp 同邮箱 60 秒发送冷却——`/register/send_code` 在 smtp 返回 `retry_after` 时按 429 透传其提示文案。 +- `0.5.2`:限流错误码统一为 429;登录时用户不存在不再兜底 500——空结果统一走 401,并执行 dummy PBKDF2 校验拉平时序防用户名枚举;pymsg 下游调用超时常量化。 +- `0.5.1`:登录成功后通过 db 更新 `Users.last_login`(`"$now"` 哨兵)。 ## 依赖 - C++20(`g++`) - Boost:`boost::json`、`boost::program_options`、`boost::property_tree` -- OpenSSL:PBKDF2-HMAC-SHA256、RSA 签名/验签、`RAND_bytes` - libcurl(反向代理出站请求) -- libsqlite3(用户表存储) -- 项目库:[libhttp](../libhttp/)(HttpServer/HttpClient)、[libcrypt](../libcrypt/)(MyRSA/Base64/SHA256)、[libsqlite3](../libsqlite3/) +- 项目库:[libhttp](../libhttp/)(HttpServer/HttpClient)、[libmsg](../libmsg/) diff --git a/gateway/src/main.cpp b/gateway/src/main.cpp index 203690e..ffb4b31 100644 --- a/gateway/src/main.cpp +++ b/gateway/src/main.cpp @@ -1,30 +1,19 @@ #include #include -#include -#include -#include -#include #include #include #include #include #include #include -#include -#include #include #include #include #include #include #include -#include -#include -#include #include "ExpBase.hpp" -#include "LocalTime.hpp" #include "Conf.hpp" -#include "DbC.hpp" #include "LogC.hpp" using namespace pmc::net; @@ -32,7 +21,11 @@ using namespace qing; namespace json = boost::json; namespace po = boost::program_options; -constexpr const char *_VER = "0.8.0"; +/* v0.9.0:认证独立化瘦身(waf → ca → gateway 前置拓扑) + * - 认证资产(Token 签发/验签、登录/注册/重置、WS 代理鉴权)已全部迁至 ca 模块 + * - gateway 退化为纯转发服务:仅 loopback 监听(默认 8445),不验签 + * - 身份契约:显式 Target-Url 请求必须携带 ca 注入的 X-Auth-User 头(存在性不变量) */ +constexpr const char *_VER = "0.9.0"; ////////////////////////////////// #include @@ -57,189 +50,6 @@ std::vector split(const std::string& s, char delim) { } ////////////////////////////////// -// PBKDF2-HMAC-SHA256 password hashing -////////////////////////////////// -class PBKDF2Hasher { -public: - static const int ITERATIONS = 100000; - static const int SALT_LEN = 16; - static const int HASH_LEN = 32; - - static std::string hash_password(const std::string& password) { - unsigned char salt[SALT_LEN]; - RAND_bytes(salt, SALT_LEN); - - unsigned char hash[HASH_LEN]; - PKCS5_PBKDF2_HMAC( - password.c_str(), password.size(), - salt, SALT_LEN, - ITERATIONS, - EVP_sha256(), - HASH_LEN, hash - ); - - // Format: pbkdf2:100000:: - std::string salt_hex = bytes_to_hex(salt, SALT_LEN); - std::string hash_hex = bytes_to_hex(hash, HASH_LEN); - return "pbkdf2:" + std::to_string(ITERATIONS) + ":" + salt_hex + ":" + hash_hex; - } - - static bool verify_password(const std::string& password, const std::string& stored) { - // Parse stored hash - if (stored.substr(0, 7) != "pbkdf2:") { - return false; - } - - auto parts = split(stored, ':'); - if (parts.size() != 4) return false; - - int iterations = std::stoi(parts[1]); - auto salt = hex_to_bytes(parts[2]); - auto expected_hash = hex_to_bytes(parts[3]); - - unsigned char hash[HASH_LEN]; - PKCS5_PBKDF2_HMAC( - password.c_str(), password.size(), - salt.data(), salt.size(), - iterations, - EVP_sha256(), - HASH_LEN, hash - ); - - // Constant-time comparison - if (expected_hash.size() != (size_t)HASH_LEN) return false; - unsigned char diff = 0; - for (int i = 0; i < HASH_LEN; i++) { - diff |= hash[i] ^ expected_hash[i]; - } - return diff == 0; - } - -private: - static std::string bytes_to_hex(const unsigned char* data, size_t len) { - static const char hex[] = "0123456789abcdef"; - std::string result; - result.reserve(len * 2); - for (size_t i = 0; i < len; i++) { - result.push_back(hex[data[i] >> 4]); - result.push_back(hex[data[i] & 0x0F]); - } - return result; - } - - static std::vector hex_to_bytes(const std::string& hex_str) { - std::vector bytes; - bytes.reserve(hex_str.size() / 2); - for (size_t i = 0; i < hex_str.size(); i += 2) { - auto hi = hex_char_to_nibble(hex_str[i]); - auto lo = hex_char_to_nibble(hex_str[i + 1]); - bytes.push_back((hi << 4) | lo); - } - return bytes; - } - - static unsigned char hex_char_to_nibble(char c) { - if (c >= '0' && c <= '9') return c - '0'; - if (c >= 'a' && c <= 'f') return 10 + (c - 'a'); - if (c >= 'A' && c <= 'F') return 10 + (c - 'A'); - return 0; - } -}; - -////////////////////////////////// -// Password strength validator -////////////////////////////////// -class PasswordValidator { -public: - struct Result { - bool valid; - std::string message; - }; - - static Result validate(const std::string& password) { - if (password.size() < 8) { - return {false, "Password must be at least 8 characters"}; - } - if (password.size() > 128) { - return {false, "Password too long (max 128)"}; - } - - bool has_upper = false, has_lower = false, has_digit = false, has_special = false; - for (char c : password) { - // std::isupper 等函数参数为 int,char 为有符号且非 ASCII 时是 UB - // 必须 static_cast 后再传入(P1-2) - unsigned char uc = static_cast(c); - if (std::isupper(uc)) has_upper = true; - else if (std::islower(uc)) has_lower = true; - else if (std::isdigit(uc)) has_digit = true; - else has_special = true; - } - - int categories = has_upper + has_lower + has_digit + has_special; - if (categories < 3) { - return {false, "Password must contain at least 3 of: uppercase, lowercase, digit, special character"}; - } - - return {true, "OK"}; - } -}; - -////////////////////////////////// -// IP Rate Limiter (in-memory) -////////////////////////////////// -#include -#include -#include - -class RateLimiter { -public: - struct AttemptInfo { - int count; - std::chrono::steady_clock::time_point first_attempt; - }; - - bool check_and_record(const std::string& ip, int max_attempts = 5, int window_seconds = 300) { - std::lock_guard lock(mtx); - auto now = std::chrono::steady_clock::now(); - - auto& info = attempts[ip]; - - // Reset if window expired - auto elapsed = std::chrono::duration_cast(now - info.first_attempt).count(); - if (elapsed > window_seconds) { - info.count = 0; - info.first_attempt = now; - } - - info.count++; - - // Cleanup old entries periodically - if (attempts.size() > 10000) { - for (auto it = attempts.begin(); it != attempts.end(); ) { - auto e = std::chrono::duration_cast(now - it->second.first_attempt).count(); - if (e > window_seconds) it = attempts.erase(it); - else ++it; - } - } - - return info.count <= max_attempts; - } - -private: - std::unordered_map attempts; - std::mutex mtx; -}; - -////////////////////////////////// -/* -------- 数据库异常\数据库操作失败 -------- */ -class DatabaseException: public ExceptionBase { -public: - using ExceptionBase::ExceptionBase; - - std::string build_message() const override { - return "[Database]" + ExceptionBase::build_message(); - } -}; /* http异常、非200 */ class HttpException: public ExceptionBase { @@ -264,11 +74,7 @@ private: std::string m_url; }; - -//void db_init(); /* 初始化数据库 */ void registerRoutes(HttpServer &server); -std::string generate_captcha_text(); -bool verify_captcha(const std::string& token, const std::string& answer); /* http视图包装器,主要是将异常回显到浏览器 */ http::response wrapperHttp( @@ -276,7 +82,6 @@ http::response wrapperHttp( const std::unordered_map& params, HttpRequestHandler _main); - /* http代理视图,额外接受一个method字符串 */ http::response handleProxy( const http::request& req, @@ -284,486 +89,31 @@ http::response handleProxy( const char *method ); -/* */ -http::response handleLogin( - const http::request& req, - const std::unordered_map& params); - -http::response handleRegister( - const http::request& req, - const std::unordered_map& params); - -/* 发送注册邮箱验证码:pymsg 调 smtp 服务 send_email 路由 */ -http::response handleSendEmailCode( - const http::request& req, - const std::unordered_map& params); - -/* 发送重置密码邮箱验证码:pymsg 调 smtp 服务 send_email 路由(定制重置文案) */ -http::response handleResetSendCode( - const http::request& req, - const std::unordered_map& params); - -/* 重置密码:与注册流程一致,但最后一步由创建用户改为更新密码 */ -http::response handleResetPassword( - const http::request& req, - const std::unordered_map& params); - -http::response handleCaptcha( - const http::request& req, - const std::unordered_map& params); - -http::response handleLogout( - const http::request& req, - const std::unordered_map& params); - -http::response handleRefreshToken( - const http::request& req, - const std::unordered_map& params); - -/* 当前用户身份查询:验签 Token 后返回 user / is_admin / Token 生命周期信息 */ -http::response handleWhoami( - const http::request& req, - const std::unordered_map& params); - -/* 内部服务间管理员验签视图:供 core 等内部服务调用,验证 Token 是否为有效管理员 */ -http::response handleAdminVerify( - const http::request& req, - const std::unordered_map& params); - -/* 代理 captcha 图形验证码:pymsg 调 captcha 服务,返回 PNG 图片 */ -http::response handleCaptchaImage( - const http::request& req, - const std::unordered_map& params); - -/* 从 Cookie 头中解析指定名称的值(找不到返回空串) */ -std::string get_cookie_value(const std::string& cookie_header, const std::string& name); - - -/* headers can't processed */ +/* 特殊请求头过滤(转发时剥离,见 handleProxy) */ bool is_special_header(const std::string &header); /* 请求路径安全性校验(防路径遍历/协议注入/空字节) */ bool is_safe_target(const std::string& target); -/* ===== Token 吊销机制 ===== */ -// Token 验签结果结构体 -struct TokenInfo { - bool valid = false; - std::string username; - std::string jti; // Token 唯一标识(用于单 Token 吊销) - int ver = 1; // 用户 Token 版本号(用于全设备吊销) - bool is_admin = false; - int keep_time = 3600; - int refresh_count = 0; - int max_refresh = 10; - std::string iat; - std::string created_at; - int http_status = 401; // 验签失败时的推荐 HTTP 状态码 -}; - -// 生成 128-bit 随机 JTI(hex 字符串,32 字符) -std::string generate_jti(); - -// 获取用户当前 Token 版本号(不存在则初始化为 1) -int get_user_ver(const std::string& username); - -// 吊销单个 Token(将 jti 加入黑名单直到其过期) -void revoke_jti(const std::string& jti, int keep_time); - -// 吊销用户所有 Token(递增版本号,使该用户所有旧 Token 失效) -void revoke_all_user_tokens(const std::string& username); - -// 从请求中提取原始 Token 字符串(Authorization: Bearer 优先,回退 Cookie q4_token) -// 返回值:原始 "base64_signature" 格式字符串;提取失败返回空串 -// out_cookie_token:如果 Token 来自 Cookie,回填 cookie_token 值(供下游 Authorization 注入) -std::string extract_token_from_request(const http::request& req, std::string& out_cookie_token); - -// 统一 Token 验签:RSA 验签 + 过期检查 + jti 黑名单检查 + 版本号检查 -// 参数 raw_token:"base64data_signature" 格式(不含 "Bearer " 前缀) -TokenInfo verify_token(const std::string& raw_token); - /* configure items */ static std::string _ADDR; static int _PORT; static bool _TLS = false; static std::string _DEFT_URL; static std::vector _OPEN_URL; -static std::string _DB_PATH; -static int _TOKEN_KEEP_TIME = 3600; // Token 有效期(秒),可由 [Token] keep_time 配置 -static int _TOKEN_REFRESH_GRACE_MULTIPLIER = 2; // Token 刷新宽限期倍数,过期后 iat + keep_time*倍数 窗口内仍可刷新 -static int _TOKEN_MAX_REFRESH = 10; // Token 生命周期内最大刷新次数,可由 [Token] max_refresh 配置 -static std::vector _CORS_ORIGINS; // CORS 允许的 Origin 白名单,可由 [CORS] allowed_origins 配置 -static std::vector _ADMIN_USERS; // 管理员账号白名单,可由 [Admin] users 配置(分号分隔),登录时写入 Token payload 的 is_admin - -/* 下游 pymsg 服务地址(从 confs [Downstream] 段加载,可被环境变量覆盖) */ +/* 下游 pymsg 服务地址(日志推送端点,从 confs [Downstream] 段加载,可被环境变量覆盖) */ static std::string _DB_HOST = "127.0.0.1"; static int _DB_PORT = 8005; -static std::string _CAPTCHA_HOST = "127.0.0.1"; -static int _CAPTCHA_PORT = 8009; -static std::string _SMTP_HOST = "127.0.0.1"; -static int _SMTP_PORT = 8008; /* confs 引导配置(不依赖 confs 本身,环境变量 CONFS_HOST/CONFS_PORT 覆盖,默认 127.0.0.1:8010) */ static std::string _CONFS_HOST = "127.0.0.1"; static int _CONFS_PORT = 8010; -/* WebSocket 代理路由:path -> ws://backend-host:port/target - * 仅管理员用户可访问,握手阶段由 ws_proxy 回调验签 Token 中的 is_admin */ -static std::unordered_map _WS_PROXY_ROUTES; - /* instance items */ static std::unique_ptr _SERVE; -static std::unique_ptr _KEYGEN; -//static std::unique_ptr _SQLDB; -static RateLimiter _LOGIN_RATE; -static RateLimiter _REGISTER_RATE; -static RateLimiter _EMAIL_CODE_RATE; // 发送邮箱验证码 IP 级限流 -static RateLimiter _REFRESH_TOKEN_RATE; // Token 刷新限流 -static RateLimiter _RESET_PASSWORD_RATE; // 重置密码 IP 级限流 -static RateLimiter _RESET_CODE_RATE; // 发送重置密码验证码 IP 级限流 -static RateLimiter _CAPTCHA_IMAGE_RATE; // 获取验证码图片 IP 级限流(防全局 DoS,P1-1) -// 邮箱级双窗口限流:同一邮箱在 gateway 层的发送配额。 -// 配合 IP 限流(IP 可被代理池绕过)与 smtp 同邮箱 60s 冷却(仅防短期重复)。 -// 双窗口设计:小时窗限制短期轰炸,天窗限制长期骚扰。 -static RateLimiter _EMAIL_ADDR_RATE_HOUR; // 3 次/小时/邮箱 -static RateLimiter _EMAIL_ADDR_RATE_DAY; // 10 次/天/邮箱 - -/* 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 -struct CaptchaEntry { - std::string answer; - std::chrono::steady_clock::time_point created; -}; -static std::unordered_map _CAPTCHA_STORE; -static std::mutex _CAPTCHA_MTX; - -/* Token 吊销机制(P1:Token 吊销缺失修复) - * - * 双轨纯内存方案: - * 1. jti_denylist:单 Token 吊销(logout),key=jti,val=过期时间(steady_clock),到期自动清理 - * 2. user_ver_map:用户级版本号(改密/封禁),key=username,val=当前版本号,签发新 Token 时读取 - * - * 设计优势: - * - 零 DB 改动:黑名单和版本号全在 gateway 进程内存中 - * - 重启自动清空:gateway 每次重启 RSA 密钥对重新生成,旧 Token 签名天然验签失败 - * - O(1) 验签:unordered_set/map 查找均摊 O(1),对代理转发性能无影响 - * - 内存有界:jti_denylist 定期清理过期条目;user_ver_map 按需清理 - */ -struct JtiDenylistEntry { - std::chrono::steady_clock::time_point expire_at; -}; -static std::unordered_map _JTI_DENYLIST; -static std::unordered_map _USER_VER_MAP; -static std::mutex _TOKEN_REVOKE_MTX; -static constexpr size_t _JTI_DENYLIST_MAX_SIZE = 5000; // 超过此阈值触发批量清理 /* 集中式日志客户端:推送 gateway 运行日志到 db 模块 Logs 表(异步批量,fail-safe) */ static inline LogC& logs() { return LogC::instance(); } -std::string generate_captcha_text() { - static const char chars[] = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789"; - // thread_local:每个线程独立 rng 实例,避免多线程并发调用 dist(rng) 时的数据竞争 - // (static 变量初始化本身线程安全,但 mt19937::operator() 修改内部状态非线程安全) - thread_local std::mt19937 rng(std::random_device{}()); - thread_local std::uniform_int_distribution dist(0, sizeof(chars) - 2); - - std::string text; - for (int i = 0; i < 5; i++) { - text += chars[dist(rng)]; - } - return text; -} - -bool verify_captcha(const std::string& token, const std::string& answer) { - std::lock_guard lock(_CAPTCHA_MTX); - auto it = _CAPTCHA_STORE.find(token); - if (it == _CAPTCHA_STORE.end()) return false; - - auto elapsed = std::chrono::duration_cast( - std::chrono::steady_clock::now() - it->second.created).count(); - if (elapsed > 300) { // 5 min expiry - _CAPTCHA_STORE.erase(it); - return false; - } - - bool match = (it->second.answer == answer); - _CAPTCHA_STORE.erase(it); // one-time use - return match; -} - -/* 调用 captcha 服务(pymsg/TCP :8009)校验图形验证码。 - * Token 一次性消费;服务不可用或响应异常一律视为校验失败(fail-closed)。 - * 与内置 verify_captcha 并存:登录/注册可任选其一接入。 */ -bool verify_captcha_remote(const std::string& token, const std::string& answer) { - try { - pmc::net::MsgClient client(_CAPTCHA_HOST, _CAPTCHA_PORT, "gateway"); - json::value jv_req = { - {"_route", "verify"}, - {"token", token}, - {"answer", answer} - }; - 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; - return obj.at("data").as_object().at("valid").as_bool(); - } catch (const std::exception&) { - return false; - } -} - -/* 调用 smtp 服务(pymsg/TCP)校验邮箱验证码。 - * 验证码一次性消费;服务不可用或响应异常一律视为校验失败(fail-closed)。 */ -bool verify_email_code_remote(const std::string& email, const std::string& code) { - try { - pmc::net::MsgClient client(_SMTP_HOST, _SMTP_PORT, "gateway"); - json::value jv_req = { - {"_route", "verify_email"}, - {"email", email}, - {"code", code} - }; - 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; - return obj.at("data").as_object().at("valid").as_bool(); - } catch (const std::exception&) { - return false; - } -} - -/* ===== Token 吊销机制实现 ===== */ - -std::string generate_jti() { - unsigned char jti_bytes[16]; - RAND_bytes(jti_bytes, 16); - std::string jti; - static const char hex[] = "0123456789abcdef"; - jti.reserve(32); - for (int i = 0; i < 16; i++) { - jti += hex[jti_bytes[i] >> 4]; - jti += hex[jti_bytes[i] & 0x0F]; - } - return jti; -} - -int get_user_ver(const std::string& username) { - std::lock_guard lock(_TOKEN_REVOKE_MTX); - auto it = _USER_VER_MAP.find(username); - if (it == _USER_VER_MAP.end()) { - _USER_VER_MAP[username] = 1; - return 1; - } - return it->second; -} - -void revoke_jti(const std::string& jti, int keep_time) { - if (jti.empty()) return; - std::lock_guard lock(_TOKEN_REVOKE_MTX); - auto expire = std::chrono::steady_clock::now() + std::chrono::seconds(keep_time); - _JTI_DENYLIST[jti] = {expire}; - - // 超过阈值时批量清理过期条目 - if (_JTI_DENYLIST.size() > _JTI_DENYLIST_MAX_SIZE) { - auto now = std::chrono::steady_clock::now(); - for (auto it = _JTI_DENYLIST.begin(); it != _JTI_DENYLIST.end(); ) { - if (now > it->second.expire_at) - it = _JTI_DENYLIST.erase(it); - else - ++it; - } - } -} - -void revoke_all_user_tokens(const std::string& username) { - if (username.empty()) return; - std::lock_guard lock(_TOKEN_REVOKE_MTX); - auto it = _USER_VER_MAP.find(username); - if (it == _USER_VER_MAP.end()) { - _USER_VER_MAP[username] = 2; // 初始版本1,吊销后为2 - } else { - it->second++; - } - logs().info("gateway", "用户所有Token已吊销: " + username, std::nullopt, - json::serialize(json::value{{"new_ver", _USER_VER_MAP[username]}})); -} - -std::string extract_token_from_request(const http::request& req, std::string& out_cookie_token) { - out_cookie_token.clear(); - std::string raw; - - // 1. 优先从 Authorization: Bearer 头获取 - auto it_auth = req.base().find("Authorization"); - if (it_auth != req.base().end()) { - raw = std::string(it_auth->value()); - } - - // 2. 回退从 Cookie q4_token 获取 - if (raw.empty()) { - auto cit = req.base().find("Cookie"); - if (cit != req.base().end()) { - std::string cookie_token = get_cookie_value(std::string(cit->value()), "q4_token"); - if (!cookie_token.empty()) { - raw = "Bearer " + cookie_token; - out_cookie_token = cookie_token; - } - } - } - - // 去掉 "Bearer " 前缀 - if (raw.size() >= 7 && raw.substr(0, 7) == "Bearer ") { - return raw.substr(7); - } - return raw; // 可能为空或不含前缀,由调用方处理 -} - -// 清理 denylist 中指定 jti(如果已过期则删除) -static void cleanup_jti_if_expired(const std::string& jti) { - auto now = std::chrono::steady_clock::now(); - auto it = _JTI_DENYLIST.find(jti); - if (it != _JTI_DENYLIST.end() && now > it->second.expire_at) { - _JTI_DENYLIST.erase(it); - } -} - -TokenInfo verify_token(const std::string& raw_token) { - TokenInfo info; - info.valid = false; - - if (raw_token.empty()) { - info.http_status = 401; - return info; - } - - // 解析 base64data_signature - size_t idx = raw_token.find("_"); - if (idx == std::string::npos) { - info.http_status = 400; - return info; - } - - std::string data = raw_token.substr(0, idx); - std::string sign = raw_token.substr(idx + 1); - std::string auth; - try { - auth = Base64::base64_decode_to_string(data); - } catch (const std::exception&) { - info.http_status = 400; - return info; - } - - // RSA 验签 - auto publ = MyRSA::Public_Key::from_pem(_KEYGEN->get_public_key_pem()); - if (!publ.Verify(auth, sign)) { - info.http_status = 401; - return info; - } - - // 解析 payload - json::value jv_t; - try { - jv_t = json::parse(auth); - } catch (const std::exception&) { - info.http_status = 400; - return info; - } - if (!jv_t.is_object()) { - info.http_status = 400; - return info; - } - - json::object& obj = jv_t.as_object(); - - // 提取字段(兼容旧 Token 缺失 jti/ver 字段的情况) - try { - info.username = json::value_to(obj.at("user")); - } catch (...) { - info.http_status = 400; - return info; - } - - // 过期检查 - auto it_created = obj.find("created_at"); - if (it_created != obj.end() && it_created->value().is_string()) { - info.created_at = json::value_to(it_created->value()); - } - auto it_keep = obj.find("keep_time"); - if (it_keep != obj.end() && it_keep->value().is_int64()) { - info.keep_time = static_cast(it_keep->value().as_int64()); - } - if (!info.created_at.empty()) { - auto created_tp = LocalTime::parse(info.created_at); - if (created_tp) { - auto now = std::chrono::system_clock::now(); - auto expire_tp = *created_tp + std::chrono::seconds(info.keep_time); - if (now > expire_tp) { - info.http_status = 401; - return info; - } - } - } - - // is_admin - auto it_admin = obj.find("is_admin"); - if (it_admin != obj.end() && it_admin->value().is_bool()) { - info.is_admin = it_admin->value().as_bool(); - } - - // refresh_count / max_refresh / iat - auto it_rc = obj.find("refresh_count"); - if (it_rc != obj.end() && it_rc->value().is_int64()) - info.refresh_count = static_cast(it_rc->value().as_int64()); - auto it_mr = obj.find("max_refresh"); - if (it_mr != obj.end() && it_mr->value().is_int64()) - info.max_refresh = static_cast(it_mr->value().as_int64()); - auto it_iat = obj.find("iat"); - if (it_iat != obj.end() && it_iat->value().is_string()) - info.iat = json::value_to(it_iat->value()); - - // jti 黑名单检查(单 Token 吊销) - auto it_jti = obj.find("jti"); - if (it_jti != obj.end() && it_jti->value().is_string()) { - info.jti = json::value_to(it_jti->value()); - std::lock_guard lock(_TOKEN_REVOKE_MTX); - cleanup_jti_if_expired(info.jti); // 惰性清理 - if (_JTI_DENYLIST.count(info.jti)) { - info.http_status = 401; - return info; - } - } - - // 版本号检查(全设备吊销:改密后版本号递增) - // 兼容旧 Token 无 ver 字段:视为 ver=1 - auto it_ver = obj.find("ver"); - int token_ver = 1; - if (it_ver != obj.end() && it_ver->value().is_int64()) { - token_ver = static_cast(it_ver->value().as_int64()); - } - info.ver = token_ver; - { - std::lock_guard lock(_TOKEN_REVOKE_MTX); - auto it_uv = _USER_VER_MAP.find(info.username); - int current_ver = (it_uv != _USER_VER_MAP.end()) ? it_uv->second : 1; - if (token_ver < current_ver) { - info.http_status = 401; - return info; - } - } - - // 全部通过 - info.valid = true; - info.http_status = 200; - return info; -} - /* -------- 信号处理 -------- * * 安全设计原则: @@ -900,43 +250,10 @@ int main(int argc, char **argv) { _TLS = cf.get("Server.tls", false); _DEFT_URL = cf.get("Proxy.default", ""); _OPEN_URL = split(cf.get("Proxy.opened", ""), ';'); - _DB_PATH = cf.get("Database.path", ""); - _TOKEN_KEEP_TIME = cf.get("Token.keep_time", 3600); - _TOKEN_REFRESH_GRACE_MULTIPLIER = cf.get("Token.refresh_grace_multiplier", 2); - _TOKEN_MAX_REFRESH = cf.get("Token.max_refresh", 10); - _CORS_ORIGINS = split(cf.get("CORS.allowed_origins", ""), ';'); - _ADMIN_USERS = split(cf.get("Admin.users", ""), ';'); - /* 下游服务地址 */ + /* 下游服务地址(日志推送端点) */ _DB_HOST = cf.get("Downstream.db_host", "127.0.0.1"); _DB_PORT = cf.get("Downstream.db_port", 8005); - _CAPTCHA_HOST = cf.get("Downstream.captcha_host", "127.0.0.1"); - _CAPTCHA_PORT = cf.get("Downstream.captcha_port", 8009); - _SMTP_HOST = cf.get("Downstream.smtp_host", "127.0.0.1"); - _SMTP_PORT = cf.get("Downstream.smtp_port", 8008); - - /* [WsProxy] WebSocket 代理路由:格式 /path=ws://host:port/target;... */ - { - std::string ws_cfg = cf.get("WsProxy.routes", ""); - if (!ws_cfg.empty()) { - auto entries = split(ws_cfg, ';'); - for (auto& entry : entries) { - auto eq = entry.find('='); - if (eq != std::string::npos) { - std::string path = entry.substr(0, eq); - std::string url = entry.substr(eq + 1); - auto trim = [](std::string& s) { - while (!s.empty() && std::isspace((unsigned char)s.front())) s.erase(s.begin()); - while (!s.empty() && std::isspace((unsigned char)s.back())) s.pop_back(); - }; - trim(path); trim(url); - if (!path.empty() && !url.empty()) { - _WS_PROXY_ROUTES[path] = url; - } - } - } - } - } /* 3. 环境变量覆盖(优先级最高,Docker 环境使用服务名而非 127.0.0.1) */ std::string env_listen_addr = ConfIni::env("LISTEN_ADDR"); @@ -949,16 +266,6 @@ int main(int argc, char **argv) { int env_db_port = ConfIni::env_int("DB_PORT", -1); if (env_db_port > 0) _DB_PORT = env_db_port; - std::string env_captcha_host = ConfIni::env("CAPTCHA_HOST"); - if (!env_captcha_host.empty()) _CAPTCHA_HOST = env_captcha_host; - int env_captcha_port = ConfIni::env_int("CAPTCHA_PORT", -1); - if (env_captcha_port > 0) _CAPTCHA_PORT = env_captcha_port; - - std::string env_smtp_host = ConfIni::env("SMTP_HOST"); - if (!env_smtp_host.empty()) _SMTP_HOST = env_smtp_host; - int env_smtp_port = ConfIni::env_int("SMTP_PORT", -1); - if (env_smtp_port > 0) _SMTP_PORT = env_smtp_port; - /* 4. 配置完整性校验 */ if (_ADDR.empty() || _PORT == -1) { std::cerr << "[gateway] 致命错误: 配置项 [Server] address/port 缺失" @@ -973,31 +280,31 @@ int main(int argc, char **argv) { std::cerr << "[gateway] 致命错误: 配置项 [Downstream] db_host/db_port 缺失" << std::endl; return 1; } + + /* 5. loopback 强制自检(v0.9.0 前置拓扑): + * gateway 不再验签,身份完全信任 ca 注入的 X-Auth-User 头, + * 因此必须只监听 loopback;非 loopback 地址意味着外部可直连伪造身份,拒绝启动 */ + if (_ADDR != "127.0.0.1" && _ADDR != "::1" && _ADDR != "localhost") { + std::cerr << "[gateway] 致命错误: 前置拓扑(waf → ca → gateway)下 gateway 仅允许 loopback 监听," + << "当前 address=" << _ADDR << ",请改为 127.0.0.1" << std::endl; + return 1; + } } - /* 初始化下游客户端地址 */ - DbC::init(_DB_HOST, _DB_PORT); + /* 初始化日志推送端点 */ logs().set_endpoint(_DB_HOST, _DB_PORT); std::cout << "[gateway] 监听: " << _ADDR << ":" << _PORT << " (TLS: " << (_TLS ? "on" : "off") << ")" << std::endl; - std::cout << "[gateway] 下游服务: db=" << _DB_HOST << ":" << _DB_PORT - << ", captcha=" << _CAPTCHA_HOST << ":" << _CAPTCHA_PORT - << ", smtp=" << _SMTP_HOST << ":" << _SMTP_PORT << std::endl; // 根据 Server.tls 选择 HTTPS(带证书)或 HTTP(无证书)构造函数 - // HTTP 模式用于 WAF 终止 TLS 后的内部转发链路 + // HTTP 模式用于 ca 前置后的内部转发链路 if (_TLS) { _SERVE = std::make_unique (_ADDR, _PORT, "full_chain.crt", "server.key", 4); } else { _SERVE = std::make_unique (_ADDR, _PORT, 4); } - _KEYGEN = std::make_unique (); - //_SQLDB = std::make_unique (_DB_PATH); - /* 迁移到了外部 */ - //db_init(); - /* */ registerRoutes(*_SERVE); @@ -1071,151 +378,19 @@ int main(int argc, char **argv) { if (future.wait_for(std::chrono::seconds(3)) == std::future_status::timeout) { std::cerr << "[gateway] 警告:日志刷新超时,退出" << std::endl; log_flush_thread.detach(); - } else { - log_flush_thread.join(); } + /* 非超时分支不得再 join:async 已完成 join,线程已不可 join, + * 重复 join 抛 std::system_error 导致 terminate(审计 A1) */ } std::cerr << "[gateway] 已退出" << std::endl; _SERVE.reset(); - _KEYGEN.reset(); -} - -//void db_init() { -// -// std::string sql = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='Users'"; -// -// auto flag = false; -// { -// auto stmt = _SQLDB->stmt(sql); -// if (stmt.step()) { -// auto num = stmt.read_int(0); -// if (num == 0) { -// flag = true; -// } -// } -// } -// -// if (flag) { -// std::cout << "Start database init..." << std::endl; -// std::string sql_crtt = -// "CREATE TABLE Users (\n" -// "id INTEGER PRIMARY KEY AUTOINCREMENT,\n" -// "email TEXT UNIQUE NOT NULL,\n" -// "password_hash TEXT NOT NULL,\n" -// "create_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n" -// "last_login DATETIME,\n" -// "is_active BOOLEAN DEFAULT 1\n" -// ");\n\n" -// "CREATE INDEX idx_users_email ON users(email);\n"; -// -// _SQLDB->exec(sql_crtt); -// -// // No hardcoded admin - first registered user becomes admin -// std::cout << "done (no default admin - register manually)" << std::endl; -// } -// -// else { -// std::cout << "The database already init" << std::endl; -// } -//} - - -/* WebSocket 代理握手鉴权:从 HTTP Upgrade 请求中提取 Token 并验证 is_admin。 - * 返回 true 表示鉴权通过,out_target 填入后端 ws:// URL;返回 false 拒绝连接。 */ -static bool verifyWsAdminToken(const http::request& req, - const std::string& target_url, - std::string& out_target) { - out_target.clear(); - try { - /* 提取 Token(Authorization 优先,回退 Cookie) */ - std::string cookie_token; - std::string raw_token = extract_token_from_request(req, cookie_token); - if (raw_token.empty()) return false; - - /* 统一验签(RSA + 过期 + jti黑名单 + 版本号) */ - TokenInfo ti = verify_token(raw_token); - if (!ti.valid) return false; - - // 必须是管理员 - if (!ti.is_admin) { - logs().warning("gateway", "WS proxy rejected: non-admin user", - std::nullopt, std::string(req.target())); - return false; - } - - out_target = target_url; - return true; - } - catch (const std::exception& e) { - std::cerr << "[WS proxy auth error] " << e.what() << std::endl; - return false; - } } /* 注册http路由 */ void registerRoutes(HttpServer &server) { - server.post("/captcha", [](const auto& req, const auto& params) { - return handleCaptcha(req, params); - }); - /* const http::request& req, const std::unordered_map& params */ - server.post("/login", [](const auto& req, const auto& params) -> http::response { - return wrapperHttp(req, params, - [](const auto& req, const auto& params) {return handleLogin(req, params);}); - }); - - server.post("/register", [](const auto& req, const auto& params) -> http::response { - return wrapperHttp(req, params, - [](const auto& req, const auto& params) { return handleRegister(req, params); }); - }); - - /* 发送注册邮箱验证码(须在 post("*") 通配符之前注册) */ - server.post("/register/send_code", [](const auto& req, const auto& params) -> http::response { - return wrapperHttp(req, params, - [](const auto& req, const auto& params) { return handleSendEmailCode(req, params); }); - }); - - /* 发送重置密码邮箱验证码(须在 post("*") 通配符之前注册) */ - server.post("/reset/send_code", [](const auto& req, const auto& params) -> http::response { - return wrapperHttp(req, params, - [](const auto& req, const auto& params) { return handleResetSendCode(req, params); }); - }); - - /* 重置密码(须在 post("*") 通配符之前注册) */ - server.post("/reset_password", [](const auto& req, const auto& params) -> http::response { - return wrapperHttp(req, params, - [](const auto& req, const auto& params) { return handleResetPassword(req, params); }); - }); - - server.post("/logout", [](const auto& req, const auto& params) -> http::response { - return wrapperHttp(req, params, - [](const auto& req, const auto& params) { return handleLogout(req, params); }); - }); - - server.post("/refresh_token", [](const auto& req, const auto& params) -> http::response { - return wrapperHttp(req, params, - [](const auto& req, const auto& params) { return handleRefreshToken(req, params); }); - }); - - server.post("/whoami", [](const auto& req, const auto& params) -> http::response { - return wrapperHttp(req, params, - [](const auto& req, const auto& params) { return handleWhoami(req, params); }); - }); - - /* 内部服务间管理员验签视图(供 core 等调用,须在 post("*") 通配符之前注册) */ - server.post("/admin/verify", [](const auto& req, const auto& params) -> http::response { - return wrapperHttp(req, params, - [](const auto& req, const auto& params) { return handleAdminVerify(req, params); }); - }); - - /* 代理 captcha 图形验证码(须在 get("*") 通配符之前注册) */ - server.get("/captcha/image", [](const auto& req, const auto& params) -> http::response { - return wrapperHttp(req, params, - [](const auto& req, const auto& params) { return handleCaptchaImage(req, params); }); - }); - server.get("*", [](const auto& req, const auto& params) -> http::response { return wrapperHttp(req, params, [](const auto& req, const auto& params) { return handleProxy(req, params, "get"); }); @@ -1226,20 +401,6 @@ void registerRoutes(HttpServer &server) { [](const auto& req, const auto& params) { return handleProxy(req, params, "post"); }); }); - /* WebSocket 反向代理(仅管理员): - * ws_proxy 回调在 HTTP Upgrade 握手阶段触发, - * 返回非空 ws:// URL 表示鉴权通过并桥接到该后端;返回空串则拒绝连接。 */ - for (const auto& [path, url] : _WS_PROXY_ROUTES) { - server.ws_proxy(path, [url](const http::request& upgradeReq) -> std::string { - std::string target; - if (verifyWsAdminToken(upgradeReq, url, target)) { - return target; - } - return ""; - }); - std::cout << " WS-PROXY " << path << " -> " << url << " (admin only)" << std::endl; - } - } /* Http视图包装器 @@ -1268,8 +429,9 @@ catch (const HttpException& e) { json::serialize(json::value{{"url", e.url()}, {"status", e.status()}})); json::value jv_r = { {"success", false}, - {"message", e.what()} - /* {"message", "Registration failed"} */ + /* 客户端只给纯消息(e.message());e.what() 的 [Http][Error NNNN] 内部前缀 + * 仅保留在服务端 stderr/集中日志,不外泄(审计 A8) */ + {"message", e.message()} }; res.set(http::field::content_type, "application/json"); res.result(e.status()); @@ -1302,1029 +464,12 @@ catch (const std::exception& e) { return res; } -// CAPTCHA endpoint: generates a random text, stores it, returns a simple token -// by shuiyu -http::response handleCaptcha( - const http::request& req, - const std::unordered_map& params) -{ - http::response res; - res.version(req.version()); - - std::string text = generate_captcha_text(); - - // Generate a token for this captcha - unsigned char token_bytes[16]; - RAND_bytes(token_bytes, 16); - std::string token; - static const char hex[] = "0123456789abcdef"; - for (int i = 0; i < 16; i++) { - token += hex[token_bytes[i] >> 4]; - token += hex[token_bytes[i] & 0x0F]; - } - - { - std::lock_guard lock(_CAPTCHA_MTX); - _CAPTCHA_STORE[token] = {text, std::chrono::steady_clock::now()}; - } - - json::value jv = { - {"token", token}, - {"text", text} - }; - - res.set(http::field::content_type, "application/json"); - res.result(http::status::ok); - res.body() = json::serialize(jv); - res.prepare_payload(); - return res; -} - -/* 处理登陆 +/* http 代理转发视图(v0.9.0 纯转发版) * - * /login */ -http::response handleLogin( - const http::request& req, - const std::unordered_map& params) -{ - http::response res; - res.version(req.version()); - - // IP rate limit check - std::string client_ip = ""; - auto it_host = req.base().find("X-Forwarded-For"); - if (it_host != req.base().end()) - client_ip = std::string(it_host->value()); - else - client_ip = "direct"; - - if (!_LOGIN_RATE.check_and_record(client_ip, 5, 300)) - THROW_EX_CUSTOM(HttpException, 2001, "Too many login attempts. Please try again later.", 429, "/login"); - - // parse json req body - json::value jv; - try { jv = json::parse(req.body()); } - catch (const boost::wrapexcept& e) { - THROW_EX_CUSTOM(HttpException, 2001, "Invalid_json_Argument", 400, "/login"); - } - - if (!jv.is_object()) THROW_EX_CUSTOM(HttpException, 2002, "Invalid_json_object_Argument", 400, "/login"); - auto& obj = jv.as_object(); - auto username = std::string(obj.at("username").as_string()); - auto password = std::string(obj.at("password").as_string()); - - // CAPTCHA verification 验证码(对接 captcha pymsg 服务,图形码 Token 一次性消费) - auto captcha_it = obj.find("captcha_token"); - auto captcha_ans_it = obj.find("captcha_answer"); - if (captcha_it == obj.end() || captcha_ans_it == obj.end()) - THROW_EX_CUSTOM(HttpException, 2002, "Bad_Request", 400, "/login"); - std::string ct = std::string(captcha_it->value().as_string()); - std::string ca = std::string(captcha_ans_it->value().as_string()); - if (!verify_captcha_remote(ct, ca)) THROW_EX_CUSTOM(HttpException, 2201, "Invalid CAPTCHA", 400, "/login"); - - std::string stored_hash = ""; - { - // Fetch stored hash - support both PBKDF2 and legacy SHA256 - //std::string sql = "SELECT password_hash FROM Users WHERE email=?;"; - //auto stmt = _SQLDB->stmt(sql); - //stmt.bind_text(1, username); - //if (stmt.step()) { - // stored_hash = std::string(reinterpret_cast(stmt.read_text(0))); - //} - - // 新版调用db模块(通过 MsgClient pymsg TCP 协议) - // 【保留】旧版 HTTP 调用方式: - // auto jv_db_r = DbC::call_http(jv_select); - json::value jv_select = { - {"action", "select"}, - {"table", "Users"}, - {"where", json::object{ - {"username", username}, - }} - }; - 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 failed, please try again later", 500, "/login"); - auto obj_rd = obj_r.at("data").as_array(); - /* 用户不存在时 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(); - } - } - - - - bool auth_ok = false; - if (!stored_hash.empty()) { - if (stored_hash.substr(0, 7) == "pbkdf2:") - // New PBKDF2 hash - auth_ok = PBKDF2Hasher::verify_password(password, stored_hash); - //else { - // Legacy SHA256 hash - verify then auto-migrate - //auto legacy_hash = SHA256::sha256(password); - //if (legacy_hash == stored_hash) { - // auth_ok = true; - // Auto-migrate to PBKDF2 - // auto new_hash = PBKDF2Hasher::hash_password(password); - // std::string sql_migrate = "UPDATE Users SET password_hash=? WHERE email=?;"; - // auto stmt_mig = _SQLDB->stmt(sql_migrate); - // stmt_mig.bind_text(1, new_hash); - // stmt_mig.bind_text(2, username); - // stmt_mig.step(); - // 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"); - - // Update last_login - //std::string sql_update = "UPDATE Users SET last_login=datetime('now','localtime') WHERE email=?;"; - //auto stmt_upd = _SQLDB->stmt(sql_update); - //stmt_upd.bind_text(1, username); - //stmt_upd.step(); - // - // 新版调用db模块:values 中的 "$now" 哨兵由 db 日期适配层替换为服务端当前 UTC 时间 - //(此前因 db 无法处理日期列而注释,dbv2 增加 _coerce_value 后可用)。 - json::value jv_upd = { - {"action", "update"}, - {"table", "Users"}, - {"values", json::object{ - {"last_login", "$now"}, - }}, - {"where", json::object{ - {"username", username}, - }} - }; - auto jv_upd_r = DbC::call_msg(jv_upd); - if (!jv_upd_r.as_object()["success"].as_bool()) - THROW_EX_CUSTOM(HttpException, 2102, "Login failed, please try again later", 500, "/login"); - - - /* 生成令牌 */ - auto _PRIV = MyRSA::Private_Key::from_pem(_KEYGEN->get_private_key_pem()); - - /* 管理员判定:账号命中 [Admin] users 白名单即在 payload 中签发 is_admin, - * 下游服务(dnfs 等)直接读取该字段豁免所有权校验,无需各自维护名单 */ - bool is_admin = std::find(_ADMIN_USERS.begin(), _ADMIN_USERS.end(), username) != _ADMIN_USERS.end(); - - /* Token 吊销支持:签发新 jti + 当前版本号 */ - std::string jti = generate_jti(); - int user_ver = get_user_ver(username); - - json::value jv_t = { - {"user", username}, - {"jti", jti}, - {"ver", user_ver}, - {"iat", LocalTime::getCurrent()}, - {"created_at", LocalTime::getCurrent()}, - {"keep_time", _TOKEN_KEEP_TIME}, - {"is_admin", is_admin}, - {"refresh_count", 0}, - {"max_refresh", _TOKEN_MAX_REFRESH} - }; - std::string data = json::serialize(jv_t); - std::string auth = Base64::base64_encode(data); - std::string sign = _PRIV.Sign(data); - std::string token = auth + "_" + sign; - - - /* 生成返回json TODO: 专门写个类做这事 */ - json::value jv_rr = { - {"success", true}, - {"data", json::object{{ - "token", token}} - } - }; - res.set("Connection", "close"); - - /* 签发 HttpOnly Cookie(防 XSS 窃取 Token;JS 不再经手 Token) */ - { - std::string ck = "q4_token=" + token + - "; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=" + - std::to_string(_TOKEN_KEEP_TIME); - res.set("Set-Cookie", ck); - } - - logs().info("gateway", "登录成功: " + username, std::nullopt, - json::serialize(json::value{{"ip", client_ip}})); - - res.result(http::status::ok); - res.body() = json::serialize(jv_rr); - res.prepare_payload(); - return res; -} - -/* 退出登录 /logout:吊销当前 Token(加入 jti 黑名单)并清除 q4_token Cookie */ -http::response handleLogout( - const http::request& req, - const std::unordered_map& params) -{ - http::response res; - res.version(req.version()); - - /* 提取并吊销当前 Token:将 jti 加入黑名单(单设备退出) */ - std::string cookie_token; - std::string raw_token = extract_token_from_request(req, cookie_token); - if (!raw_token.empty()) { - TokenInfo ti = verify_token(raw_token); - if (ti.valid && !ti.jti.empty()) { - revoke_jti(ti.jti, ti.keep_time); - logs().info("gateway", "Token 已吊销: " + ti.username, std::nullopt, - json::serialize(json::value{{"jti_prefix", ti.jti.substr(0, 8)}})); - } - } - - res.set("Set-Cookie", - "q4_token=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"); - res.set("Connection", "close"); - res.result(http::status::ok); - res.body() = R"({"success": true, "message": "logged out"})"; - res.prepare_payload(); - return res; -} - -/* 当前用户身份查询 /whoami - * - * 验签 Token 后返回 user / is_admin / Token 生命周期信息(iat / created_at / keep_time)。 - * Token 来源:Authorization: Bearer 优先,回退 Cookie q4_token。 - * 无 Token 或验签失败返回 {"login": false},与 dnfs 旧接口契约一致。 */ -http::response handleWhoami( - const http::request& req, - const std::unordered_map& params) -{ - http::response res; - res.version(req.version()); - - res.set(http::field::content_type, "application/json"); - res.set("Connection", "close"); - - /* 提取 Token(Authorization 优先,回退 Cookie) */ - std::string cookie_token; - std::string raw_token = extract_token_from_request(req, cookie_token); - - if (raw_token.empty()) { - res.result(http::status::ok); - res.body() = R"({"login": false})"; - res.prepare_payload(); - return res; - } - - /* 统一验签(RSA + 过期 + jti黑名单 + 版本号) */ - TokenInfo ti = verify_token(raw_token); - if (!ti.valid) { - res.result(http::status::ok); - res.body() = R"({"login": false})"; - res.prepare_payload(); - return res; - } - - json::value jv_r = { - {"login", true}, - {"user", ti.username}, - {"is_admin", ti.is_admin} - }; - - /* 附加 Token 生命周期信息,供前端 Token 管理器估算过期时间 */ - if (!ti.iat.empty()) jv_r.as_object()["iat"] = ti.iat; - if (!ti.created_at.empty()) jv_r.as_object()["created_at"] = ti.created_at; - if (ti.keep_time > 0) jv_r.as_object()["keep_time"] = ti.keep_time; - - res.result(http::status::ok); - res.body() = json::serialize(jv_r); - res.prepare_payload(); - return res; -} - -/* 内部服务间管理员验签视图:POST /admin/verify - * - * 供 core 等内部服务调用,验证请求携带的 Token 是否为有效且未过期的管理员。 - * Token 来源:Authorization: Bearer 优先,回退 Cookie q4_token。 - * gateway 是 Token 签发与验证的唯一权威,core 通过本接口完成鉴权,避免密钥共享。 - * - * 响应:{ success, is_admin, user } - * - success=true, is_admin=true → 有效管理员(放行) - * - success=true, is_admin=false → 非管理员(拒绝) - * - success=false → 无 Token / 验签失败 / 过期(拒绝) - * 验签失败一律返回 HTTP 401(fail-closed),供内部服务快速判拒绝。 */ -http::response handleAdminVerify( - const http::request& req, - const std::unordered_map& params) -{ - http::response res; - res.version(req.version()); - res.set(http::field::content_type, "application/json"); - res.set("Connection", "close"); - - /* 提取 Token(Authorization 优先,回退 Cookie) */ - std::string cookie_token; - std::string raw_token = extract_token_from_request(req, cookie_token); - - if (raw_token.empty()) { - res.result(http::status::unauthorized); - res.body() = R"({"success": false, "is_admin": false})"; - res.prepare_payload(); - return res; - } - - /* 统一验签(RSA + 过期 + jti黑名单 + 版本号) */ - TokenInfo ti = verify_token(raw_token); - if (!ti.valid) { - res.result(http::status::unauthorized); - res.body() = R"({"success": false, "is_admin": false})"; - res.prepare_payload(); - return res; - } - - json::value jv_r = { - {"success", true}, - {"is_admin", ti.is_admin}, - {"user", ti.username} - }; - res.result(http::status::ok); - res.body() = json::serialize(jv_r); - res.prepare_payload(); - return res; -} - -/* 刷新 Token:POST /refresh_token - * - * 接受当前有效 Token(即使已过期但在宽限期内),签发新 Token。 - * 仅更新 created_at 与 refresh_count;iat 保持不变以限制整条链的总生命周期。 - * - * 宽限期:iat + keep_time * refresh_grace_multiplier - * 例:keep_time=3600, multiplier=2 → 首次签发后 2 小时内可刷新 - * 刷新上限:refresh_count < max_refresh - * 限流:5 次 / 5 分钟 / IP - * Token 来源:Authorization: Bearer 优先,回退 Cookie q4_token - * - * 响应:{ success, data:{ token, refresh_count, refreshes_remaining } } - */ -http::response handleRefreshToken( - const http::request& req, - const std::unordered_map& params) -{ - http::response res; - res.version(req.version()); - - /* IP 速率限制:5 次 / 300 秒 */ - 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 (!_REFRESH_TOKEN_RATE.check_and_record(client_ip, 5, 300)) - THROW_EX_CUSTOM(HttpException, 4001, "Too many refresh attempts. Please try again later.", - 429, "/refresh_token"); - - /* 获取并验证 Token(含 jti 黑名单 + 版本号检查) */ - std::string cookie_token; - std::string raw_token = extract_token_from_request(req, cookie_token); - if (raw_token.empty()) - THROW_EX_CUSTOM(HttpException, 4002, "No token provided", 400, "/refresh_token"); - - /* 正常过期(非吊销/版本错误)才允许宽限期刷新: - * - jti 在黑名单(logout/refresh后旧token)→ 401 拒绝 - * - 版本号落后(改密后旧token)→ 401 拒绝 - * 注意:verify_token 不区分"过期"和"吊销",统一返回 valid=false。 - * 我们需要区分"正常过期可刷新"和"被吊销不可刷新"。 - * 策略:先做 RSA+黑名单+版本号检查(通过则可刷新),过期检查在后面宽限期逻辑中处理。 */ - - // 重新做一次不含过期检查的验签(判断是否被吊销) - bool token_revoked = false; - std::string old_jti; - std::string username; - std::string iat_str; - int keep_time = _TOKEN_KEEP_TIME; - int refresh_count = 0; - int max_refresh = _TOKEN_MAX_REFRESH; - bool is_admin = false; - int user_ver = 1; - { - if (raw_token.find("_") == std::string::npos) - THROW_EX_CUSTOM(HttpException, 4002, "Invalid token format", 400, "/refresh_token"); - size_t idx2 = raw_token.find("_"); - std::string data = raw_token.substr(0, idx2); - std::string sign = raw_token.substr(idx2 + 1); - std::string auth; - try { auth = Base64::base64_decode_to_string(data); } - catch (const std::exception&) { - THROW_EX_CUSTOM(HttpException, 4002, "Invalid token format", 400, "/refresh_token"); - } - auto _PUBL = MyRSA::Public_Key::from_pem(_KEYGEN->get_public_key_pem()); - if (!_PUBL.Verify(auth, sign)) - THROW_EX_CUSTOM(HttpException, 4003, "Invalid token signature", 401, "/refresh_token"); - json::value jv_t; - try { jv_t = json::parse(auth); } - catch (const std::exception&) { - THROW_EX_CUSTOM(HttpException, 4004, "Bad token payload", 400, "/refresh_token"); - } - if (!jv_t.is_object()) - THROW_EX_CUSTOM(HttpException, 4004, "Bad token payload", 400, "/refresh_token"); - json::object& obj = jv_t.as_object(); - username = json::value_to(obj.at("user")); - iat_str = json::value_to(obj.at("iat")); - keep_time = json::value_to(obj.at("keep_time")); - refresh_count = json::value_to(obj.at("refresh_count")); - max_refresh = json::value_to(obj.at("max_refresh")); - auto it_admin = obj.find("is_admin"); - if (it_admin != obj.end() && it_admin->value().is_bool()) - is_admin = it_admin->value().as_bool(); - // 检查 jti 黑名单 - auto it_jti = obj.find("jti"); - if (it_jti != obj.end() && it_jti->value().is_string()) { - old_jti = json::value_to(it_jti->value()); - std::lock_guard lock(_TOKEN_REVOKE_MTX); - if (_JTI_DENYLIST.count(old_jti)) - token_revoked = true; - } - // 检查版本号 - auto it_ver = obj.find("ver"); - int token_ver = 1; - if (it_ver != obj.end() && it_ver->value().is_int64()) - token_ver = static_cast(it_ver->value().as_int64()); - { - std::lock_guard lock(_TOKEN_REVOKE_MTX); - auto it_uv = _USER_VER_MAP.find(username); - user_ver = (it_uv != _USER_VER_MAP.end()) ? it_uv->second : 1; - if (token_ver < user_ver) - token_revoked = true; - } - } - - if (token_revoked) - THROW_EX_CUSTOM(HttpException, 4003, "Token has been revoked. Please re-login.", - 401, "/refresh_token"); - - /* 刷新次数检查 */ - if (refresh_count >= max_refresh) - THROW_EX_CUSTOM(HttpException, 4005, "Refresh limit exceeded. Please re-login.", - 400, "/refresh_token"); - - /* 宽限期检查:iat + keep_time * grace_multiplier > now */ - auto iat_tp = LocalTime::parse(iat_str); - if (!iat_tp) - THROW_EX_CUSTOM(HttpException, 4004, "Bad token iat", 400, "/refresh_token"); - - auto now = std::chrono::system_clock::now(); - auto grace_expire = *iat_tp + std::chrono::seconds(keep_time * _TOKEN_REFRESH_GRACE_MULTIPLIER); - if (now > grace_expire) - THROW_EX_CUSTOM(HttpException, 4006, "Token expired beyond refresh window. Please re-login.", - 401, "/refresh_token"); - - /* 吊销旧 Token 的 jti(防止旧 refresh_token 被重放) */ - if (!old_jti.empty()) { - revoke_jti(old_jti, keep_time * _TOKEN_REFRESH_GRACE_MULTIPLIER); - } - - /* 签发新 Token:iat 不变,created_at 更新,refresh_count++,新 jti,当前版本号 */ - std::string new_jti = generate_jti(); - auto _PRIV = MyRSA::Private_Key::from_pem(_KEYGEN->get_private_key_pem()); - json::value jv_new = { - {"user", username}, - {"jti", new_jti}, - {"ver", user_ver}, - {"iat", iat_str}, - {"created_at", LocalTime::getCurrent()}, - {"keep_time", keep_time}, - {"is_admin", is_admin}, - {"refresh_count", refresh_count + 1}, - {"max_refresh", max_refresh} - }; - std::string new_data = json::serialize(jv_new); - std::string new_auth = Base64::base64_encode(new_data); - std::string new_sign = _PRIV.Sign(new_data); - std::string new_token = new_auth + "_" + new_sign; - - /* Set-Cookie */ - { - std::string ck = "q4_token=" + new_token + - "; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=" + - std::to_string(keep_time); - res.set("Set-Cookie", ck); - } - - logs().info("gateway", "Token refreshed", std::nullopt, - json::serialize(json::value{ - {"user", username}, - {"ip", client_ip}, - {"refresh_count", refresh_count + 1}, - {"refreshes_remaining", max_refresh - (refresh_count + 1)} - })); - - json::value jv_rr = { - {"success", true}, - {"data", json::object{ - {"token", new_token}, - {"refresh_count", refresh_count + 1}, - {"refreshes_remaining", max_refresh - (refresh_count + 1)} - }} - }; - res.set("Connection", "close"); - res.result(http::status::ok); - res.set(http::field::content_type, "application/json"); - res.body() = json::serialize(jv_rr); - res.prepare_payload(); - return res; -} - -/* 代理 captcha 图形验证码:GET /captcha/image - * - * captcha 服务为 pymsg/TCP(:8009),浏览器无法直接访问, - * 由本路由做 HTTP → pymsg 转换:调 image 路由拿到 base64 PNG 与 token, - * 解码后以 image/png + X-Captcha-Token 响应头返回(与旧直出接口契约一致)。 - */ -http::response handleCaptchaImage( - const http::request& req, - const std::unordered_map& params) -{ - http::response res; - res.version(req.version()); - - /* IP 级速率限制(P1-1 修复):单 IP 每分钟最多 10 次, - 防止单一攻击者打满全局限流导致全站验证码不可用 */ - 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 (!_CAPTCHA_IMAGE_RATE.check_and_record(client_ip, 10, 60)) - THROW_EX_CUSTOM(HttpException, 2403, - "Too many captcha requests. Please try again later.", 429, "/captcha/image"); - - /* 调 captcha 服务(pymsg/TCP),连接失败/超时视为 502 */ - pmc::net::MsgClient client(_CAPTCHA_HOST, _CAPTCHA_PORT, "gateway"); - std::string resp; - try { - resp = client.call(R"({"_route":"image"})", _TIMEOUT_CAPTCHA); - } catch (const std::exception& e) { - THROW_EX_CUSTOM(HttpException, 2400, "Captcha service unavailable", 502, "/captcha/image"); - } - - /* 解析响应(格式错误同样视为 502) */ - std::string token; - std::string png; - try { - auto jv = json::parse(resp); - auto& obj = jv.as_object(); - if (!obj.at("success").as_bool()) { - // P3-5: 依据 error 信息映射准确 HTTP 状态码 - std::string err_msg = json::value_to(obj.at("error")); - if (err_msg.find("rate_limit") != std::string::npos) - THROW_EX_CUSTOM(HttpException, 2401, "Too many captcha requests", 429, "/captcha/image"); - if (err_msg.find("store") != std::string::npos || err_msg.find("full") != std::string::npos) - THROW_EX_CUSTOM(HttpException, 2401, "Captcha service overloaded", 503, "/captcha/image"); - THROW_EX_CUSTOM(HttpException, 2401, "Captcha service error", 502, "/captcha/image"); - } - auto& data = obj.at("data").as_object(); - token = json::value_to(data.at("token")); - auto image_b64 = json::value_to(data.at("image")); - png = Base64::base64_decode_to_string(image_b64); - } catch (const HttpException&) { - throw; - } catch (const std::exception& e) { - THROW_EX_CUSTOM(HttpException, 2402, "Bad captcha response", 502, "/captcha/image"); - } - - res.set(http::field::content_type, "image/png"); - res.set("X-Captcha-Token", token); - res.set("Cache-Control", "no-store"); /* 验证码图片禁止缓存 */ - res.set("Connection", "close"); - res.result(http::status::ok); - res.body() = std::move(png); - res.prepare_payload(); - return res; -} - -/* 处理注册 /register */ -http::response handleRegister( - const http::request& req, - const std::unordered_map& params) -{ - http::response 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 (!_REGISTER_RATE.check_and_record(client_ip, 3, 600)) - THROW_EX_CUSTOM(HttpException, 3001, "Too many registration attempts. Please try again later.", - 429, "/register"); - - // parse the req body - json::value jv; - try { jv = json::parse(req.body()); } - catch (const boost::wrapexcept& e) { - THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/register"); - } - - if (!jv.is_object()) - THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/register"); - - auto& obj = jv.as_object(); - auto username = std::string(obj.at("username").as_string()); - auto password = std::string(obj.at("password").as_string()); - - // Strong password check - auto pw_result = PasswordValidator::validate(password); - if (!pw_result.valid) - THROW_EX_CUSTOM(HttpException, 3003, pw_result.message, 401, "/register"); - - // Email format check - std::regex email_regex(R"(^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$)"); - if (!std::regex_match(username, email_regex)) - THROW_EX_CUSTOM(HttpException, 3004, "Invalid email format", 400, "/register"); - - // CAPTCHA verification - auto captcha_it = obj.find("captcha_token"); - auto captcha_ans_it = obj.find("captcha_answer"); - if (captcha_it != obj.end() && captcha_ans_it != obj.end()) { - std::string ct = std::string(captcha_it->value().as_string()); - std::string ca = std::string(captcha_ans_it->value().as_string()); - if (!verify_captcha(ct, ca)) - 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); - - //std::string sql = "INSERT INTO Users (email, password_hash, last_login) VALUES (?, ?, datetime('now', 'localtime'));"; - //auto stmt = _SQLDB->stmt(sql); - //stmt.bind_text(1, username); - //stmt.bind_text(2, password_hash); - //stmt.step(); - // - // 新版调用db模块(通过 MsgClient pymsg TCP 协议) - // 【保留】旧版 HTTP 调用方式: - // auto jv_result = DbC::call_http(jv_insert); - json::value jv_insert = { - {"action", "insert"}, - {"table", "Users"}, - {"values", json::object{ - {"username", username}, - {"password_hash", password_hash} - }} - }; - auto jv_result = DbC::call_msg(jv_insert); - auto obj_r = jv_result.as_object(); - auto success = obj_r["success"].as_bool(); - if (!success) THROW_EX_CUSTOM(HttpException, 2202, "Registration failed, please try again later", 500, "/register"); - auto jv_r = json::value{{"success", success}}; - - logs().info("gateway", "注册成功: " + username, std::nullopt, - json::serialize(json::value{{"ip", client_ip}})); - - res.result(http::status::ok); - res.body() = json::serialize(jv_r); - res.prepare_payload(); - return res; -} - -/* 处理发送注册邮箱验证码 /register/send_code - * - * 经 pymsg 调 smtp 服务 send_email 路由,向目标邮箱发送 6 位验证码。 - * 限流 5 次/10 分钟/IP;smtp 不可用返回 502(fail-closed)。 */ -http::response handleSendEmailCode( - const http::request& req, - const std::unordered_map& params) -{ - http::response 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.", - 429, "/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"); - - // CAPTCHA 强制校验(防邮件轰炸:挡住自动化脚本,见审计报告 P0-1) - // captcha_token / captcha_answer 必填,经 captcha:8009 pymsg 校验,Token 一次性消费。 - // fail-closed:captcha 服务不可用或响应异常一律视为校验失败。 - auto captcha_token_it = obj.find("captcha_token"); - auto captcha_answer_it = obj.find("captcha_answer"); - if (captcha_token_it == obj.end() || captcha_answer_it == obj.end()) - THROW_EX_CUSTOM(HttpException, 3007, "CAPTCHA required", 400, "/register/send_code"); - if (!verify_captcha_remote(std::string(captcha_token_it->value().as_string()), - std::string(captcha_answer_it->value().as_string()))) - THROW_EX_CUSTOM(HttpException, 2201, "Invalid CAPTCHA", 400, "/register/send_code"); - - // 邮箱级双窗口限流(CAPTCHA 通过后才计入配额,避免无效请求消耗限流额度) - // 3 次/小时 + 10 次/天;IP 限流可被代理池绕过,邮箱维度是更可靠的限制。 - if (!_EMAIL_ADDR_RATE_HOUR.check_and_record(email, 3, 3600)) - THROW_EX_CUSTOM(HttpException, 3104, - "Too many code requests for this email. Please try again later.", - 429, "/register/send_code"); - if (!_EMAIL_ADDR_RATE_DAY.check_and_record(email, 10, 86400)) - THROW_EX_CUSTOM(HttpException, 3105, - "Daily email limit exceeded. Please try again tomorrow.", - 429, "/register/send_code"); - - // 调 smtp 服务(pymsg/TCP :8008)发送验证码 - bool sent = false; - int retry_after = 0; // smtp 同邮箱发送冷却的剩余秒数,0 表示非冷却拒绝 - std::string smtp_err; // 冷却拒绝时 smtp 返回的提示文案(仅冷却场景透传客户端) - try { - pmc::net::MsgClient client(_SMTP_HOST, _SMTP_PORT, "gateway"); - json::value jv_req = { - {"_route", "send_email"}, - {"email", email} - }; - 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()) { - 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(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) { - // 同邮箱发送冷却:按限流语义返回 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}})); - - 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; -} - -/* 发送重置密码邮箱验证码 /reset/send_code - * - * 与注册发码(/register/send_code)结构一致,区别在定制"重置密码"主题与文案: - * 经 pymsg 调 smtp 服务 send_email 路由(subject/content 字段覆盖默认注册文案)。 - * 限流 5 次/10 分钟/IP;CAPTCHA 强制校验;smtp 不可用返回 502(fail-closed)。 */ -http::response handleResetSendCode( - const http::request& req, - const std::unordered_map& params) -{ - http::response 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 (!_RESET_CODE_RATE.check_and_record(client_ip, 5, 600)) - THROW_EX_CUSTOM(HttpException, 3201, "Too many code requests. Please try again later.", - 429, "/reset/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, "/reset/send_code"); - } - - if (!jv.is_object()) - THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/reset/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, "/reset/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, "/reset/send_code"); - - // CAPTCHA 强制校验(防邮件轰炸,与 /register/send_code 一致) - // 经 captcha:8009 pymsg 校验,Token 一次性消费;fail-closed。 - auto captcha_token_it = obj.find("captcha_token"); - auto captcha_answer_it = obj.find("captcha_answer"); - if (captcha_token_it == obj.end() || captcha_answer_it == obj.end()) - THROW_EX_CUSTOM(HttpException, 3007, "CAPTCHA required", 400, "/reset/send_code"); - if (!verify_captcha_remote(std::string(captcha_token_it->value().as_string()), - std::string(captcha_answer_it->value().as_string()))) - THROW_EX_CUSTOM(HttpException, 2201, "Invalid CAPTCHA", 400, "/reset/send_code"); - - // 邮箱级双窗口限流(CAPTCHA 通过后才计入配额,避免无效请求消耗限流额度) - // 与注册发码共用同一配额,防止重置/注册双通道叠加绕过邮箱轰炸防护 - if (!_EMAIL_ADDR_RATE_HOUR.check_and_record(email, 3, 3600)) - THROW_EX_CUSTOM(HttpException, 3104, - "Too many code requests for this email. Please try again later.", - 429, "/reset/send_code"); - if (!_EMAIL_ADDR_RATE_DAY.check_and_record(email, 10, 86400)) - THROW_EX_CUSTOM(HttpException, 3105, - "Daily email limit exceeded. Please try again tomorrow.", - 429, "/reset/send_code"); - - // 调 smtp 服务(pymsg/TCP :8008)发送重置密码验证码(定制文案) - bool sent = false; - int retry_after = 0; // smtp 同邮箱发送冷却的剩余秒数,0 表示非冷却拒绝 - std::string smtp_err; // 冷却拒绝时 smtp 返回的提示文案(仅冷却场景透传客户端) - try { - pmc::net::MsgClient client(_SMTP_HOST, _SMTP_PORT, "gateway"); - json::value jv_req = { - {"_route", "send_email"}, - {"email", email}, - {"subject", "QSONT.me - Password Reset"}, - {"content", "您正在重置 QSONT.me 账号密码,验证码为 {code},10 分钟内有效。如非本人操作,请忽略本邮件。"} - }; - 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()) { - 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(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, "/reset/send_code"); - } - - 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, "/reset/send_code"); - THROW_EX_CUSTOM(HttpException, 3103, "Failed to send verification code", 500, "/reset/send_code"); - } - - logs().info("gateway", "重置密码验证码邮件发送成功: " + email, std::nullopt, - json::serialize(json::value{{"ip", client_ip}})); - - 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; -} - -/* 处理重置密码 /reset_password - * - * 与注册流程一致,最后一步由"创建用户"改为"更新密码": - * 校验顺序:密码强度 → 邮箱格式 → CAPTCHA(可选)→ 邮箱验证码(smtp verify_email,一次性) - * → 生成 PBKDF2 哈希 → UPDATE Users SET password_hash WHERE username - * 防枚举:用户不存在(update 匹配 0 行)时仍统一返回成功,不泄露邮箱是否已注册。 - * CAPTCHA 走 captcha:8009 远端校验(与前端 /captcha/image 的 token 匹配)。 - */ -http::response handleResetPassword( - const http::request& req, - const std::unordered_map& params) -{ - http::response 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 (!_RESET_PASSWORD_RATE.check_and_record(client_ip, 5, 600)) - THROW_EX_CUSTOM(HttpException, 3301, "Too many reset attempts. Please try again later.", - 429, "/reset_password"); - - // 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, "/reset_password"); - } - - if (!jv.is_object()) - THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/reset_password"); - - auto& obj = jv.as_object(); - auto username = std::string(obj.at("username").as_string()); - auto password = std::string(obj.at("password").as_string()); - - // Strong password check - auto pw_result = PasswordValidator::validate(password); - if (!pw_result.valid) - THROW_EX_CUSTOM(HttpException, 3003, pw_result.message, 401, "/reset_password"); - - // Email format check - std::regex email_regex(R"(^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$)"); - if (!std::regex_match(username, email_regex)) - THROW_EX_CUSTOM(HttpException, 3004, "Invalid email format", 400, "/reset_password"); - - // CAPTCHA verification(可选,与注册一致;走 captcha 服务远端校验) - auto captcha_it = obj.find("captcha_token"); - auto captcha_ans_it = obj.find("captcha_answer"); - if (captcha_it != obj.end() && captcha_ans_it != obj.end()) { - std::string ct = std::string(captcha_it->value().as_string()); - std::string ca = std::string(captcha_ans_it->value().as_string()); - if (!verify_captcha_remote(ct, ca)) - THROW_EX_CUSTOM(HttpException, 2201, "Invalid CAPTCHA", 400, "/reset_password"); - } - - // 邮箱验证码校验(smtp 服务,一次性消费;须放在所有本地校验通过之后、写库之前) - auto ecode_it = obj.find("email_code"); - if (ecode_it == obj.end()) - THROW_EX_CUSTOM(HttpException, 3005, "Email verification code required", 400, "/reset_password"); - 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, "/reset_password"); - - // generate the hash of password - auto password_hash = PBKDF2Hasher::hash_password(password); - - // 更新密码:UPDATE Users SET password_hash=? WHERE username=?(经 db 服务 pymsg execute) - json::value jv_update = { - {"action", "update"}, - {"table", "Users"}, - {"values", json::object{ - {"password_hash", password_hash} - }}, - {"where", json::object{ - {"username", username} - }} - }; - auto jv_result = DbC::call_msg(jv_update); - auto obj_r = jv_result.as_object(); - if (!obj_r["success"].as_bool()) - THROW_EX_CUSTOM(HttpException, 2202, "Password reset failed, please try again later", 500, "/reset_password"); - - /* 重置密码成功:吊销该用户所有已签发的 Token(版本号递增,全设备失效,需重新登录) */ - revoke_all_user_tokens(username); - - // 防枚举:用户不存在(rowcount=0)时仍统一返回成功,不泄露邮箱是否已注册 - logs().info("gateway", "重置密码成功: " + username, std::nullopt, - json::serialize(json::value{{"ip", client_ip}})); - - auto jv_r = json::value{{"success", true}}; - res.result(http::status::ok); - res.body() = json::serialize(jv_r); - res.prepare_payload(); - return res; -} - + * 认证职责已迁至 ca:gateway 不验签,仅做存在性不变量检查—— + * 显式携带 Target-Url 的受保护请求必须带有 ca 注入的 X-Auth-User 头, + * 缺失说明请求未经 ca(如直连 8445),一律 401。 + * X-Auth-User / X-Auth-Admin 头不在 is_special_header 剥离列表中,原样透传给下游。 */ http::response handleProxy( const http::request& req, const std::unordered_map& params, @@ -2335,7 +480,6 @@ http::response handleProxy( std::string t_url = ""; std::string url = ""; - std::string cookie_token = ""; /* 从 Cookie 提取的 Token(用于向下游注入 Authorization) */ bool is_public_target = false; { /* 大写的Target-Url */ @@ -2369,23 +513,22 @@ http::response handleProxy( url += "/"; url += target_path; - /* 提取 Token(Authorization 优先,回退 Cookie) */ - std::string raw_token = extract_token_from_request(req, cookie_token); - if (!is_public_target) { - /* 需要鉴权的目标:统一验签(RSA + 过期 + jti黑名单 + 版本号) */ - if (raw_token.empty()) - THROW_EX_CUSTOM(HttpException, 2301, "Unauthorization", 401, "*"); - - TokenInfo ti = verify_token(raw_token); - if (!ti.valid) { - if (ti.http_status == 400) - THROW_EX_CUSTOM(HttpException, 2302, "Bad token", 400, "*"); - THROW_EX_CUSTOM(HttpException, 2301, "Unauthorization", 401, "*"); + /* 存在性不变量(非验签):受保护请求必须携带 ca 注入的 X-Auth-User。 + * Beast name_string() 保留线上原始大小写,统一转小写比较 */ + bool has_auth_user = false; + for (auto const& field : req) { + std::string h; + h.reserve(field.name_string().size()); + for (unsigned char c : field.name_string()) + h.push_back(static_cast(std::tolower(c))); + if (h == "x-auth-user" && !field.value().empty()) { + has_auth_user = true; + break; + } } - } else { - /* 公开目标不做凭证校验,也不向下游注入 Authorization */ - cookie_token = ""; + if (!has_auth_user) + THROW_EX_CUSTOM(HttpException, 2301, "Unauthorization", 401, "*"); } } @@ -2400,7 +543,9 @@ http::response handleProxy( } if (!flag) { logs().warning("gateway", "代理目标不在白名单: " + t_url); - throw std::runtime_error("403"); + /* 不得抛 std::runtime_error:会被 wrapper 的 std::exception 分支 + * 兜底成 500,与文档宣称的 403 不符(审计 A2) */ + THROW_EX_CUSTOM(HttpException, 2303, "Forbidden: target not allowed", 403, "*"); } } @@ -2411,7 +556,7 @@ http::response handleProxy( res.body() += ret; }; - /* 复制(几乎)所有的请求头 */ + /* 复制(几乎)所有的请求头(含 ca 注入的 X-Auth-User / X-Auth-Admin / Authorization) */ auto client = HttpClient(); for (auto const& field: req) { @@ -2425,21 +570,16 @@ http::response handleProxy( // 主动设置可信的 X-Forwarded-For(P1-1) // 说明:is_special_header 已剥离客户端可能伪造的 XFF/X-Real-IP/Forwarded - // WAF → Gateway 为可信内网链路,收到的 XFF 即客户端真实 IP + // ca → gateway 为可信内网链路,收到的 XFF 即客户端真实 IP(由 waf/ca 链路注入) // 下游服务应信任此值做限流/审计,不再被客户端伪造 auto xff_it = req.base().find("X-Forwarded-For"); if (xff_it != req.base().end()) { client.set_header(std::string("X-Forwarded-For: ") + std::string(xff_it->value())); } else { - // WAF 未传 XFF 时,标记为 unknown,避免下游拿到空值 + // 上游未传 XFF 时,标记为 unknown,避免下游拿到空值 client.set_header("X-Forwarded-For: unknown"); } - /* Token 来自 Cookie 时,向下游补齐 Authorization 头(dnfs 等下游仍按 Bearer 解析,无需改动) */ - if (!cookie_token.empty()) { - client.set_header("Authorization: Bearer " + cookie_token); - } - /* 转发请求 */ if (method == "get") client.get_stream_sync(url, callback); @@ -2468,32 +608,18 @@ bool is_special_header(const std::string &header) { // - X-Forwarded-For / X-Real-IP / Forwarded // 剥离后由 gateway 主动设置可信值(见 handleProxy 转发逻辑) // 防止客户端伪造 IP 绕过下游限流/审计 - return header == "Host" || header == "connection" - || header == "Accept-Encoding" - || header == "X-Forwarded-For" - || header == "X-Real-IP" - || header == "Forwarded"; -} - -std::string get_cookie_value(const std::string& cookie_header, const std::string& name) { - // Cookie 头格式:"k1=v1; k2=v2; q4_token=xxx" - size_t pos = 0; - while (pos <= cookie_header.size()) { - size_t end = cookie_header.find(';', pos); - if (end == std::string::npos) end = cookie_header.size(); - std::string pair = cookie_header.substr(pos, end - pos); - size_t eq = pair.find('='); - if (eq != std::string::npos) { - std::string k = pair.substr(0, eq); - size_t b = k.find_first_not_of(" \t"); - size_t e = k.find_last_not_of(" \t"); - if (b != std::string::npos) k = k.substr(b, e - b + 1); - if (k == name) return pair.substr(eq + 1); - } - if (end == cookie_header.size()) break; - pos = end + 1; - } - return ""; + // A5 修复:Beast name_string() 保留线上原始大小写,比较前统一转小写, + // 否则小写 x-forwarded-for 等变体绕过剥离,与注入的可信 XFF 并存到达下游 + // 注意:x-auth-user / x-auth-admin 不在剥离列表——它们是 ca 注入的身份头,须透传给下游 + std::string h; + h.reserve(header.size()); + for (unsigned char c: header) + h.push_back(static_cast(std::tolower(c))); + return h == "host" || h == "connection" + || h == "accept-encoding" + || h == "x-forwarded-for" + || h == "x-real-ip" + || h == "forwarded"; } bool is_safe_target(const std::string& target) {