P0安全修复: WS token改subprotocol + nginx日志关闭 + 类型修复 + 降级验证 + 依赖
This commit is contained in:
@@ -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
@@ -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 任务记忆
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] 依赖)
|
||||
|
||||
@@ -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)。
|
||||
@@ -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}`])
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 连接成功
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user