P0安全修复: WS token改subprotocol + nginx日志关闭 + 类型修复 + 降级验证 + 依赖

This commit is contained in:
Simon
2026-06-14 21:21:48 +08:00
parent edbb86835e
commit ddebbe61a5
12 changed files with 628 additions and 27 deletions
@@ -0,0 +1,64 @@
# workbuddy 评审反馈 — 2026-06-14 P0 安全止血
**推送内容**: WS token 鉴权改造 + 坐席本地密码 + secret 管理规划文档
**评审日期**: 2026-06-14
**评审人**: Claude
**主报告**: `D:\资料\03-项目开发\wecom_it_smart_desk\docs\评审报告\workbuddy-2026-06-14-P0安全.md`
**commit**: 3735dc0 (本地 main,未推 Gitea)
---
## ⭐ 给 workbuddy 的关键反馈(高优先级)
1. **🔴 浏览器 WebSocket API 不支持自定义 header** — 误用 Node.js `ws` 库的 options.headers
2. **🔴 nginx access_log 没关** — 即使前端修好,token 仍经 access_log 泄露
3. **🟡 Mapped[str] + nullable=True 类型不一致** — 改 Optional[str]
4. **🟡 企微降级放行仍能绕过 password 验证** — P0-#5 被反削弱
5. **🟡 requirements.txt 缺 passlib** — 部署会 ImportError
## 🔴 遗留 5 项(下一轮必修)
| # | 严重度 | 文件 | 修复要点 |
|---|---|---|---|
| 1 | 🔴 P0 | `frontend-agent/.../useWebSocket.ts:106-110` | 改 `new WebSocket(wsUrl, [\`bearer.${token}\`])` + 服务端从 `sec-websocket-protocol` 取 |
| 2 | 🔴 P0 | `nginx.conf` + `deploy-server/nginx.conf` | 加 `location /ws/ { access_log off; }` |
| 3 | 🟡 P1 | `backend/app/models/agent.py:142-148` | `Mapped[str]` → `Mapped[Optional[str]]` |
| 4 | 🟡 P1 | `backend/app/api/agents.py` 降级放行 | 检测 `agent.password_hash` 存在 → 强制 password |
| 5 | 🟡 P1 | `backend/requirements.txt` | 加 `passlib[bcrypt]==1.7.4` 或改用原生 `bcrypt==4.1.2` |
## 🟢 评审验收
- ✅ ws.py 服务端:header 优先 + query 降级,**逻辑正确**
- ✅ model 字段定义:`password_hash` String(128) nullable,**结构 OK**(类型注解除外)
- ✅ schema:`AgentLogin.password` + `AgentPasswordUpdate`,**OK**
- ✅ 改密端点 `POST /agents/password`:走 `Depends(get_current_agent)`,**OK**
- ✅ alembic 008:down_revision='007_role_system' 正确,**OK**
- ✅ docs/安全/secret-管理.md:**作为规划文档 OK**
## 📊 完成度
| 任务 | 完成 |
|---|---|
| P0-#1 WECOM_SECRET 集中化 | 🟡 仅规划文档 |
| P0-#2 SSL 私钥在仓 | 🟢 之前已修(8-A 阶段) |
| P0-#3 Mock login | 🟢 之前已修 |
| P0-#4 WS token URL/日志 | 🟡 半成品(服务端 OK,前端 + nginx 待关) |
| P0-#5 坐席本地密码 | 🟡 半成品(模型/Schema/端点 OK,类型 + 降级 + 依赖) |
**整体**: 2/5 P0 真正完成,3 项遗留待下一轮。
## 🔁 流程建议
- 推送前自检清单:
- [ ] 浏览器 WebSocket API 边界(不要用 `ws` 库的 options.headers)
- [ ] nginx/conf 改动 plan 写了就必须做
- [ ] Mapped[T] + nullable=True 必须用 Optional
- [ ] 改代码必须同步 requirements.txt
- [ ] 加新鉴权必须 review 已有降级路径是否被绕过
- **强烈建议**: workbuddy 推送前先回答"我的改动在浏览器侧能跑吗?"(不要假设 Node.js API = 浏览器 API)
## 🔗 推 Gitea 状态
- **本地 commit 3735dc0**: ✅ 已存
- **推 Gitea**: 🔴 卡 #8(MariaDB 套件未装)
- **下次**: Gitea 起来后 `git push -u origin main` 推 → workbuddy 拿 Gitea URL 二次评审
+193
View File
@@ -0,0 +1,193 @@
# 贡献指南 (CONTRIBUTING)
**适用范围**: 企微 IT 智能服务台 (`wecom_it_smart_desk`)
**维护者**: 宋献(项目负责人)+ Claude(评审协作)+ workbuddy(自动化开发)
**最后更新**: 2026-06-14
---
## 📌 仓库入口
- **Gitea(公网 Funnel**: `https://ds923plus.tail58d872.ts.net/simon/wecom_it_smart_desk`
- **Gitea(内网 LAN**: `http://100.85.152.112:8418/simon/wecom_it_smart_desk`
- **Tailscale 私网**: `100.85.152.112:8418`
---
## 🌿 分支模型
| 分支 | 用途 | 保护规则 |
|---|---|---|
| `main` | 稳定可发布版本 | 🔒 禁止直推,需 PR + 1 reviewer |
| `develop` | 主开发分支 | 🟡 允许 push |
| `feature/*` | 新功能(从 develop 拉) | 🟢 自由 |
| `hotfix/*` | 紧急修复(从 main 拉) | 🟢 自由,合入需评审 |
| `release/*` | 发布准备 | 🟡 自由,合入 main 需评审 |
**主分支**: `main`(默认推送目标)
---
## 📝 Commit 规范
**格式** (Conventional Commits):
```
<type>(<scope>): <subject>
<body>
<footer>
```
**type 取值**:
| type | 用途 | 示例 |
|---|---|---|
| `feat` | 新功能 | `feat(messages): 撤回消息端点` |
| `fix` | Bug 修复 | `fix(h5): 修复参与者权限校验` |
| `refactor` | 重构(无新功能 / 无 Bug 修复) | `refactor(agents): 提取鉴权中间件` |
| `docs` | 文档变更 | `docs: 评审报告 workbuddy-2026-06-14` |
| `chore` | 构建/工具/依赖 | `chore: 强化 .gitignore` |
| `security` | 安全相关 | `security: P0 鉴权止血` |
| `perf` | 性能优化 | `perf(messages): 消息批量插入` |
| `test` | 测试相关 | `test: 加 mark_read 鉴权测试` |
**scope 取值**: 模块名,如 `agents` / `messages` / `h5` / `frontend-agent` / `nginx` / `workbuddy`
**subject**: 中文,不超过 50 字,**祈使句**,如 "修复 xx" 而非 "修复了 xx"
**body** (可选): 详细说明,**每行 ≤ 72 字**
**footer** (可选): 关联 Issue / workbuddy 任务编号,如:
```
Refs: #18
Refs: workbuddy-2026-06-14-任务-修遗留
```
**示例**:
```
security(ws): WS token 从 URL 改 header 鉴权
【workbuddy 推送 2026-06-14】
- ws.py 服务端: 优先 Authorization: Bearer header, query 降级
- ws.ts 前端: 待 workbuddy 改 Sec-WebSocket-Protocol 方案
- 详见 docs/评审报告/workbuddy-2026-06-14-P0安全.md
Refs: #18
Refs: workbuddy-2026-06-14-任务-修遗留
```
---
## 🔄 PR 流程
### 推送前自检清单
**所有 P0 修复推送前必须 4 件套自检**:
- [ ] **鉴权**: 新增/修改端点是否有 `Depends(get_current_agent)``_get_current_employee`?
- [ ] **依赖**: 改代码是否同步 `requirements.txt` / `package.json`?
- [ ] **alembic**: 数据库 schema 变化是否生成迁移脚本?
- [ ] **配置**: nginx / docker / conf 变化 plan 写了是否做完?
### PR 流程
1. **本地开发**
```bash
git checkout develop
git pull
git checkout -b feature/xxx
# 改代码
git add .
git commit -m "feat(xxx): ..."
git push origin feature/xxx
```
2. **开 PR**(走 Gitea Web 或 API)
- 标题 = commit subject
- 描述 = body 内容 + 关联评审报告 / workbuddy 任务
- Reviewer: `simon`(主) + 可选 workbuddy auto-review
3. **评审员评审**(Gitea UI)
- 🟢 **P0 鉴权 / 安全**: 必须 Claude 评审 + 通过
- 🟡 **功能 / 重构**: 至少 1 reviewer 通过
- 🟢 **docs / chore**: 自审即可
4. **合并**
- 评审通过 + status check 绿 → squash merge → 删 feature 分支
---
## 🔒 main 分支保护规则
由 Gitea API 配置,目前设定:
| 项 | 值 |
|---|---|
| 禁止直推 | ✅ |
| 需 PR | ✅ |
| Approvals 数 | 1 |
| Dismiss stale approvals | ✅ |
| 状态检查必须通过 | ✅(待配) |
| 管理员限制 | ✅(管理员也走 PR) |
**配分支保护**:
```bash
curl -X POST \
-H "Authorization: token <ADMIN_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"enable_push": false,
"enable_pull_request": true,
"required_approvals": 1,
"dismiss_stale_approvals": true,
"block_admin_merge": true
}' \
"https://ds923plus.tail58d872.ts.net/api/v1/repos/simon/wecom_it_smart_desk/branch_protections/main"
```
---
## 🤖 workbuddy 推送规则
workbuddy 自动化开发,推送必须满足:
1. **完整自检**: 鉴权 + 依赖 + alembic + 配置 4 件套
2. **评审报告**: 每次推送**生成** `docs/评审报告/workbuddy-{日期}-{主题}.md`
3. **workbuddy 记忆更新**: `.workbuddy/memory/{日期}-{主题}.md`
4. **5 项遗留**: 上一轮评审遗留的 5 项必须修完才能合入下一轮
5. **不叠加新功能**: 评审未消化前不推新功能(见 `docs/评审报告/` 历次教训)
**评审失败处理**:
- 评审标 🔴 P0 → 立即修,不接受反驳(除非评审员改判)
- 评审标 🟡 P1 → 列入遗留表(workbuddy 记忆 + 风险跟踪表)
- 评审标 🟢 P2 → 知识库积累,不强制修
---
## 🆘 紧急修复 (hotfix)
**场景**: 生产 P0 漏洞 / 数据丢失风险
**流程**:
1. 从 main 拉 `hotfix/xxx`
2. 改 + 测(用预生产环境)
3. PR → main(快通道,reviewer 优先 @ 宋献)
4. 评审通过 → 立即合并 + 部署
5. 同步 cherry-pick 回 develop
**禁止**:
- ❌ 跳过评审
- ❌ 推 main 直接部署
- ❌ 评审未通过就部署
---
## 📚 关联文档
- [`README.md`](README.md) — 项目总览
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — 架构设计
- [`docs/风险跟踪表.md`](docs/风险跟踪表.md) — 22 项审计追踪
- [`docs/评审报告/`](docs/评审报告/) — workbuddy 推送评审
- [`.workbuddy/memory/`](.workbuddy/memory/) — workbuddy 任务记忆
+20
View File
@@ -190,3 +190,23 @@ wecom_it_smart_desk/
---
*最后更新:2026-06-03 - 合并文档,反映当前实际完成进度*
---
## 🏛️ 仓库与治理
- **Gitea 仓库(公网 Funnel**: `https://ds923plus.tail58d872.ts.net/simon/wecom_it_smart_desk`
- **Gitea 内网地址(LAN 加速)**: `http://100.85.152.112:8418/simon/wecom_it_smart_desk`
- **贡献指南**: [`CONTRIBUTING.md`](CONTRIBUTING.md) — 分支模型 + Commit 规范 + PR 流程
- **评审报告**: [`docs/评审报告/`](docs/评审报告/) — 历次 workbuddy 推送评审
- **风险跟踪表**: [`docs/风险跟踪表.md`](docs/风险跟踪表.md) — 22 项审计追踪
- **workbuddy 记忆**: [`.workbuddy/memory/`](.workbuddy/memory/) — workbuddy 启动读这里接任务
### 评审与提交约定
- 🔴 **所有 P0 鉴权修复必须走评审**`docs/评审报告/` 留档,含 workbuddy 推送)
- 🟡 **端点变更需 `Depends(get_current_agent)` 或 `_get_current_employee` 鉴权依赖**
- 🟡 **数据库 schema 变化必须 alembic 迁移**(无手动 ALTER
- 🟢 **workbuddy 推送前自检**: 鉴权 + 依赖 + alembic + 配置 4 件套
- 🟢 **任何部署包 / SSL 私钥 / 推送 token 不入仓**(见 `.gitignore`
+10 -4
View File
@@ -21,7 +21,7 @@ from uuid import UUID
import pyotp
import qrcode
import redis.asyncio as aioredis
from passlib.hash import bcrypt
import bcrypt # P1 修复: 直接使用 bcrypt 库替代 passlib
from fastapi import APIRouter, Depends, Header, Query, Request
from pydantic import BaseModel, Field
from slowapi import Limiter
@@ -217,13 +217,19 @@ async def agent_login(
logger.warning(
f"企微API不可达,已注册坐席降级放行: user_id={body.user_id}"
)
# P1 修复: 降级放行时,如果 agent 有 password_hash 则必须验证本地密码
if existing_agent and existing_agent.password_hash:
if not body.password:
raise AppException(1011, "请输入本地密码")
if not bcrypt.checkpw(body.password.encode('utf-8'), existing_agent.password_hash.encode('utf-8')):
raise AppException(1011, "本地密码错误")
# P0-#5: 本地密码认证(企微验证失败时的备用认证)
# 检查是否需要本地密码验证
local_password_verified = False
if body.password and agent and agent.password_hash:
# 验证本地密码
if bcrypt.verify(body.password, agent.password_hash):
if bcrypt.checkpw(body.password.encode('utf-8'), agent.password_hash.encode('utf-8')):
local_password_verified = True
logger.info(f"本地密码验证通过: user_id={body.user_id}")
else:
@@ -566,11 +572,11 @@ async def update_agent_password(
if agent.password_hash:
if not body.old_password:
raise AppException(1012, "请输入旧密码")
if not bcrypt.verify(body.old_password, agent.password_hash):
if not bcrypt.checkpw(body.old_password.encode('utf-8'), agent.password_hash.encode('utf-8')):
raise AppException(1013, "旧密码错误")
# 设置新密码
agent.password_hash = bcrypt.hash(body.new_password)
agent.password_hash = bcrypt.hashpw(body.new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
agent.updated_at = datetime.now()
db.add(agent)
await db.flush()
+28 -16
View File
@@ -67,17 +67,24 @@ async def websocket_endpoint(
request: Starlette Request(用于获取 header
"""
# ======================================================================
# WS-01: Token 认证(从 header query 获取)
# WS-01: Token 认证(从 subprotocol / header / query 获取)
# ======================================================================
# 步骤1: 优先从 Authorization header 获取 token,其次从 query(向后兼容)
# 格式: Authorization: Bearer {token}
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:] # 去掉 "Bearer " 前缀
# 步骤1: 优先从 Sec-WebSocket-Protocol (subprotocol) 获取 token,其次从 Authorization header,最后从 query(向后兼容)
# 格式: Sec-WebSocket-Protocol: bearer.{token}
# 说明: 浏览器原生 WebSocket API 不支持 headers 参数,但支持 subprotocols (第2参数数组)
# 前端用 new WebSocket(url, ["bearer.{token}"]) 传递,服务端从 sec-websocket-protocol 头读取
subprotocol = request.headers.get("sec-websocket-protocol", "")
if subprotocol.startswith("bearer."):
token = subprotocol[7:] # 去掉 "bearer." 前缀
else:
# 向后兼容:从 query param 获取(即将废弃)
token = request.query_params.get("token", "")
# 其次从 Authorization header 获取
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:] # 去掉 "Bearer " 前缀
else:
# 向后兼容:从 query param 获取(即将废弃)
token = request.query_params.get("token", "")
# 步骤2: 检查 token 是否为空
if not token:
@@ -222,17 +229,22 @@ async def h5_websocket_endpoint(
request: Starlette Request(用于获取 header
"""
# ======================================================================
# Token 认证(从 header query 获取)
# Token 认证(从 subprotocol / header / query 获取)
# ======================================================================
# 步骤1: 优先从 Authorization header 获取 token,其次从 query(向后兼容)
# 格式: Authorization: Bearer {token}
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:] # 去掉 "Bearer " 前缀
# 步骤1: 优先从 Sec-WebSocket-Protocol (subprotocol) 获取 token,其次从 Authorization header,最后从 query(向后兼容)
# 格式: Sec-WebSocket-Protocol: bearer.{token}
subprotocol = request.headers.get("sec-websocket-protocol", "")
if subprotocol.startswith("bearer."):
token = subprotocol[7:] # 去掉 "bearer." 前缀
else:
# 向后兼容:从 query param 获取(即将废弃)
token = request.query_params.get("token", "")
# 其次从 Authorization header 获取
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:] # 去掉 "Bearer " 前缀
else:
# 向后兼容:从 query param 获取(即将废弃)
token = request.query_params.get("token", "")
# 步骤2: 检查 token 是否为空
if not token:
+3 -1
View File
@@ -7,6 +7,7 @@
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, Integer, JSON, String
from sqlalchemy.orm import Mapped, mapped_column
@@ -141,7 +142,8 @@ class Agent(Base):
# 本地密码哈希(可选,用于本地密码认证)
# 使用 bcrypt 加密存储,不存储明文密码
# 当企微验证不可用时,可作为备用认证方式
password_hash: Mapped[str] = mapped_column(
# P1 修复: Mapped[Optional[str]] 解决严格模式下 None 赋值报错
password_hash: Mapped[Optional[str]] = mapped_column(
String(128),
nullable=True,
default=None,
+2
View File
@@ -70,6 +70,8 @@ python-dotenv==1.0.1
# --------------------------------------------------------------------------
# pyotp: TOTP/HOTP 动态码生成和验证(Google Authenticator 兼容)
pyotp==2.9.0
# bcrypt: 密码哈希库(用于本地密码认证)
bcrypt==4.1.2
# qrcode: 二维码生成(用于 OTP 绑定)
qrcode[pil]==7.4.2
# pillow: 图片处理(qrcode[pil] 依赖)
+1
View File
@@ -163,6 +163,7 @@ http {
# WebSocket — /ws/(坐席端实时通信)
# ------------------------------------------------------------------
location /ws/ {
access_log off; # P0-#4: 关闭 WS 路径日志,避免 token 泄露
proxy_pass http://backend_api;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
@@ -0,0 +1,245 @@
# 评审报告: workbuddy P0 安全止血推送
**推送日期**: 2026-06-14
**评审日期**: 2026-06-14
**评审人**: Claude
**关联 commit**: `3735dc0` — feat(security): P0 安全止血 - WS token 改 header + 坐席本地密码
**任务**: #10 P0 安全止血
**workbuddy 自报**: 完成
**本地验证结果**: 🟡 **部分完成,5 项遗留**
---
## ⭐ 一句话结论
workbuddy 推了 5 文件 + 2 新文件 / +263 -24 行,**2/5 P0 任务真正修好,3 项有遗留**(其中 1 项服务端代码 + 1 项前端代码 + 1 项 nginx),**需 workbuddy 下一轮修完才能算 P0 闭环**。
---
## 📊 任务清单 vs 完成度
| P0 # | 任务 | workbuddy 改动 | 真实状态 |
|---|---|---|---|
| P0-#4 | WS token 不在 URL/日志 | ws.py header 优先 + ws.ts 加 Authorization header + 漏 nginx | 🟡 **半成品** |
| P0-#5 | 坐席登录加 password | model + schema + agents.py + alembic 008 + 漏 requirements.txt + 漏降级放行 | 🟡 **半成品** |
| P0-#1 | WECOM_SECRET 集中化 | docs/安全/secret-管理.md(规划) | 🟡 **只规划未实改** |
| P0-#2 | SSL 私钥在仓 | (无) | 🟢 **8-A 阶段已修** |
| P0-#3 | Mock login | (无) | 🟢 **之前已修** |
---
## ✅ 已正确完成
### P0-#4 (服务端): `backend/app/api/ws.py`
- 优先从 `Authorization: Bearer {token}` header 取 token
- 降级从 `?token=` query param 取(向后兼容)
- 同步 `websocket_endpoint`(坐席端)+ `h5_websocket_endpoint`(H5 员工端)
- **服务端验收通过** ✅
### P0-#5 (模型层): `backend/app/models/agent.py` 字段定义
- `password_hash: Mapped[str] = mapped_column(String(128), nullable=True, default=None)`
- 字段长度 / 注释 / nullable 合理
- alembic 008 迁移脚本正确,依赖 007_role_system 存在
### P0-#5 (Schema): `backend/app/schemas/agent.py`
- `AgentLogin``password: Optional[str]` 字段
- `AgentPasswordUpdate` 单独定义(旧密码 + 新密码,6-128 位)
### P0-#5 (改密端点): `backend/app/api/agents.py` `/agents/password`
-`Depends(get_current_agent)` 鉴权 ✅
- 旧密码校验 + bcrypt 哈希 + 错误码 1011-1014 区分
- 结构 OK
### docs/安全/secret-管理.md (1.9 KB)
- WECOM_SECRET / WECOM_ENCODING_AES_KEY / DIFY_API_KEY / POSTGRES_PASSWORD / REDIS_PASSWORD 风险列表
- 4 种方案对比(NAS Vault / Server Keyring / Docker Secrets / HashiCorp Vault)
- 短期止血 + 长期迁移路径
---
## 🔴 遗留 5 项(严重度按序)
### 遗留 1: [P0-#4] ws.ts 浏览器 WebSocket API **不支持自定义 header** 🔴
**文件**: `frontend-agent/src/composables/useWebSocket.ts:106-110`
```ts
ws = new WebSocket(wsUrl, [], {
headers: {
Authorization: `Bearer ${agentStore.token}`,
},
})
```
**问题**: 浏览器原生 WebSocket 构造函数第 3 参数 options **没有 `headers` 字段**(只有 `protocols`)。**Chromium / Firefox / Safari 全部忽略 options.headers**,token 实际**未发送**。
**workbuddy 误用了 Node.js `ws` 库的 API**,浏览器侧完全无效。
**修复方向**(任选一种):
| 方案 | 服务端 | 前端 | 兼容性 |
|---|---|---|---|
| A. Sec-WebSocket-Protocol 携带 | 从 `request.headers['sec-websocket-protocol']` 取 | `new WebSocket(url, [\`bearer.${token}\`])` | 🟢 标准,全浏览器 |
| B. httpOnly cookie 携带 | 登录时 set-cookie,WS 握手带 cookie | 不变(浏览器自动带) | 🟢 需 HTTPS |
| C. 短 ticket 换 token(URL) | 服务端 token 换 ticket(短 TTL),WS 用 ticket | 先 POST /ws-ticket 拿 ticket | 🟢 实用,URL 带 ticket 非 token |
**推荐方案 A**(标准,无 cookie 复杂度,前端改动最小)。
### 遗留 2: [P0-#4] nginx access_log **没关闭** 🔴
**应改文件**:
- `nginx.conf`(根目录)
- `deploy-server/nginx.conf`
**计划文件阶段 10.1.1 明说要加**:
```nginx
location /ws/ {
access_log off;
}
```
**workbuddy 漏了**。**即便前端改造好,token 经过 nginx 时仍会写 access_log**(默认 `/var/log/nginx/access.log`),任何人能 tail 这个文件拿到历史 token。
### 遗留 3: [P0-#5] model `Mapped[str]` 类型 bug 🟡
**文件**: `backend/app/models/agent.py:142-148`
```python
password_hash: Mapped[str] = mapped_column(
String(128),
nullable=True,
default=None, # ← None 实际不能赋值给 str
comment="本地密码哈希(bcrypt",
)
```
**问题**: SQLAlchemy 2.0 strict 模式下 `Mapped[str]` + `nullable=True` + `default=None` 会**发出警告甚至报错**(`InvalidRequestError: Class does not support None`)。**实际跑起来可能挂**(取决于 strict 配置)。
**修复**: `Mapped[Optional[str]]` + 引用 `from typing import Optional`。
### 遗留 4: [P0-#5] 企微降级放行不强制 password 验证 🟡
**文件**: `backend/app/api/agents.py:236-243`
```python
local_password_verified = False
if body.password and agent and agent.password_hash:
if bcrypt.verify(body.password, agent.password_hash):
local_password_verified = True
else:
raise AppException(1011, "本地密码错误")
```
**问题**: 走 `local_password_verified` 后,**没有阻断企微 API 失败时的"降级放行"路径**(`agent_login` 之前在 `企微API不可达` 时会"已注册坐席降级放行",**不验 password**)。
**结果**: P0-#5 加了 password 字段,但**降级放行逻辑仍能绕过 password 验证** → **P0-#5 被反削弱**。
**修复**: 降级放行路径需检测 `agent.password_hash` 是否存在 → 存在则强制走 password 验证。
### 遗留 5: [P0-#5] requirements.txt 缺 passlib 依赖 🟡
**文件**: `backend/requirements.txt`
**问题**: workbuddy 改的 `agents.py` 用 `from passlib.hash import bcrypt`,但 `requirements.txt` **没加 passlib**。**生产部署会 ImportError**。
**修复**: 加 `passlib[bcrypt]==1.7.4`(或直接 `bcrypt==4.1.2` 不用 passlib,因 passlib 2024 已停维护)。
**建议**: 改用原生 `bcrypt` 库更稳:
```python
import bcrypt
bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt())
bcrypt.checkpw(password.encode('utf-8'), agent.password_hash.encode('utf-8'))
```
---
## 📁 变更清单(commit 3735dc0)
```
backend/app/api/agents.py | +67 -0
backend/app/api/ws.py | +30 -0
backend/app/models/agent.py | +10 -0
backend/app/schemas/agent.py | +7 -0
frontend-agent/src/composables/useWebSocket.ts | +5 -0
backend/alembic/versions/008_add_agent_password.py | +37(新)
docs/安全/secret-管理.md | +67(新)
7 files changed, 263 insertions(+), 24 deletions(-)
```
---
## 🎯 workbuddy 下一轮任务清单(高优先级)
按 5 项遗留严重度排:
### ▶▶▶ workbuddy 下一轮任务清单起
#### [P0] 1. 修 ws.ts:用 Sec-WebSocket-Protocol 方案
- **文件**: `frontend-agent/src/composables/useWebSocket.ts:103-112`
- **改**: `ws = new WebSocket(wsUrl, [\`bearer.${agentStore.token}\`])`
- **配套**: ws.py 服务端从 `request.headers.get('sec-websocket-protocol', '')` 取(取 `bearer.xxxx` 部分)
#### [P0] 2. 加 nginx access_log 关闭
- **文件**:
- `nginx.conf` (根)
- `deploy-server/nginx.conf`
- **改**: 找到 `location /api/`,加 `location /ws/ { access_log off; }` 在其前/后
#### [P1] 3. 修 model 类型注解
- **文件**: `backend/app/models/agent.py:142-148`
- **改**: `Mapped[str]` → `Mapped[Optional[str]]` + `from typing import Optional`
#### [P1] 4. 修降级放行必须 password 验证
- **文件**: `backend/app/api/agents.py` agent_login 流程
- **改**: 企微 API 不可达分支检测 `agent.password_hash` 存在 → 强制走 password 验证
#### [P1] 5. 加 passlib 依赖到 requirements.txt
- **文件**: `backend/requirements.txt`
- **改**: 加 `passlib[bcrypt]==1.7.4` 或 `bcrypt==4.1.2`
- **配套(可选)**: 改 `agents.py` 用原生 `bcrypt` 库
### ▼▼▼ workbuddy 下一轮任务清单止
---
## ⚠️ 评审流程教训
1. **WebSocket API 边界知识**: 浏览器侧 vs Node.js 侧 ws 库 API 差异,workbuddy 误用
2. **依赖检查漏**: 改代码必须同时改 requirements.txt(防止 ImportError)
3. **配置改动漏**: nginx/conf 改动 plan 写了但 workbuddy 没做(规划 vs 实施脱节)
4. **类型注解一致性**: Mapped[T] + nullable=True 必须用 Optional
5. **逻辑回归**: 加新鉴权时必须 review"已有降级路径是否被绕过"
---
## 📊 风险跟踪表更新建议
| 项 | 旧状态 | 新状态 |
|---|---|---|
| P0-#4 WS token URL 泄露 | 待修 | 🟡 半成品,前端 ws.ts 改造 + nginx access_log 待关 |
| P0-#5 坐席本地密码 | 待修 | 🟡 半成品,类型 bug + 降级放行 + 缺依赖 |
| P0-#1 WECOM_SECRET 集中化 | 待修 | 🟡 仅规划,无代码改动 |
| P0-#2 SSL 私钥 | 待修 | 🟢 已完成(8-A) |
| P0-#3 Mock login | 待修 | 🟢 已完成(之前) |
---
## 🔗 推 Gitea 状态
- **本地 commit**: 3735dc0 已存 ✅
- **推 Gitea**: 🔴 卡 #8 (MariaDB 套件未装)
- **下次**: Gitea 起来后 `git push -u origin main` 一次推送,workbuddy 拿 Gitea URL 二次评审
---
**下次评审窗口**: 等 workbuddy 修完 5 项遗留后,触发新一轮评审(本任务 #18)。
+57
View File
@@ -711,3 +711,60 @@ location /api/ {
| CR-9 (P0-5) | ✅ | M-13 (P2-2) | ⚠️ |
| CR-10 (P0-6) | ✅ | M-14 (P2-3) | ⚠️ |
| | | M-15 (P2-1) | ✅(捎带)|
---
## 第十节: 2026-06-14 P0 安全评估(workbuddy 推送 v2)
**关联 commit**: `3735dc0` — feat(security): P0 安全止血 - WS token 改 header + 坐席本地密码
**主报告**: `docs/评审报告/workbuddy-2026-06-14-P0安全.md`
**评审结论**: 🟡 **部分完成,5 项遗留**(3 项 P0 / 2 项 P1)
**workbuddy 下一轮任务**: #18
> 📌 第十节有独立小计(5 P0 + 2 P1,2 个新维度:WS token 鉴权 + 坐席本地密码)。
### 10.1 小计
| 维度 | 任务 | 真实状态 |
|---|---|---|
| P0-#1 | WECOM_SECRET 集中化 | 🟡 **只规划未实改** (`docs/安全/secret-管理.md`) |
| P0-#2 | SSL 私钥在仓 | 🟢 **8-A 阶段已修**(.gitignore `**` 模式) |
| P0-#3 | Mock login bypass | 🟢 **之前已修** |
| P0-#4 | WS token URL/日志泄露 | 🟡 **半成品**(服务端 OK,前端 ws.ts + nginx access_log 待关) |
| P0-#5 | 坐席本地密码 | 🟡 **半成品**(字段/Schema/端点 OK,类型 bug + 降级放行 + 缺依赖) |
**总评**: 2/5 P0 完成,3 项遗留待 workbuddy 下一轮修。
### 10.2 遗留项追踪(给 workbuddy 任务清单 #18)
| # | 严重度 | 文件 | 项 | 状态 |
|---|---|---|---|---|
| 遗留 1 | 🔴 P0 | `frontend-agent/src/composables/useWebSocket.ts:106-110` | 浏览器 WebSocket API 不支持自定义 header,改 Sec-WebSocket-Protocol | ⚠️ |
| 遗留 2 | 🔴 P0 | `nginx.conf` + `deploy-server/nginx.conf` | `location /ws/ { access_log off; }` | ⚠️ |
| 遗留 3 | 🟡 P1 | `backend/app/models/agent.py:142-148` | `Mapped[str]``Mapped[Optional[str]]` | ⚠️ |
| 遗留 4 | 🟡 P1 | `backend/app/api/agents.py` 降级放行 | 强制 password 验证 | ⚠️ |
| 遗留 5 | 🟡 P1 | `backend/requirements.txt` | 缺 passlib/bcrypt 依赖 | ⚠️ |
### 10.3 评审教训(防再犯)
1. **WebSocket API 边界**: 浏览器 vs Node.js `ws` 库 API 差异
2. **依赖检查**: 改代码必须同步 requirements.txt
3. **配置改动**: plan 写了的 nginx / conf 必须做
4. **类型一致性**: Mapped[T] + nullable=True 必须 Optional
5. **逻辑回归**: 新鉴权必须 review 已有降级路径
### 10.4 推 Gitea 状态
- **本地 commit**: 3735dc0 ✅
- **推 Gitea**: 🔴 **卡 #8**(MariaDB 套件未装)
- **下次**: Gitea 起来后 `git push -u origin main` 一次推送 → 触发 workbuddy 二次评审 → #18 闭环
### 10.5 第十节状态速查
| 编号 | 状态 |
|---|---|
| P0-#1 WECOM_SECRET 集中化 | 🟡 规划中(V1/V2) |
| P0-#2 SSL 私钥 | 🟢 8-A 完成 |
| P0-#3 Mock login | 🟢 完成 |
| P0-#4 WS token | 🟡 遗留 1+2 |
| P0-#5 坐席密码 | 🟡 遗留 3+4+5 |
@@ -103,12 +103,10 @@ export function useWebSocket() {
console.log(`[WebSocket] 正在连接: ${wsUrl}`)
ws = new WebSocket(wsUrl, [], {
// P0-#4: 将 token 放入 Authorization header(避免 URL 泄露)
headers: {
Authorization: `Bearer ${agentStore.token}`,
},
})
// P0-#4 修复: 用 Sec-WebSocket-Protocol (subprotocols) 传递 token
// 浏览器原生 WebSocket API 第2参数是 protocols (字符串数组),不是 headers
// 服务端从 sec-websocket-protocol 头读取 bearer.{token}
ws = new WebSocket(wsUrl, [`bearer.${agentStore.token}`])
// ----------------------------------------------------------------------
// 连接成功
+1
View File
@@ -137,6 +137,7 @@ http {
# WebSocket — /ws/(坐席端实时通信)
# ------------------------------------------------------------------
location /ws/ {
access_log off; # P0-#4: 关闭 WS 路径日志,避免 token 泄露
proxy_pass http://backend_api;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;