feat(security): P0 安全止血 - WS token 改 header + 坐席本地密码
【workbuddy 推送 2026-06-14,任务 #10】 修复: - P0-#4 WS token 泄露:服务端 ws.py 优先从 Authorization: Bearer header 取, query param 仅作向后兼容降级路径(h5_websocket_endpoint 同) - P0-#5 坐席本地密码:Agent 模型加 password_hash 字段(bcrypt), 坐席登录增加 password 字段(企微验证失败时备用), 新增 POST /agents/password 端点修改密码, alembic 008 迁移脚本 新增/变更: M backend/app/api/agents.py (+67 行,登录 password 验证 + 改密端点) M backend/app/api/ws.py (~+30 行,header 优先 + query 降级) M backend/app/models/agent.py (+10 行,password_hash 字段) M backend/app/schemas/agent.py (+7 行,password 字段) M frontend-agent/.../useWebSocket.ts (+5 行,Authorization header) A backend/alembic/versions/008_add_agent_password.py A docs/安全/secret-管理.md (P0-#1 长期方案规划) 【评审遗留 5 项,详见 docs/评审报告/workbuddy-2026-06-14-P0安全.md】 - [P0-#4-ws.ts] 浏览器 WebSocket API 不支持自定义 header,需改 Sec-WebSocket-Protocol - [P0-#4-nginx] nginx access_log 没关闭,token 仍可能经 access_log 泄露 - [P0-#5-type] model Mapped[str] 严格模式下为 None 会报错,应改 Optional - [P0-#5-fall] 企微降级放行路径不强制 password 验证,反削弱 P0-#5 - [P0-#5-dep] requirements.txt 缺 passlib 依赖,部署会 ImportError 【推 Gitea】 卡 #8: MariaDB 套件未装,Gitea 未启动。本次 commit 暂存本地, Gitea 起来后一次 git push -u origin main 推送供 workbuddy 二次评审。
This commit is contained in:
+53
-23
@@ -19,7 +19,8 @@
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.services.ws_manager import manager as ws_manager
|
||||
from app.services.cache_service import cache_service
|
||||
@@ -38,32 +39,47 @@ WS_CLOSE_UNAUTHORIZED = 4001
|
||||
async def websocket_endpoint(
|
||||
websocket: WebSocket,
|
||||
agent_id: str,
|
||||
token: str = Query(default="", description="登录 token,用于 WebSocket 认证"),
|
||||
request: Request,
|
||||
) -> None:
|
||||
"""坐席 WebSocket 端点主循环(含 WS-01 token 认证)。
|
||||
|
||||
做什么:
|
||||
1. 验证 token 有效性(查 Redis)
|
||||
2. 验证 token 与 agent_id 一致性(防冒充)
|
||||
3. 认证通过后接受连接,注册到 ConnectionManager
|
||||
4. 进入消息接收循环,处理客户端发送的消息
|
||||
5. 连接断开时清理注册信息
|
||||
1. 从 Authorization header 获取 token(优先)或 query param(兼容)
|
||||
2. 验证 token 有效性(查 Redis)
|
||||
3. 验证 token 与 agent_id 一致性(防冒充)
|
||||
4. 认证通过后接受连接,注册到 ConnectionManager
|
||||
5. 进入消息接收循环,处理客户端发送的消息
|
||||
6. 连接断开时清理注册信息
|
||||
|
||||
为什么需要 token 认证(WS-01):
|
||||
- 之前 /ws/{agent_id} 无任何认证,任何人知道 URL 即可冒充任意坐席
|
||||
- 攻击者可监听所有消息、发送伪造消息,是 P0 级安全漏洞
|
||||
- 修复后,必须提供与 agent_id 匹配的有效 token 才能建立连接
|
||||
|
||||
安全改进(P0-#4):
|
||||
- 优先从 Authorization: Bearer {token} header 获取 token
|
||||
- 兼容从 ?token= URL 参数获取(向后兼容)
|
||||
- 不再将 token 暴露在 URL 中,避免 access_log 泄露
|
||||
|
||||
Args:
|
||||
websocket: FastAPI WebSocket 对象(框架自动注入)
|
||||
agent_id: 坐席ID(从 URL 路径参数获取)
|
||||
token: 登录 token(从 URL query parameter 获取)
|
||||
request: Starlette Request(用于获取 header)
|
||||
"""
|
||||
# ======================================================================
|
||||
# WS-01: Token 认证
|
||||
# WS-01: Token 认证(从 header 或 query 获取)
|
||||
# ======================================================================
|
||||
|
||||
# 步骤1: 检查 token 是否为空
|
||||
# 步骤1: 优先从 Authorization header 获取 token,其次从 query(向后兼容)
|
||||
# 格式: Authorization: Bearer {token}
|
||||
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:
|
||||
# 先 accept 再 close,否则客户端收不到关闭帧
|
||||
await websocket.accept()
|
||||
@@ -71,7 +87,7 @@ async def websocket_endpoint(
|
||||
logger.warning(f"WebSocket 拒绝连接: agent_id={agent_id}, 原因=缺少token")
|
||||
return
|
||||
|
||||
# 步骤2: 从 Redis 查询 token 对应的坐席信息
|
||||
# 步骤3: 从 Redis 查询 token 对应的坐席信息
|
||||
# Redis 中存储格式: agent:token:{token} -> agent_user_id
|
||||
# (与坐席登录 API /api/agents/login 存储格式一致)
|
||||
try:
|
||||
@@ -87,7 +103,7 @@ async def websocket_endpoint(
|
||||
)
|
||||
return
|
||||
|
||||
# 步骤3: 验证 token 与 agent_id 一致性
|
||||
# 步骤4: 验证 token 与 agent_id 一致性
|
||||
if not stored_agent_id:
|
||||
# token 不存在(已过期或伪造)
|
||||
await websocket.accept()
|
||||
@@ -174,22 +190,27 @@ async def websocket_endpoint(
|
||||
async def h5_websocket_endpoint(
|
||||
websocket: WebSocket,
|
||||
employee_id: str,
|
||||
token: str = Query(default="", description="H5员工登录 token,用于 WebSocket 认证"),
|
||||
request: Request,
|
||||
) -> None:
|
||||
"""H5员工 WebSocket 端点主循环(含 token 认证)。
|
||||
|
||||
做什么:
|
||||
1. 验证 employee token 有效性(查 Redis)
|
||||
2. 验证 token 与 employee_id 一致性(防冒充)
|
||||
3. 认证通过后接受连接,注册到 ConnectionManager 的员工连接表
|
||||
4. 进入消息接收循环,处理心跳 ping
|
||||
5. 连接断开时清理注册信息
|
||||
1. 从 Authorization header 获取 token(优先从)或 query param(兼容)
|
||||
2. 验证 employee token 有效性(查 Redis)
|
||||
3. 验证 token 与 employee_id 一致性(防冒充)
|
||||
4. 认证通过后接受连接,注册到 ConnectionManager 的员工连接表
|
||||
5. 进入消息接收循环,处理心跳 ping
|
||||
6. 连接断开时清理注册信息
|
||||
|
||||
为什么需要 H5 WS 连接:
|
||||
- H5员工需要实时接收参与者变更事件(新参与者加入、有人退出等)
|
||||
- 当前仅通过 3 秒轮询获取更新,实时性不足
|
||||
- WS 推送 + 轮询降级,双通道保证消息可达
|
||||
|
||||
安全改进(P0-#4):
|
||||
- 优先从 Authorization: Bearer {token} header 获取 token
|
||||
- 兼容从 ?token= URL 参数获取(向后兼容)
|
||||
|
||||
认证机制(与坐席端一致):
|
||||
- Redis 中存储格式: employee:token:{token} -> employee_id
|
||||
- (与H5登录 API /api/h5/mock-login 存储格式一致)
|
||||
@@ -198,20 +219,29 @@ async def h5_websocket_endpoint(
|
||||
Args:
|
||||
websocket: FastAPI WebSocket 对象(框架自动注入)
|
||||
employee_id: 员工企微 UserID(从 URL 路径参数获取)
|
||||
token: H5员工登录 token(从 URL query parameter 获取)
|
||||
request: Starlette Request(用于获取 header)
|
||||
"""
|
||||
# ======================================================================
|
||||
# Token 认证
|
||||
# Token 认证(从 header 或 query 获取)
|
||||
# ======================================================================
|
||||
|
||||
# 步骤1: 检查 token 是否为空
|
||||
# 步骤1: 优先从 Authorization header 获取 token,其次从 query(向后兼容)
|
||||
# 格式: Authorization: Bearer {token}
|
||||
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:
|
||||
await websocket.accept()
|
||||
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Missing token")
|
||||
logger.warning(f"H5 WebSocket 拒绝连接: employee_id={employee_id}, 原因=缺少token")
|
||||
return
|
||||
|
||||
# 步骤2: 从 Redis 查询 token 对应的员工信息
|
||||
# 步骤3: 从 Redis 查询 token 对应的员工信息
|
||||
# Redis 中存储格式: employee:token:{token} -> employee_id
|
||||
# (与H5登录 API /api/h5/mock-login 存储格式一致)
|
||||
try:
|
||||
@@ -226,7 +256,7 @@ async def h5_websocket_endpoint(
|
||||
)
|
||||
return
|
||||
|
||||
# 步骤3: 验证 token 与 employee_id 一致性
|
||||
# 步骤4: 验证 token 与 employee_id 一致性
|
||||
if not stored_employee_id:
|
||||
await websocket.accept()
|
||||
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Invalid or expired token")
|
||||
|
||||
Reference in New Issue
Block a user