feat: captcha 改造为 pymsg/TCP 服务,gateway 新增图形码代理路由

captcha 参照 dbv2 统一为 pymsg 协议(image/verify/health 路由),与主干 RPC 栈对齐;gateway 新增 GET /captcha/image 做 HTTP→pymsg 转换,解码 base64 PNG 并以原契约(image/png + X-Captcha-Token)返回浏览器。
This commit is contained in:
qing committed 2026-07-28 09:54:08 +08:00
1 parent d06546f6bf
commit 44c1e5ef72
4 files changed
+307 -224

No files matched your search

+10 -8
View File
@@ -48,7 +48,7 @@
| [dnfs](dnfs/) | Python(Flask) | 8006 | 树状网络文件系统(Dendrachy Network File-System),基于父子 pid 的节点树增删改查,集成 AI 内容审核 | | [dnfs](dnfs/) | Python(Flask) | 8006 | 树状网络文件系统(Dendrachy Network File-System),基于父子 pid 的节点树增删改查,集成 AI 内容审核 |
| [db](db/) | Python(pymsg) | 8005 | 通用数据库服务,基于 pymsg 协议(TCP 长度前缀帧 + JSON)的 SQL-over-TCP,SQLAlchemy Core 参数化查询防注入;内置集中式日志收集(Logs 表 + 自动清理) | | [db](db/) | Python(pymsg) | 8005 | 通用数据库服务,基于 pymsg 协议(TCP 长度前缀帧 + JSON)的 SQL-over-TCP,SQLAlchemy Core 参数化查询防注入;内置集中式日志收集(Logs 表 + 自动清理) |
| [ai_review](ai_review/) | Python(Flask) | 8007 | AI 内容审核服务,DeepSeek LLM + ChromaDB RAG,支持人工反馈纠偏 | | [ai_review](ai_review/) | Python(Flask) | 8007 | AI 内容审核服务,DeepSeek LLM + ChromaDB RAG,支持人工反馈纠偏 |
| [captcha](captcha/) | Python(stdlib+PIL) | 8009 | 验证码服务,生成图形验证码(5 分钟过期、一次性),供 gateway 登录/注册可选启用 CAPTCHA 校验 | | [captcha](captcha/) | Python(pymsg) | 8009 | 验证码服务(pymsg/TCP),生成图形验证码(5 分钟过期、一次性),经 gateway 的 `GET /captcha/image` 代理给浏览器 |
| [smtp](smtp/) | Python(Flask) | 8008 | 邮件服务,发送/校验邮箱验证码(/send_email、/verify_email),注册流程的邮件验证码后端,SMTP 账号配置在 .env | | [smtp](smtp/) | Python(Flask) | 8008 | 邮件服务,发送/校验邮箱验证码(/send_email、/verify_email),注册流程的邮件验证码后端,SMTP 账号配置在 .env |
| [aibox](aibox/) | Python | 8765 | 独立 AI 智能体系统(DeepSeek + WebSocket),自带技能系统/记忆/任务调度,由 core 拉起但与主干微服务无调用关系 | | [aibox](aibox/) | Python | 8765 | 独立 AI 智能体系统(DeepSeek + WebSocket),自带技能系统/记忆/任务调度,由 core 拉起但与主干微服务无调用关系 |
@@ -68,14 +68,15 @@
- 极客操作:`kill -HUP <pid>` 热加载规则;`waf -list` 列规则;`waf -test <file>` 校验语法;`waf -check "<input>"` 测试单条输入。 - 极客操作:`kill -HUP <pid>` 热加载规则;`waf -list` 列规则;`waf -test <file>` 校验语法;`waf -check "<input>"` 测试单条输入。
- 配置见 [waf/config.yaml](waf/config.yaml),详见 [waf/README.md](waf/README.md)。 - 配置见 [waf/config.yaml](waf/config.yaml),详见 [waf/README.md](waf/README.md)。
### gateway(应用层网关 · v0.3.0-security) ### gateway(应用层网关 · v0.4.3)
- 内部 HTTP 服务,默认监听 127.0.0.1:8443,不再承载 TLS(TLS 已由 WAF 终止)。 - 内部 HTTP 服务,默认监听 127.0.0.1:8443,不再承载 TLS(TLS 已由 WAF 终止)。
- 通过 `Server.tls = on/off` 配置切换 HTTPS/HTTP 模式([example.ini](gateway/example.ini)),独立部署时仍可启用 TLS。 - 通过 `Server.tls = on/off` 配置切换 HTTPS/HTTP 模式([example.ini](gateway/example.ini)),独立部署时仍可启用 TLS。
- **密码安全**:PBKDF2-HMAC-SHA256(10 万轮 + 16 字节随机盐),密码强度校验(8+ 字符、4 类中至少 3 类),登录时自动迁移旧 SHA256 哈希,常数时间比较防 timing attack。 - **密码安全**:PBKDF2-HMAC-SHA256(10 万轮 + 16 字节随机盐),密码强度校验(8+ 字符、4 类中至少 3 类),常数时间比较防 timing attack。
- **用户存储**:用户数据通过 pymsg/TCP 调用 `db:8005`(`DbC::call_msg`)存取,不再直连本地 SQLite。 - **用户存储**:用户数据通过 pymsg/TCP 调用 `db:8005`(`DbC::call_msg`)存取,不再直连本地 SQLite。
- **Token 机制**:`Base64(payload) + "_" + RSA-Sign(payload)`,默认 1 小时过期,验签通过才放行;RSA 密钥对启动时内存生成,重启后旧 Token 全部失效。 - **Token 机制**:`Base64(payload) + "_" + RSA-Sign(payload)`,默认 1 小时过期,验签通过才放行;RSA 密钥对启动时内存生成,重启后旧 Token 全部失效。
- **CAPTCHA**:`/captcha` 接口生成验证码(5 分钟过期、一次性使用),登录与注册均可选启用。 - **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 计数。 - **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟,按 `X-Forwarded-For`(WAF 注入)客户端 IP 计数。
- **反向代理**:`Target-Url` 必须在 `Proxy.opened` 白名单中(`closed` 仅记录用途,实际隔离靠"不在 opened 中即拒绝"),缺省走 `Proxy.default`(不需 Token,服务前端静态资源)。 - **反向代理**:`Target-Url` 必须在 `Proxy.opened` 白名单中(`closed` 仅记录用途,实际隔离靠"不在 opened 中即拒绝"),缺省走 `Proxy.default`(不需 Token,服务前端静态资源)。
- **自动建表**:启动时若 `Users` 表不存在则自动创建,首位注册者即为管理员。 - **自动建表**:启动时若 `Users` 表不存在则自动创建,首位注册者即为管理员。
@@ -125,9 +126,10 @@
### captcha(验证码服务) ### captcha(验证码服务)
- 生成图形验证码,Token 一次性使用、5 分钟过期;纯标准库实现(http.server + PIL,无 Flask)。 - 生成图形验证码,Token 一次性使用、5 分钟过期;基于 [pymsg](pymsg/) 协议(TCP 长度前缀帧 + JSON),仅依赖 PIL。
- 接口:`GET /captcha/image`(生成 PNG,Token 在 `X-Captcha-Token` 响应头)、`POST /captcha/verify`(校验,body 含 token/answer)、`GET /health`。 - pymsg 路由:`image`(生成,返回 token + base64 PNG)、`verify`(校验,一次性消费)、`health`。
- 答案错误返回 `200 {valid:false}`(Token 照常消费),仅参数 / Token 错误返回 `400`;比较采用 XOR 防时序攻击。 - 答案比较采用 XOR 防时序攻击;错误答案与无效 Token 分别返回 `{valid:false}` 与 `{valid:false, error:...}`。
- **gateway 代理路由**:`GET /captcha/image` 经 pymsg 调 captcha 后解码 PNG,以 `image/png` + `X-Captcha-Token` 响应头返回浏览器(`Cache-Control: no-store`)。
- 由 `gateway` 在 `/login`、`/register` 中可选启用 CAPTCHA 校验(字段 `captcha_token` / `captcha_answer`)。 - 由 `gateway` 在 `/login`、`/register` 中可选启用 CAPTCHA 校验(字段 `captcha_token` / `captcha_answer`)。
- 内存存储,不支持多实例水平扩展(需换 Redis);详见 [captcha/README.md](captcha/README.md)。 - 内存存储,不支持多实例水平扩展(需换 Redis);详见 [captcha/README.md](captcha/README.md)。
@@ -171,7 +173,7 @@
| 8006 | dnfs | 127.0.0.1 | 树状菜单(经 gateway 代理) | | 8006 | dnfs | 127.0.0.1 | 树状菜单(经 gateway 代理) |
| 8005 | db | 127.0.0.1 | 数据库服务(pymsg/TCP,仅内部调用) | | 8005 | db | 127.0.0.1 | 数据库服务(pymsg/TCP,仅内部调用) |
| 8007 | ai_review | 127.0.0.1 | AI 审核(仅内部调用) | | 8007 | ai_review | 127.0.0.1 | AI 审核(仅内部调用) |
| 8009 | captcha | 127.0.0.1 | 验证码服务(stdlib http.server,独立组件) | | 8009 | captcha | 127.0.0.1 | 验证码服务(pymsg/TCP,经 gateway 代理) |
| 8008 | smtp | 127.0.0.1 | 邮件服务(仅内部调用,注册验证码邮件后端) | | 8008 | smtp | 127.0.0.1 | 邮件服务(仅内部调用,注册验证码邮件后端) |
| 8765 | aibox | 127.0.0.1 | AI 智能体(WebSocket,独立产品) | | 8765 | aibox | 127.0.0.1 | AI 智能体(WebSocket,独立产品) |
+75 -123
View File
@@ -1,24 +1,24 @@
# CAPTCHA 验证码服务 # CAPTCHA 验证码服务
一个轻量级的图片验证码服务,仅依赖 Python 标准库实现 HTTP(无需 Flask),配合 Pillow 生成验证码图片。提供图片生成、Token 校验、健康检查三个接口,可作为后端服务的验证码组件独立部署。 一个轻量级的图片验证码服务,基于自研 [pymsg](../pymsg/) 协议(TCP 长度前缀帧 + JSON),配合 Pillow 生成验证码图片。提供图片生成、Token 校验、健康检查三个路由,由 `gateway` 经 pymsg 调用并代理给浏览器。
服务由 `core`(PMC)的 `init.txt` 拉起,监听 `127.0.0.1:8009`,仅允许本机/内网调用;`gateway` 在登录与注册中可选地启用 CAPTCHA 校验(见 `gateway/README.md`)。 服务由 `core`(PMC)的 `init.txt` 拉起,监听 `127.0.0.1:8009`(TCP),仅允许本机/内网调用。
## 功能特性 ## 功能特性
- **图片验证码生成**:5 位字符(字母 + 数字),自动排除易混淆字符(`l I 1 O 0 o`) - **图片验证码生成**:5 位字符(字母 + 数字),自动排除易混淆字符(`l I 1 O 0 o`)
- **Token 机制**:每次生成图片返回唯一 Token(32 位十六进制),校验时**一次性消费**,防止重放 - **Token 机制**:每次生成图片返回唯一 Token(32 位十六进制),校验时**一次性消费**,防止重放
- **过期自动清理**:Token 默认 300 秒(5 分钟)失效;`/captcha/image` 请求时顺带清理过期项,`/captcha/verify` 时若 Token 已不在存储即判为失效 - **过期自动清理**:Token 默认 300 秒(5 分钟)失效;`image` 路由请求时顺带清理过期项
- **线程安全**:使用锁(`STORE_LOCK`)保护内存存储,支持并发请求 - **线程安全**:使用锁(`STORE_LOCK`)保护内存存储,支持并发请求
- **恒定时间比较**:答案校验使用逐字符异或(`diff |= ord(a) ^ ord(b)`),避免计时攻击 - **恒定时间比较**:答案校验使用逐字符异或(`diff |= ord(a) ^ ord(b)`),避免计时攻击
- **干扰元素**:随机字符颜色、垂直位置偏移、干扰线与噪点,提升机器识别难度 - **干扰元素**:随机字符颜色、垂直位置偏移、干扰线与噪点,提升机器识别难度
- **零框架依赖**:HTTP 层基于标准库 `http.server`,除 Pillow 外无第三方依赖 - **零框架依赖**:基于 pymsg(项目内置库),除 Pillow 外无第三方依赖
## 技术栈 ## 技术栈
- Python 3 - Python 3
- [Pillow](https://python-pillow.org/)(PIL)—— 图片绘制 - [Pillow](https://python-pillow.org/)(PIL)—— 图片绘制
- Python 标准库:`http.server`、`threading`、`secrets`、`json`、`urllib` - [pymsg](../pymsg/) —— TCP 长度前缀帧协议(`[4字节大端长度][UTF-8 JSON]`)
## 安装 ## 安装
@@ -35,158 +35,109 @@ pip install Pillow
cd captcha && ./_run.sh cd captcha && ./_run.sh
# 方式二:直接运行 # 方式二:直接运行
python3 captcha_service.py python3 captcha_service.py # 默认 127.0.0.1:8009
python3 captcha_service.py --port 8009 --addr 127.0.0.1
``` ```
服务启动后监听 `http://127.0.0.1:8009`。 ## 协议与路由
## 配置项 服务基于 pymsg 协议:每条消息为完整 JSON,`_route` 字段指定路由。响应格式与 db 服务一致:
所有可调参数均为 `captcha_service.py` 顶部的模块级常量:
| 常量 | 默认值 | 说明 |
| ------------- | ------------ | --------------------------------- |
| `CAPTCHA_LEN` | `5` | 验证码字符长度 |
| `CAPTCHA_TTL` | `300` | Token 有效期(秒) |
| `ADDR` | `127.0.0.1` | 监听地址 |
| `PORT` | `8009` | 监听端口 |
## API 接口
### 接口总览
| 方法 | 路径 | 说明 |
| ---- | ----------------- | --------------------------------------------- |
| GET | `/captcha/image` | 生成验证码图片,响应头下发 `X-Captcha-Token` |
| POST | `/captcha/verify` | 校验用户输入,一次性消费 Token |
| GET | `/health` | 健康检查 |
---
### 1. 获取验证码图片
```
GET /captcha/image
```
生成一组新验证码(5 位字符 + 对应 Token),返回 PNG 图片二进制流,并在响应头 `X-Captcha-Token` 中携带本次校验所需的 Token。
**请求**:无参数、无请求体。
**响应头**:
| 头字段 | 取值 | 说明 |
| ----------------- | --------------- | --------------------------------- |
| `Content-Type` | `image/png` | 图片格式 |
| `Content-Length` | 数值 | 图片字节数 |
| `X-Captcha-Token` | 32 位十六进制串 | 本次验证码的 Token,校验时必须回传 |
**响应体**:二进制 PNG 图片,尺寸 160 × 50。
**示例**:
```bash
curl -D - http://127.0.0.1:8009/captcha/image -o captcha.png
```
响应头示例:
```
Content-Type: image/png
Content-Length: 3210
X-Captcha-Token: 3f9a1c2b4d5e6f7a8b9c0d1e2f3a4b5c
```
> Token 有效期 300 秒,且**一次性使用**——无论校验成功与否,第一次 `/captcha/verify` 调用后即被消费。
---
### 2. 校验验证码
```
POST /captcha/verify
Content-Type: application/json
```
提交 `/captcha/image` 下发的 Token 与用户输入,比对答案(大小写不敏感)。**Token 在调用后被立即移除(一次性)。**
**请求体字段**:
| 字段 | 类型 | 必填 | 说明 |
| ------- | ------ | ---- | ------------------------------------------ |
| `token` | string | 是 | `GET /captcha/image` 响应头中的 `X-Captcha-Token` |
| `answer` | string | 是 | 用户输入的验证码文本,校验前统一转小写,大小写不敏感 |
**请求体示例**:
```json ```json
{ {"success": true, "data": {...}}
"token": "3f9a1c2b4d5e6f7a8b9c0d1e2f3a4b5c", {"success": false, "error": "..."}
"answer": "AB3XY" ```
}
| _route | 说明 |
| ------- | ------------------------------------------ |
| `image` | 生成验证码图片(base64 PNG + Token) |
| `verify` | 校验用户输入,一次性消费 Token |
| `health` | 健康检查 |
### 1. image — 生成验证码图片
**请求**:
```json
{"_route": "image"}
``` ```
**响应**: **响应**:
| HTTP 状态码 | 响应体 | 触发条件 | ```json
| ----------- | ------------------------------------------ | ---------------------------------------------- | {
| `200` | `{ "valid": true }` | Token 有效且答案正确 | "success": true,
| `200` | `{ "valid": false }` | Token 有效但**答案错误**(Token 已消费) | "data": {
| `400` | `{ "valid": false, "error": "invalid json" }` | 请求体非合法 JSON | "token": "3f9a1c2b4d5e6f7a8b9c0d1e2f3a4b5c",
| `400` | `{ "valid": false, "error": "token and answer required" }` | 缺少 `token` 或 `answer` 字段 | "image": "<base64 编码的 PNG,160×50>"
| `400` | `{ "valid": false, "error": "invalid or expired token" }` | Token 无效、已消费或已过期 | }
}
> ⚠️ **注意区分**:答案错误与 Token 错误是两种不同的结果。答案错误(Token 仍有效)返回 `200 {valid:false}`;而 Token 缺失/失效/过期会直接返回 `400`。消费方(如 gateway)应据此分别处理:前者提示"验证码错误",后者提示"请重新获取验证码"。
**示例**:
```bash
curl -X POST http://127.0.0.1:8009/captcha/verify \
-H 'Content-Type: application/json' \
-d '{"token":"3f9a1c2b4d5e6f7a8b9c0d1e2f3a4b5c","answer":"AB3XY"}'
``` ```
--- > Token 有效期 300 秒,且**一次性使用**——无论校验成功与否,第一次 `verify` 调用后即被消费。
### 3. 健康检查 ### 2. verify — 校验验证码
``` **请求**:
GET /health
```
**响应**(HTTP 200):
```json ```json
{ "status": "ok", "service": "captcha" } {"_route": "verify", "token": "3f9a1c2b4d5e6f7a8b9c0d1e2f3a4b5c", "answer": "AB3XY"}
``` ```
--- **响应**(均为 `success: true`,业务结果在 `data` 中区分):
| 响应 data | 触发条件 |
| ------------------------------------------------------------- | ------------------------------ |
| `{ "valid": true }` | Token 有效且答案正确 |
| `{ "valid": false }` | Token 有效但**答案错误**(Token 已消费) |
| `{ "valid": false, "error": "token and answer required" }` | 缺少 `token` 或 `answer` 字段 |
| `{ "valid": false, "error": "invalid or expired token" }` | Token 无效、已消费或已过期 |
> ⚠️ **注意区分**:答案错误与 Token 错误是两种不同的结果。消费方(如 gateway)应分别处理:前者提示"验证码错误",后者提示"请重新获取验证码"。
### 3. health — 健康检查
**请求**:`{"_route": "health"}`
**响应**:`{"success": true, "data": {"status": "ok", "service": "captcha"}}`
### 调用示例(Python)
```python
import json, sys
sys.path.insert(0, "../pymsg")
from pymsg import MsgClient
c = MsgClient("127.0.0.1", 8009, "my-service")
resp = json.loads(c.call(json.dumps({"_route": "image"}), timeout=5))
token = resp["data"]["token"]
png_b64 = resp["data"]["image"]
```
## 数据模型与 Token 生命周期 ## 数据模型与 Token 生命周期
- **存储**:进程内存字典 `captcha_store`,键为 Token,值为 `{ answer: <小写答案>, created_at: <Unix 时间戳> }`。 - **存储**:进程内存字典 `captcha_store`,键为 Token,值为 `{ answer: <小写答案>, created_at: <Unix 时间戳> }`。
- **生成**:`GET /captcha/image` 时写入一条记录,并返回对应 Token。 - **生成**:`image` 路由时写入一条记录,并返回对应 Token 与 base64 PNG。
- **消费**:`POST /captcha/verify` 时 `pop` 移除该 Token,无论答案对错,均不可再次使用。 - **消费**:`verify` 路由时 `pop` 移除该 Token,无论答案对错,均不可再次使用。
- **过期**:超过 `CAPTCHA_TTL`(300 秒)即视为失效。`/captcha/image` 会在每次生成时顺带清理过期项;`/captcha/verify` 时若 Token 不在存储中(过期、未生成或已消费),统一返回 `400 invalid or expired token`。 - **过期**:超过 `CAPTCHA_TTL`(300 秒)即视为失效。`image` 路由每次生成时顺带清理过期项;`verify` 时若 Token 不在存储中(过期、未生成或已消费),统一返回 `invalid or expired token`。
## 集成关系 ## 集成关系
- 由 `core`(`PMC`)通过 `init.txt` 中的 `bash /opt/micro_servers/captcha/_run.sh` 拉起并守护。 - 由 `core`(`PMC`)通过 `init.txt` 中的 `bash /opt/micro_servers/captcha/_run.sh` 拉起并守护。
- `gateway` 提供 **`GET /captcha/image`** 代理路由:经 pymsg 调本服务的 `image` 路由,将 base64 PNG 解码后以 `image/png` 响应体 + `X-Captcha-Token` 响应头返回给浏览器(`Cache-Control: no-store`)。
- `gateway` 在 `/login` 与 `/register` 中可选地启用 CAPTCHA 校验(以 `captcha_token` + `captcha_answer` 字段传入,见 `gateway/README.md`)。 - `gateway` 在 `/login` 与 `/register` 中可选地启用 CAPTCHA 校验(以 `captcha_token` + `captcha_answer` 字段传入,见 `gateway/README.md`)。
- 在集中式日志链路中,`captcha` 作为 `service` 名之一,可经 `log_client` SDK 推送日志至 `db` 的 `/log/ingest`。
## 典型使用流程 ## 典型使用流程
1. 客户端调用 `GET /captcha/image`,保存返回的图片与响应头中的 `X-Captcha-Token`。 1. 前端请求 `GET https://<host>/captcha/image`(经 waf → gateway → captcha),展示图片并保存 `X-Captcha-Token`。
2. 用户根据图片内容输入验证码文本。 2. 用户根据图片内容输入验证码文本。
3. 客户端调用 `POST /captcha/verify`,提交 `token` 与用户输入的 `answer`。 3. 前端在登录/注册请求中携带 `captcha_token` + `captcha_answer`。
4. 服务端比对答案(小写化、恒定时间比较),返回 `valid` 结果;Token 随即作废(无论对错)。 4. 服务端比对答案(小写化、恒定时间比较),Token 随即作废(无论对错)。
## 项目结构 ## 项目结构
``` ```
captcha/ captcha/
├── captcha_service.py # 服务主程序(HTTP 服务 + 验证码生成与校验) ├── captcha_service.py # 服务主程序(pymsg 服务 + 验证码生成与校验)
├── _run.sh # 启动脚本(cd 后 exec python3 captcha_service.py) ├── _run.sh # 启动脚本(cd 后 exec python3 captcha_service.py)
└── README.md └── README.md
``` ```
@@ -194,6 +145,7 @@ captcha/
## 注意事项 ## 注意事项
- 验证码存储在进程内存中,服务重启后所有 Token 失效;**不支持多实例水平扩展**(如需分布式部署,应将 `captcha_store` 替换为 Redis 等外部存储,并相应调整过期清理逻辑)。 - 验证码存储在进程内存中,服务重启后所有 Token 失效;**不支持多实例水平扩展**(如需分布式部署,应将 `captcha_store` 替换为 Redis 等外部存储,并相应调整过期清理逻辑)。
- 默认仅监听 `127.0.0.1`,仅供本机或内网调用;如需对外暴露,请配合反向代理(如 `gateway`/`waf`)并做好限流。 - 默认仅监听 `127.0.0.1`,仅供本机或内网调用;浏览器侧图片获取统一走 `gateway` 的 `GET /captcha/image` 代理。
- 答案校验不区分大小写;Token 一经校验即从内存中移除(一次性使用)。 - 答案校验不区分大小写;Token 一经校验即从内存中移除(一次性使用)。
- 字体缺失时自动回退到 PIL 默认字体,可能影响验证码观感,部署前建议确认 DejaVu 字体已安装。 - 字体缺失时自动回退到 PIL 默认字体,可能影响验证码观感,部署前建议确认 DejaVu 字体已安装。
- 旧版 HTTP 接口(http.server)已下线,协议与 db 服务统一为 pymsg/TCP。
+160 -92
View File
@@ -1,144 +1,212 @@
""" """
CAPTCHA Image Service (stdlib only, no Flask dependency) captcha — 基于 pymsg 协议(MsgServer)的图形验证码服务。
替代原 http.server HTTP 接口,通过 TCP 长度前缀帧收发 JSON。
协议:每条消息为完整 JSON,``_route`` 字段对应路由名。
请求示例::
{"_route": "image"} → 生成验证码图片
{"_route": "verify", "token": "...", "answer": "AB3XY"} → 校验
{"_route": "health"} → 健康检查
响应格式(与 dbv2 一致)::
{"success": true, "data": {...}}
{"success": false, "error": "..."}
说明:PNG 图片为二进制,经 base64 编码后放入 JSON 返回;
调用方(gateway)解码后以 image/png 代理给浏览器。
""" """
import os
import base64
import io import io
import json import json
import time import os
import random import random
import string
import secrets import secrets
import string
import threading import threading
from http.server import HTTPServer, BaseHTTPRequestHandler import time
from urllib.parse import urlparse, parse_qs
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
# pymsg 导入
import sys as _sys
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from pymsg import MsgServer # noqa: E402
# ── 配置 ────────────────────────────────────────────────────────────────
CAPTCHA_LEN = 5 CAPTCHA_LEN = 5
CAPTCHA_TTL = 300 CAPTCHA_TTL = 300 # Token 有效期(秒)
ADDR = '127.0.0.1'
PORT = 8009
CHARS = string.ascii_letters + string.digits CHARS = string.ascii_letters + string.digits
CHARS = CHARS.translate(str.maketrans('', '', 'lI1O0o')) CHARS = CHARS.translate(str.maketrans('', '', 'lI1O0o')) # 剔除易混淆字符
captcha_store = {} captcha_store = {} # token -> {'answer': str, 'created_at': float}
STORE_LOCK = threading.Lock() STORE_LOCK = threading.Lock()
# ── 验证码生成 ──────────────────────────────────────────────────────────
def generate_text(): def generate_text():
return ''.join(random.choices(CHARS, k=CAPTCHA_LEN)) return ''.join(random.choices(CHARS, k=CAPTCHA_LEN))
def generate_image(text): def generate_image(text):
width, height = 160, 50 width, height = 160, 50
bg = random.choice([(255,255,255), (240,240,240), (230,240,250)]) bg = random.choice([(255, 255, 255), (240, 240, 240), (230, 240, 250)])
img = Image.new('RGB', (width, height), bg) img = Image.new('RGB', (width, height), bg)
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
for i, ch in enumerate(text): for i, ch in enumerate(text):
color = random.choice([(30,30,30),(50,80,150),(150,50,50),(50,130,50)]) color = random.choice([(30, 30, 30), (50, 80, 150), (150, 50, 50), (50, 130, 50)])
y = random.randint(2, 12) y = random.randint(2, 12)
x = 10 + i * 28 + random.randint(-3, 3) x = 10 + i * 28 + random.randint(-3, 3)
try: try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
random.choice([28,30,32,34])) random.choice([28, 30, 32, 34]))
except (IOError, OSError): except (IOError, OSError):
font = ImageFont.load_default() font = ImageFont.load_default()
draw.text((x, y), ch, fill=color, font=font) draw.text((x, y), ch, fill=color, font=font)
for _ in range(4): for _ in range(4):
draw.line([(random.randint(0,width), random.randint(0,height)), draw.line([(random.randint(0, width), random.randint(0, height)),
(random.randint(0,width), random.randint(0,height))], (random.randint(0, width), random.randint(0, height))],
fill=random.choice([(30,30,30),(50,80,150)]), width=1) fill=random.choice([(30, 30, 30), (50, 80, 150)]), width=1)
for _ in range(50): for _ in range(50):
draw.point((random.randint(0,width-1), random.randint(0,height-1)), draw.point((random.randint(0, width - 1), random.randint(0, height - 1)),
fill=random.choice([(30,30,30),(50,80,150)])) fill=random.choice([(30, 30, 30), (50, 80, 150)]))
buf = io.BytesIO() buf = io.BytesIO()
img.save(buf, format='PNG') img.save(buf, format='PNG')
buf.seek(0) return buf.getvalue()
return buf
def json_response(handler, data, status=200): # ═══════════════════════════════════════════════════════════════════════
body = json.dumps(data).encode('utf-8') # CaptchaServer — 主服务类
handler.send_response(status) # ═══════════════════════════════════════════════════════════════════════
handler.send_header('Content-Type', 'application/json')
handler.send_header('Content-Length', str(len(body)))
handler.end_headers()
handler.wfile.write(body)
class CaptchaServer:
"""基于 pymsg 的验证码服务,通过 TCP 长连接收发 JSON。"""
class CaptchaHandler(BaseHTTPRequestHandler): def __init__(self, addr="127.0.0.1", port=8009):
def log_message(self, format, *args): self.addr = addr
pass self.port = port
self._server = MsgServer(addr, port)
def do_GET(self): # 路由表
parsed = urlparse(self.path) self._routes = {
if parsed.path == '/captcha/image': "image": self._handle_image,
text = generate_text() "verify": self._handle_verify,
token = secrets.token_hex(16) "health": self._handle_health,
with STORE_LOCK: }
captcha_store[token] = {'answer': text.lower(), 'created_at': time.time()}
now = time.time()
expired = [k for k, v in captcha_store.items() if now - v['created_at'] > CAPTCHA_TTL]
for k in expired:
del captcha_store[k]
img_buf = generate_image(text) # ── 启动 / 停止 ──────────────────────────────────────────────────
data = img_buf.read() def start(self) -> None:
self.send_response(200) """启动服务(阻塞当前线程)。"""
self.send_header('Content-Type', 'image/png') print(f"[captcha] 监听 tcp://{self.addr}:{self.port}")
self.send_header('Content-Length', str(len(data))) self._server.start(self._dispatch)
self.send_header('X-Captcha-Token', token)
self.end_headers() def stop(self) -> None:
self.wfile.write(data) """从另一线程停止服务。"""
elif parsed.path == '/health': self._server.stop()
json_response(self, {'status': 'ok', 'service': 'captcha'})
# ── 消息调度 -------------------------------------------------------
def _dispatch(self, sock, addr, text):
"""解析 JSON 中的 _route,分发给对应的 handler,并回复结果。"""
try:
req = json.loads(text)
except json.JSONDecodeError as e:
self._reply(sock, False, error=f"JSON 解析失败: {e}")
return
route = req.pop("_route", "")
handler = self._routes.get(route)
if handler is None:
self._reply(sock, False, error=f"未知路由: {route}")
return
try:
result = handler(req)
self._reply(sock, True, data=result)
except Exception as e:
self._reply(sock, False, error=str(e))
def _reply(self, sock, success, data=None, error=None):
resp = {"success": success}
if data is not None:
resp["data"] = data
if error is not None:
resp["error"] = error
self._server.reply(sock, json.dumps(resp, ensure_ascii=False))
# ═══════════════════════════════════════════════════════════════════
# 路由处理
# ═══════════════════════════════════════════════════════════════════
def _handle_image(self, _req):
"""生成验证码图片,返回 token 与 base64 编码的 PNG。"""
text = generate_text()
token = secrets.token_hex(16)
with STORE_LOCK:
captcha_store[token] = {'answer': text.lower(), 'created_at': time.time()}
# 惰性清理过期项
now = time.time()
expired = [k for k, v in captcha_store.items() if now - v['created_at'] > CAPTCHA_TTL]
for k in expired:
del captcha_store[k]
png_bytes = generate_image(text)
return {
"token": token,
"image": base64.b64encode(png_bytes).decode('ascii'),
}
def _handle_verify(self, req):
"""校验答案,Token 一次性消费;恒定时间比较防计时攻击。"""
token = str(req.get("token", "")).strip()
answer = str(req.get("answer", "")).strip().lower()
if not token or not answer:
return {"valid": False, "error": "token and answer required"}
with STORE_LOCK:
entry = captcha_store.pop(token, None)
if not entry:
return {"valid": False, "error": "invalid or expired token"}
expected = entry['answer']
if len(answer) != len(expected):
valid = False
else: else:
self.send_response(404) diff = 0
self.end_headers() for a, b in zip(answer, expected):
diff |= ord(a) ^ ord(b)
valid = diff == 0
def do_POST(self): return {"valid": valid}
parsed = urlparse(self.path)
if parsed.path == '/captcha/verify':
length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(length)
try:
data = json.loads(body)
except json.JSONDecodeError:
json_response(self, {'valid': False, 'error': 'invalid json'}, 400)
return
token = data.get('token', '').strip() def _handle_health(self, _req):
answer = data.get('answer', '').strip().lower() return {"status": "ok", "service": "captcha"}
if not token or not answer:
json_response(self, {'valid': False, 'error': 'token and answer required'}, 400)
return
with STORE_LOCK:
entry = captcha_store.pop(token, None)
if not entry:
json_response(self, {'valid': False, 'error': 'invalid or expired token'}, 400)
return
expected = entry['answer']
if len(answer) != len(expected):
valid = False
else:
diff = 0
for a, b in zip(answer, expected):
diff |= ord(a) ^ ord(b)
valid = diff == 0
json_response(self, {'valid': valid})
else:
self.send_response(404)
self.end_headers()
# ═══════════════════════════════════════════════════════════════════════
# 入口
# ═══════════════════════════════════════════════════════════════════════
if __name__ == '__main__': if __name__ == '__main__':
server = HTTPServer((ADDR, PORT), CaptchaHandler) import argparse
print(f'CAPTCHA service listening at http://{ADDR}:{PORT}')
server.serve_forever() parser = argparse.ArgumentParser(description="captcha — pymsg 验证码服务")
parser.add_argument("--addr", default="127.0.0.1", help="监听地址")
parser.add_argument("--port", type=int, default=8009, help="监听端口")
args = parser.parse_args()
server = CaptchaServer(args.addr, args.port)
try:
server.start()
except KeyboardInterrupt:
server.stop()
+62 -1
View File
@@ -26,7 +26,7 @@ using namespace qing;
namespace json = boost::json; namespace json = boost::json;
namespace po = boost::program_options; namespace po = boost::program_options;
constexpr const char *_VER = "0.4.2"; constexpr const char *_VER = "0.4.3";
////////////////////////////////// //////////////////////////////////
#include <sstream> #include <sstream>
@@ -295,6 +295,11 @@ http::response<http::string_body> handleLogout(
const http::request<http::string_body>& req, const http::request<http::string_body>& req,
const std::unordered_map<std::string, std::string>& params); const std::unordered_map<std::string, std::string>& params);
/* 代理 captcha 图形验证码:pymsg 调 captcha 服务,返回 PNG 图片 */
http::response<http::string_body> handleCaptchaImage(
const http::request<http::string_body>& req,
const std::unordered_map<std::string, std::string>& params);
/* 从 Cookie 头中解析指定名称的值(找不到返回空串) */ /* 从 Cookie 头中解析指定名称的值(找不到返回空串) */
std::string get_cookie_value(const std::string& cookie_header, const std::string& name); std::string get_cookie_value(const std::string& cookie_header, const std::string& name);
@@ -544,6 +549,12 @@ void registerRoutes(HttpServer &server) {
[](const auto& req, const auto& params) { return handleLogout(req, params); }); [](const auto& req, const auto& params) { return handleLogout(req, params); });
}); });
/* 代理 captcha 图形验证码(须在 get("*") 通配符之前注册) */
server.get("/captcha/image", [](const auto& req, const auto& params) -> http::response<http::string_body> {
return wrapperHttp(req, params,
[](const auto& req, const auto& params) { return handleCaptchaImage(req, params); });
});
server.get("*", [](const auto& req, const auto& params) -> http::response<http::string_body> { server.get("*", [](const auto& req, const auto& params) -> http::response<http::string_body> {
return wrapperHttp(req, params, return wrapperHttp(req, params,
[](const auto& req, const auto& params) { return handleProxy(req, params, "get"); }); [](const auto& req, const auto& params) { return handleProxy(req, params, "get"); });
@@ -820,6 +831,56 @@ http::response<http::string_body> handleLogout(
return res; return res;
} }
/* 代理 captcha 图形验证码:GET /captcha/image
*
* captcha 服务为 pymsg/TCP(:8009),浏览器无法直接访问,
* 由本路由做 HTTP → pymsg 转换:调 image 路由拿到 base64 PNG 与 token,
* 解码后以 image/png + X-Captcha-Token 响应头返回(与旧直出接口契约一致)。
*/
http::response<http::string_body> handleCaptchaImage(
const http::request<http::string_body>& req,
const std::unordered_map<std::string, std::string>& params)
{
http::response<http::string_body> res;
res.version(req.version());
/* 调 captcha 服务(pymsg/TCP),连接失败/超时视为 502 */
pmc::net::MsgClient client("127.0.0.1", 8009, "gateway");
std::string resp;
try {
resp = client.call(R"({"_route":"image"})", 5.0);
} 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())
THROW_EX_CUSTOM(HttpException, 2401, "Captcha service error", 502, "/captcha/image");
auto& data = obj.at("data").as_object();
token = json::value_to<std::string>(data.at("token"));
auto image_b64 = json::value_to<std::string>(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 */ /* 处理注册 /register */
http::response<http::string_body> handleRegister( http::response<http::string_body> handleRegister(
const http::request<http::string_body>& req, const http::request<http::string_body>& req,