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
+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,