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 - 合并文档,反映当前实际完成进度* *最后更新: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 pyotp
import qrcode import qrcode
import redis.asyncio as aioredis import redis.asyncio as aioredis
from passlib.hash import bcrypt import bcrypt # P1 修复: 直接使用 bcrypt 库替代 passlib
from fastapi import APIRouter, Depends, Header, Query, Request from fastapi import APIRouter, Depends, Header, Query, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from slowapi import Limiter from slowapi import Limiter
@@ -217,13 +217,19 @@ async def agent_login(
logger.warning( logger.warning(
f"企微API不可达,已注册坐席降级放行: user_id={body.user_id}" 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: 本地密码认证(企微验证失败时的备用认证) # P0-#5: 本地密码认证(企微验证失败时的备用认证)
# 检查是否需要本地密码验证 # 检查是否需要本地密码验证
local_password_verified = False local_password_verified = False
if body.password and agent and agent.password_hash: 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 local_password_verified = True
logger.info(f"本地密码验证通过: user_id={body.user_id}") logger.info(f"本地密码验证通过: user_id={body.user_id}")
else: else:
@@ -566,11 +572,11 @@ async def update_agent_password(
if agent.password_hash: if agent.password_hash:
if not body.old_password: if not body.old_password:
raise AppException(1012, "请输入旧密码") 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, "旧密码错误") 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() agent.updated_at = datetime.now()
db.add(agent) db.add(agent)
await db.flush() await db.flush()
+18 -6
View File
@@ -67,11 +67,18 @@ async def websocket_endpoint(
request: Starlette Request(用于获取 header request: Starlette Request(用于获取 header
""" """
# ====================================================================== # ======================================================================
# WS-01: Token 认证(从 header query 获取) # WS-01: Token 认证(从 subprotocol / header / query 获取)
# ====================================================================== # ======================================================================
# 步骤1: 优先从 Authorization header 获取 token,其次从 query(向后兼容) # 步骤1: 优先从 Sec-WebSocket-Protocol (subprotocol) 获取 token,其次从 Authorization header,最后从 query(向后兼容)
# 格式: Authorization: Bearer {token} # 格式: 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:
# 其次从 Authorization header 获取
auth_header = request.headers.get("Authorization", "") auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "): if auth_header.startswith("Bearer "):
token = auth_header[7:] # 去掉 "Bearer " 前缀 token = auth_header[7:] # 去掉 "Bearer " 前缀
@@ -222,11 +229,16 @@ async def h5_websocket_endpoint(
request: Starlette Request(用于获取 header request: Starlette Request(用于获取 header
""" """
# ====================================================================== # ======================================================================
# Token 认证(从 header query 获取) # Token 认证(从 subprotocol / header / query 获取)
# ====================================================================== # ======================================================================
# 步骤1: 优先从 Authorization header 获取 token,其次从 query(向后兼容) # 步骤1: 优先从 Sec-WebSocket-Protocol (subprotocol) 获取 token,其次从 Authorization header,最后从 query(向后兼容)
# 格式: Authorization: Bearer {token} # 格式: Sec-WebSocket-Protocol: bearer.{token}
subprotocol = request.headers.get("sec-websocket-protocol", "")
if subprotocol.startswith("bearer."):
token = subprotocol[7:] # 去掉 "bearer." 前缀
else:
# 其次从 Authorization header 获取
auth_header = request.headers.get("Authorization", "") auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "): if auth_header.startswith("Bearer "):
token = auth_header[7:] # 去掉 "Bearer " 前缀 token = auth_header[7:] # 去掉 "Bearer " 前缀
+3 -1
View File
@@ -7,6 +7,7 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, Integer, JSON, String from sqlalchemy import DateTime, Integer, JSON, String
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -141,7 +142,8 @@ class Agent(Base):
# 本地密码哈希(可选,用于本地密码认证) # 本地密码哈希(可选,用于本地密码认证)
# 使用 bcrypt 加密存储,不存储明文密码 # 使用 bcrypt 加密存储,不存储明文密码
# 当企微验证不可用时,可作为备用认证方式 # 当企微验证不可用时,可作为备用认证方式
password_hash: Mapped[str] = mapped_column( # P1 修复: Mapped[Optional[str]] 解决严格模式下 None 赋值报错
password_hash: Mapped[Optional[str]] = mapped_column(
String(128), String(128),
nullable=True, nullable=True,
default=None, default=None,
+2
View File
@@ -70,6 +70,8 @@ python-dotenv==1.0.1
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# pyotp: TOTP/HOTP 动态码生成和验证(Google Authenticator 兼容) # pyotp: TOTP/HOTP 动态码生成和验证(Google Authenticator 兼容)
pyotp==2.9.0 pyotp==2.9.0
# bcrypt: 密码哈希库(用于本地密码认证)
bcrypt==4.1.2
# qrcode: 二维码生成(用于 OTP 绑定) # qrcode: 二维码生成(用于 OTP 绑定)
qrcode[pil]==7.4.2 qrcode[pil]==7.4.2
# pillow: 图片处理(qrcode[pil] 依赖) # pillow: 图片处理(qrcode[pil] 依赖)
+1
View File
@@ -163,6 +163,7 @@ http {
# WebSocket — /ws/(坐席端实时通信) # WebSocket — /ws/(坐席端实时通信)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
location /ws/ { location /ws/ {
access_log off; # P0-#4: 关闭 WS 路径日志,避免 token 泄露
proxy_pass http://backend_api; proxy_pass http://backend_api;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; 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-9 (P0-5) | ✅ | M-13 (P2-2) | ⚠️ |
| CR-10 (P0-6) | ✅ | M-14 (P2-3) | ⚠️ | | CR-10 (P0-6) | ✅ | M-14 (P2-3) | ⚠️ |
| | | M-15 (P2-1) | ✅(捎带)| | | | 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}`) console.log(`[WebSocket] 正在连接: ${wsUrl}`)
ws = new WebSocket(wsUrl, [], { // P0-#4 修复: 用 Sec-WebSocket-Protocol (subprotocols) 传递 token
// P0-#4: 将 token 放入 Authorization header(避免 URL 泄露) // 浏览器原生 WebSocket API 第2参数是 protocols (字符串数组),不是 headers
headers: { // 服务端从 sec-websocket-protocol 头读取 bearer.{token}
Authorization: `Bearer ${agentStore.token}`, ws = new WebSocket(wsUrl, [`bearer.${agentStore.token}`])
},
})
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
// 连接成功 // 连接成功
+1
View File
@@ -137,6 +137,7 @@ http {
# WebSocket — /ws/(坐席端实时通信) # WebSocket — /ws/(坐席端实时通信)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
location /ws/ { location /ws/ {
access_log off; # P0-#4: 关闭 WS 路径日志,避免 token 泄露
proxy_pass http://backend_api; proxy_pass http://backend_api;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;