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:
@@ -0,0 +1,38 @@
|
||||
"""add agent password_hash
|
||||
|
||||
Revision ID: 008_add_agent_password
|
||||
Revises: 007_role_system
|
||||
Create Date: 2026-06-14
|
||||
|
||||
P0-#5: 添加坐席本地密码哈希字段
|
||||
- 新增 password_hash 字段(可选,用于本地密码认证)
|
||||
- 使用 bcrypt 加密存储
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers
|
||||
revision = '008_add_agent_password'
|
||||
down_revision = '007_role_system'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""添加 password_hash 字段"""
|
||||
op.add_column(
|
||||
'agents',
|
||||
sa.Column(
|
||||
'password_hash',
|
||||
sa.String(128),
|
||||
nullable=True,
|
||||
comment='本地密码哈希(bcrypt)'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""删除 password_hash 字段"""
|
||||
op.drop_column('agents', 'password_hash')
|
||||
@@ -21,7 +21,9 @@ from uuid import UUID
|
||||
import pyotp
|
||||
import qrcode
|
||||
import redis.asyncio as aioredis
|
||||
from passlib.hash import bcrypt
|
||||
from fastapi import APIRouter, Depends, Header, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
from sqlalchemy import select
|
||||
@@ -216,6 +218,18 @@ async def agent_login(
|
||||
f"企微API不可达,已注册坐席降级放行: user_id={body.user_id}"
|
||||
)
|
||||
|
||||
# P0-#5: 本地密码认证(企微验证失败时的备用认证)
|
||||
# 检查是否需要本地密码验证
|
||||
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
|
||||
logger.info(f"本地密码验证通过: user_id={body.user_id}")
|
||||
else:
|
||||
# 本地密码错误,拒绝登录
|
||||
raise AppException(1011, "本地密码错误")
|
||||
|
||||
# 1. 查找或创建坐席记录
|
||||
stmt = select(Agent).where(Agent.user_id == body.user_id)
|
||||
result = await db.execute(stmt)
|
||||
@@ -517,3 +531,56 @@ async def unbind_agent_otp(
|
||||
except Exception as e:
|
||||
logger.error(f"OTP解绑异常: {e}", exc_info=True)
|
||||
raise AppException(1010, f"OTP解绑失败: {str(e)}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 本地密码管理接口(P0-#5)
|
||||
# --------------------------------------------------------------------------
|
||||
class AgentPasswordUpdate(BaseModel):
|
||||
"""坐席修改密码请求 Schema"""
|
||||
old_password: Optional[str] = Field(None, description="旧密码(验证用)")
|
||||
new_password: str = Field(..., min_length=6, max_length=128, description="新密码")
|
||||
|
||||
|
||||
@router.post("/agents/password")
|
||||
async def update_agent_password(
|
||||
body: AgentPasswordUpdate,
|
||||
agent: Agent = Depends(get_current_agent),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""修改坐席本地密码。
|
||||
|
||||
可选功能,允许坐席设置本地密码作为备用认证方式。
|
||||
企微验证失败时,可使用本地密码认证。
|
||||
|
||||
P0-#5 新增端点。
|
||||
|
||||
Args:
|
||||
body.new_password: 新密码(6-128位)
|
||||
|
||||
Returns:
|
||||
Dict: 修改结果
|
||||
"""
|
||||
try:
|
||||
# 如果已有旧密码,验证旧密码
|
||||
if agent.password_hash:
|
||||
if not body.old_password:
|
||||
raise AppException(1012, "请输入旧密码")
|
||||
if not bcrypt.verify(body.old_password, agent.password_hash):
|
||||
raise AppException(1013, "旧密码错误")
|
||||
|
||||
# 设置新密码
|
||||
agent.password_hash = bcrypt.hash(body.new_password)
|
||||
agent.updated_at = datetime.now()
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"本地密码已更新: agent={agent.user_id}")
|
||||
|
||||
return success_response(data={"message": "密码已更新"})
|
||||
|
||||
except AppException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"密码更新异常: {e}", exc_info=True)
|
||||
raise AppException(1014, f"密码更新失败: {str(e)}")
|
||||
|
||||
+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")
|
||||
|
||||
@@ -138,6 +138,16 @@ class Agent(Base):
|
||||
comment="OTP是否启用(0=否, 1=是)",
|
||||
)
|
||||
|
||||
# 本地密码哈希(可选,用于本地密码认证)
|
||||
# 使用 bcrypt 加密存储,不存储明文密码
|
||||
# 当企微验证不可用时,可作为备用认证方式
|
||||
password_hash: Mapped[str] = mapped_column(
|
||||
String(128),
|
||||
nullable=True,
|
||||
default=None,
|
||||
comment="本地密码哈希(bcrypt)",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""坐席对象的字符串表示,方便调试。"""
|
||||
return (
|
||||
|
||||
@@ -26,16 +26,23 @@ class AgentLogin(BaseModel):
|
||||
第一步使用简单的用户名密码登录。
|
||||
user_id 对应企微通讯录中的 UserID。
|
||||
admin 角色需要 OTP 二次验证。
|
||||
可选本地密码认证(企微验证失败时的备用认证)。
|
||||
|
||||
P0-#5 改动:
|
||||
- 新增 password 字段:本地密码(可选)
|
||||
- 企微主路径优先 → 本地 password 双因子(新增)
|
||||
|
||||
Attributes:
|
||||
user_id: 企微用户ID
|
||||
name: 坐席姓名
|
||||
otp_code: OTP 动态码(admin 角色必填)
|
||||
password: 本地密码(企微验证失败时的备用认证)
|
||||
"""
|
||||
|
||||
user_id: str = Field(..., min_length=1, max_length=64, description="企微用户ID")
|
||||
name: str = Field(..., min_length=1, max_length=128, description="坐席姓名")
|
||||
otp_code: Optional[str] = Field(None, min_length=6, max_length=6, description="OTP动态码(6位数字)")
|
||||
password: Optional[str] = Field(None, description="本地密码(可选)")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# IT智能服务台 - Secret 管理方案
|
||||
|
||||
**版本**: 1.0
|
||||
**更新日期**: 2026-06-14
|
||||
**状态**: 规划中
|
||||
|
||||
---
|
||||
|
||||
## 一、背景
|
||||
|
||||
当前 `.env` 文件中存储了敏感信息:
|
||||
- WECOM_SECRET(企微应用密钥)
|
||||
- WECOM_ENCODING_AES_KEY(消息加密密钥)
|
||||
- DIFY_API_KEY(Dify API 密钥)
|
||||
- POSTGRES_PASSWORD(数据库密码)
|
||||
- REDIS_PASSWORD(Redis 密码)
|
||||
|
||||
**风险**:
|
||||
- `.env` 文件在 Git 仓库中(虽然被 .gitignore 排除,但部署时需手动复制)
|
||||
- 服务器上 `.env` 文件可能被未授权访问
|
||||
- 密钥轮换需要手动修改文件和重启服务
|
||||
|
||||
---
|
||||
|
||||
## 二、长期方案
|
||||
|
||||
### 方案对比
|
||||
|
||||
| 方案 | 复杂度 | 安全性 | 适用场景 |
|
||||
|------|--------|--------|----------|
|
||||
| **NAS Vault** | 低 | 中 | 有 NAS设备 |
|
||||
| **Server Keyring** | 低 | 中 | Linux 服务器 |
|
||||
| **Docker Secrets** | 中 | 高 | K8s/ Swarm |
|
||||
| **HashiCorp Vault** | 高 | 高 | 企业级 |
|
||||
|
||||
### 推荐:NAS Vault + Server Keyring
|
||||
|
||||
#### 方案1:NAS Vault(当前可用)
|
||||
|
||||
```bash
|
||||
# 在 NAS 上创建加密文件
|
||||
/volume1/docker/wecom-it-desk/secrets/.env.encrypted
|
||||
|
||||
# 启动时解密
|
||||
docker run --env-file <(gpg -d /volume1/docker/wecom-it-desk/secrets/.env.encrypted) ...
|
||||
```
|
||||
|
||||
#### 方案2:Server Keyring(Linux)
|
||||
|
||||
```bash
|
||||
# 使用 keyring 工具
|
||||
keyring set wecom-it-desk WECOM_SECRET
|
||||
keyring get wecom-it-desk WECOM_SECRET
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、短期止血
|
||||
|
||||
| 操作 | 说明 |
|
||||
|------|------|
|
||||
| 限制 .env 文件权限 | `chmod 600 .env` |
|
||||
| 不在 URL 中暴露 token | 已完成(P0-#4) |
|
||||
| 定期轮换密钥 | 建议每季度 |
|
||||
| 审计日志 | 规划中 |
|
||||
|
||||
---
|
||||
|
||||
## 四、实施计划
|
||||
|
||||
| 阶段 | 内容 | 优先级 |
|
||||
|------|------|--------|
|
||||
| MVP | 当前方案 + 限制文件权限 | P0 |
|
||||
| V1 | 迁移到 NAS Vault | P2 |
|
||||
| V2 | 迁移到 HashiCorp Vault | P3 |
|
||||
|
||||
---
|
||||
|
||||
## 五、相关文档
|
||||
|
||||
- `.env.example` - 环境变量模板(含 TODO 注释)
|
||||
- `docs/安全审计报告.md` - 安全审计记录
|
||||
@@ -103,7 +103,12 @@ export function useWebSocket() {
|
||||
|
||||
|
||||
console.log(`[WebSocket] 正在连接: ${wsUrl}`)
|
||||
ws = new WebSocket(wsUrl)
|
||||
ws = new WebSocket(wsUrl, [], {
|
||||
// P0-#4: 将 token 放入 Authorization header(避免 URL 泄露)
|
||||
headers: {
|
||||
Authorization: `Bearer ${agentStore.token}`,
|
||||
},
|
||||
})
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 连接成功
|
||||
|
||||
Reference in New Issue
Block a user