feat: OTP首次绑定 + 三端登录修复 + 管理端权限修复 (2026-07-08)
OTP首次绑定: - 新增统一 OTP 路由 /auth/otp-* (otp.py + router.py) - 坐席端 OTP 绑定面板 (OtpBindPanel.vue) - 管理端 OTP 管理列表 (MfaManage.vue) - agent_login 签发半认证 token 支持首次绑定流程 三端登录修复: - 坐席/管理端去掉'返回扫码登录'按钮 - 管理端改为二维码始终可见+轮询扫码状态 - 员工端 /itdesk/ 改为 alias 直接服务 H5 (不再301重定向) - docker-compose 添加 h5 volume 挂载 管理端权限修复: - 扫码登录改用 get_user_roles() 替代写死 roles=['agent'] - get_user_roles() 增加 agents.role 回退 - 新增 GET /admin/roles/user-roles 端点 - 角色管理页加载用户角色分配数据 文档更新: - OTP PRD + 系统设计文档 - 故障排查手册 v1.1 (新增6案例) - nginx 生产基准配置
This commit is contained in:
@@ -129,6 +129,44 @@ async def get_roles(
|
||||
return success_response(data=[r.model_dump() for r in role_list])
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 1.5. 用户角色分配列表
|
||||
# ==========================================================================
|
||||
|
||||
# ---------- GET /api/admin/roles/user-roles ----------
|
||||
@router.get("/user-roles")
|
||||
async def list_user_role_assignments(
|
||||
admin: UserInfo = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取所有用户角色分配记录。
|
||||
|
||||
Returns:
|
||||
List of user role assignments with employee_id, role info, source, etc.
|
||||
"""
|
||||
stmt = (
|
||||
select(UserRole, Role)
|
||||
.join(Role, UserRole.role_id == Role.id)
|
||||
.order_by(UserRole.assigned_at.desc().nulls_last())
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
assignments = []
|
||||
for user_role, role in rows:
|
||||
assignments.append({
|
||||
"employee_id": user_role.employee_id,
|
||||
"role_name": role.name,
|
||||
"role_display_name": role.display_name or role.name,
|
||||
"source": user_role.source or "manual",
|
||||
"assigned_by": user_role.assigned_by or "",
|
||||
"assigned_at": user_role.assigned_at.isoformat() if user_role.assigned_at else None,
|
||||
"expires_at": user_role.expires_at.isoformat() if user_role.expires_at else None,
|
||||
})
|
||||
|
||||
return success_response(data=assignments)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 2. 用户角色分配/撤销
|
||||
# ==========================================================================
|
||||
|
||||
@@ -267,13 +267,15 @@ async def agent_login(
|
||||
await db.flush()
|
||||
logger.info(f"坐席登录: user_id={body.user_id}, name={body.name}")
|
||||
|
||||
# 2. MFA 二次验证(已绑定 MFA 的坐席/管理员)
|
||||
# 决策3(三端认证重构 AUTH-04):移除「企微已登录+角色→免密直接进入」分支,
|
||||
# 2. MFA 二次验证(三端认证重构 AUTH-04/AUTH-05)
|
||||
# 决策3(AUTH-04):移除「企微已登录+角色→免密直接进入」分支,
|
||||
# 所有登录方式(扫码/账密/企微验证)均需 OTP 验证,统一安全水位。
|
||||
# 执行MFA验证
|
||||
# 决策4(AUTH-05):区分两种 OTP 状态——
|
||||
# - mfa_enabled=True → 已绑定,需验证 OTP 动态码
|
||||
# - mfa_enabled=False → 未绑定,引导首次绑定流程
|
||||
if agent.mfa_enabled:
|
||||
# 已绑定 OTP → 要求验证或校验码
|
||||
if not body.otp_code:
|
||||
# 需要 OTP 验证,返回 require_otp 标记(必须包含role字段,否则前端校验会失败)
|
||||
return success_response(data={
|
||||
"require_otp": True,
|
||||
"message": "请输入OTP动态码",
|
||||
@@ -281,10 +283,34 @@ async def agent_login(
|
||||
"name": agent.name,
|
||||
"role": agent.role, # 必须包含role字段,供前端校验权限
|
||||
})
|
||||
else:
|
||||
# 验证 OTP 码(决策3:复用 MFAService 统一校验逻辑)
|
||||
if not MFAService.verify_code(agent.mfa_secret, body.otp_code, valid_window=1):
|
||||
raise AppException(1006, "OTP验证码错误,请重新输入")
|
||||
# 验证 OTP 码(复用 MFAService 统一校验逻辑)
|
||||
if not MFAService.verify_code(agent.mfa_secret, body.otp_code, valid_window=1):
|
||||
raise AppException(1006, "OTP验证码错误,请重新输入")
|
||||
else:
|
||||
# 未绑定 OTP → 引导首次绑定(AUTH-05)
|
||||
# BUG-001 修复: 签发半认证 token,使前端可以调用 otp-bind / otp-verify
|
||||
# 这些端点需要 Bearer token(get_current_user 认证),否则流程完全阻断
|
||||
from app.services.token_service import TokenService
|
||||
from app.dependencies import get_redis
|
||||
|
||||
redis_client = await get_redis()
|
||||
token_service = TokenService(redis_client)
|
||||
bind_token = await token_service.create_token(
|
||||
employee_id=agent.user_id,
|
||||
name=agent.name,
|
||||
roles=["agent"],
|
||||
avatar=avatar,
|
||||
login_source="agent_pending_otp",
|
||||
)
|
||||
|
||||
return success_response(data={
|
||||
"require_otp_bind": True,
|
||||
"message": "首次登录请先绑定OTP二次验证",
|
||||
"user_id": agent.user_id,
|
||||
"name": agent.name,
|
||||
"role": agent.role,
|
||||
"token": bind_token,
|
||||
})
|
||||
|
||||
# 3. 生成随机 token(使用统一格式)
|
||||
from app.services.token_service import TokenService
|
||||
|
||||
@@ -150,6 +150,7 @@ async def scan_qrcode(
|
||||
state: Optional[str] = Query(None, description="扫码登录票据(企微 OAuth state 标准参数名)"),
|
||||
code: Optional[str] = Query(None, description="企微 OAuth 授权码"),
|
||||
redis_client: aioredis.Redis = Depends(dep_redis),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""处理企微 OAuth2 扫码回调。
|
||||
|
||||
@@ -186,12 +187,61 @@ async def scan_qrcode(
|
||||
service = _get_qrcode_service(redis_client)
|
||||
result = await service.process_scan(ticket=final_ticket, code=final_code)
|
||||
|
||||
# ==========================================================================
|
||||
# 扫码后自动确认(auto-confirm)
|
||||
# ==========================================================================
|
||||
# 原设计:requester 需已登录坐席调 /confirm 来授权新登录
|
||||
# 问题:首次登录时电脑端无人登录,没有合法 current_user 可调 confirm
|
||||
# 修正:扫码即确认,直接为扫码的企微用户签发 token
|
||||
# ==========================================================================
|
||||
from app.services.token_service import TokenService
|
||||
from app.services.role_mapping_service import RoleMappingService
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
token_service = TokenService(redis_client)
|
||||
|
||||
# 获取用户的真实角色(而非写死 agent)
|
||||
role_service = RoleMappingService(db)
|
||||
user_roles = await role_service.get_user_roles(result["employee_id"])
|
||||
logger.info(
|
||||
f"扫码登录角色: employee_id={result['employee_id']}, "
|
||||
f"roles={user_roles}"
|
||||
)
|
||||
|
||||
auto_token = await token_service.create_token(
|
||||
employee_id=result["employee_id"],
|
||||
name=result["name"],
|
||||
roles=user_roles,
|
||||
avatar=result.get("avatar", ""),
|
||||
login_source="qrcode_scan",
|
||||
)
|
||||
|
||||
confirm_payload = {
|
||||
"token": auto_token,
|
||||
"confirmed_at": datetime.now().isoformat(),
|
||||
"roles": user_roles,
|
||||
"employee_id": result["employee_id"],
|
||||
"name": result["name"],
|
||||
}
|
||||
CONFIRM_TTL = 60
|
||||
await redis_client.setex(
|
||||
f"qrcode:confirm:{final_ticket}",
|
||||
CONFIRM_TTL,
|
||||
json.dumps(confirm_payload, ensure_ascii=False),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"扫码自动确认: ticket={final_ticket[:8]}..., "
|
||||
f"employee_id={result['employee_id']}, name={result['name']}"
|
||||
)
|
||||
|
||||
# GET 请求(企微 OAuth 回调)→ 重定向到前端选择页
|
||||
# 因为企微 OAuth 流程不在这个端点完成最终登录,只标记 scanned,
|
||||
# 等用户在坐席端点 confirm 后才能拿到 token。
|
||||
# 但企微 WebView 期望看到跳转后的页面,所以这里给个提示页。
|
||||
from fastapi.responses import HTMLResponse
|
||||
if code is not None and ticket is not None and body is None:
|
||||
if final_code is not None and final_ticket is not None and body is None:
|
||||
# GET 模式:渲染一个 "扫码成功" 的 HTML 提示页 + 引导用户到登录页
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
+93
-21
@@ -28,7 +28,7 @@ from typing import Optional
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -200,36 +200,80 @@ async def verify_otp(
|
||||
):
|
||||
"""校验 6 位码,在 Redis 写 30 分钟复用标记。
|
||||
|
||||
行为:
|
||||
- 校验通过 → mfa:verified:{employee_id}=1 TTL 1800s
|
||||
+ 更新 mfa_last_verified_at
|
||||
- 校验失败 → verified=false(不抛异常,前端可重试)
|
||||
两种场景(三端认证重构 AUTH-05):
|
||||
- 已绑定(mfa_enabled=True) → 常规验证,写 Redis 标记
|
||||
- 首次绑定(mfa_enabled=False 但有 mfa_secret)→ 验证后启用 MFA 并直接签发 token
|
||||
- 未初始化(无 mfa_secret)→ 返回 verified=false
|
||||
|
||||
Returns:
|
||||
success_response({verified, expires_in})
|
||||
success_response({verified, expires_in, token?})
|
||||
"""
|
||||
agent = await _require_agent(db, current_user)
|
||||
|
||||
if not agent.mfa_enabled or not agent.mfa_secret:
|
||||
# 用户还没绑定 OTP,直接返回 verified=false(前端可据此跳转绑定流程)
|
||||
# 场景1: 未初始化 OTP(无 secret)→ 无法验证
|
||||
if not agent.mfa_secret:
|
||||
return success_response(data=MFAVerifyResponse(
|
||||
verified=False,
|
||||
expires_in=0,
|
||||
).model_dump())
|
||||
).model_dump(exclude={"token"}))
|
||||
|
||||
# 校验
|
||||
# 校验 OTP 码(两种场景共用)
|
||||
if not MFAService.verify_code(agent.mfa_secret, body.otp_code):
|
||||
logger.warning(f"OTP verify 验证码错误: agent={agent.user_id}")
|
||||
return success_response(data=MFAVerifyResponse(
|
||||
verified=False,
|
||||
expires_in=0,
|
||||
).model_dump())
|
||||
).model_dump(exclude={"token"}))
|
||||
|
||||
# OTP 码校验通过
|
||||
|
||||
# 场景2: 首次绑定(mfa_enabled=False 但有 secret)
|
||||
# 行为:启用 MFA + 记录绑定时间 + 写 Redis 标记 + 直接签发 token
|
||||
# 避免前端还需要二次调用 login
|
||||
is_first_bind = not agent.mfa_enabled
|
||||
now = datetime.now()
|
||||
|
||||
if is_first_bind:
|
||||
agent.mfa_enabled = True
|
||||
agent.mfa_bound_at = now
|
||||
agent.mfa_last_verified_at = now
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
|
||||
# 写 Redis 复用标记
|
||||
await MFAService.mark_verified(redis, agent.user_id, MFA_VERIFIED_TTL_SECONDS)
|
||||
|
||||
# 签发 token(直接复用 agents 登录的 token 创建逻辑)
|
||||
from app.services.token_service import TokenService
|
||||
|
||||
token_service = TokenService(redis)
|
||||
token = await token_service.create_token(
|
||||
employee_id=agent.user_id,
|
||||
name=agent.name or current_user.name,
|
||||
roles=current_user.roles,
|
||||
avatar=getattr(current_user, "avatar", None),
|
||||
login_source=getattr(current_user, "login_source", "agent"),
|
||||
)
|
||||
|
||||
logger.info(f"OTP 首次绑定成功并签发 token: agent={agent.user_id}")
|
||||
|
||||
return success_response(data={
|
||||
**MFAVerifyResponse(
|
||||
verified=True,
|
||||
expires_in=MFA_VERIFIED_TTL_SECONDS,
|
||||
).model_dump(),
|
||||
"token": token,
|
||||
"user_id": agent.user_id,
|
||||
"name": agent.name or current_user.name,
|
||||
"role": agent.role,
|
||||
"is_first_bind": True,
|
||||
})
|
||||
|
||||
# 场景3: 已绑定常规验证(mfa_enabled=True)
|
||||
# 写 Redis 复用标记(与 require_high_risk_otp 共用 key)
|
||||
await MFAService.mark_verified(redis, agent.user_id, MFA_VERIFIED_TTL_SECONDS)
|
||||
|
||||
# 更新最后验证时间
|
||||
now = datetime.now()
|
||||
agent.mfa_last_verified_at = now
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
@@ -239,7 +283,7 @@ async def verify_otp(
|
||||
return success_response(data=MFAVerifyResponse(
|
||||
verified=True,
|
||||
expires_in=MFA_VERIFIED_TTL_SECONDS,
|
||||
).model_dump())
|
||||
).model_dump(exclude={"token"}))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -295,7 +339,6 @@ async def unbind_otp(
|
||||
@require_role("admin")
|
||||
async def admin_reset_otp(
|
||||
employee_id: str,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(_get_redis),
|
||||
):
|
||||
@@ -335,20 +378,49 @@ async def admin_reset_otp(
|
||||
@router.get("/otp-admin-users", response_model=None)
|
||||
@require_role("admin")
|
||||
async def admin_list_otp_users(
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
keyword: str = None,
|
||||
bound: str = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
):
|
||||
"""管理员查看全部坐席的 OTP 绑定状态。
|
||||
"""管理员查看全部坐席的 OTP 绑定状态(支持搜索/过滤/分页)。
|
||||
|
||||
Query params:
|
||||
keyword: 搜索姓名或 employee_id(模糊匹配)
|
||||
bound: "true"=已绑定, "false"=未绑定, 空=全部
|
||||
page: 页码(默认 1)
|
||||
page_size: 每页条数(默认 20)
|
||||
|
||||
Returns:
|
||||
success_response([{employee_id, name, mfa_enabled, mfa_bound_at,
|
||||
mfa_last_verified_at}, ...])
|
||||
success_response({total, items: [{employee_id, name, mfa_enabled,
|
||||
mfa_bound_at, mfa_last_verified_at}, ...]})
|
||||
"""
|
||||
stmt = select(Agent).order_by(Agent.user_id)
|
||||
# 构建查询
|
||||
stmt = select(Agent)
|
||||
|
||||
# 搜索过滤
|
||||
if keyword:
|
||||
stmt = stmt.where(
|
||||
Agent.user_id.ilike(f"%{keyword}%") |
|
||||
Agent.name.ilike(f"%{keyword}%")
|
||||
)
|
||||
if bound == "true":
|
||||
stmt = stmt.where(Agent.mfa_enabled == True)
|
||||
elif bound == "false":
|
||||
stmt = stmt.where(Agent.mfa_enabled == False)
|
||||
|
||||
# 先查总数
|
||||
count_stmt = stmt.with_only_columns(func.count()).order_by(None)
|
||||
count_result = await db.execute(count_stmt)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
# 分页
|
||||
stmt = stmt.order_by(Agent.user_id).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(stmt)
|
||||
agents = result.scalars().all()
|
||||
|
||||
users = [
|
||||
items = [
|
||||
{
|
||||
"employee_id": a.user_id,
|
||||
"name": getattr(a, "name", "") or "",
|
||||
@@ -361,4 +433,4 @@ async def admin_list_otp_users(
|
||||
for a in agents
|
||||
]
|
||||
|
||||
return success_response(data=users)
|
||||
return success_response(data={"total": total, "items": items})
|
||||
|
||||
@@ -29,7 +29,10 @@ from app.api.admin_roles import router as admin_roles_router
|
||||
from app.api.admin.security_comparison import router as security_comparison_router
|
||||
from app.api.approval import router as approval_router
|
||||
from app.api.wecom_jsapi import router as wecom_jsapi_router # v0.5.4 应急页 JS-SDK 签名
|
||||
# from app.api.knowledge_iteration import router as knowledge_iteration_router # P2-13 知识库自动迭代 (暂未完成)
|
||||
from app.api.knowledge_iteration import router as knowledge_iteration_router # Tier1 知识库自动迭代
|
||||
from app.api.approval_queue import router as approval_queue_router # Tier1 独立审批队列
|
||||
from app.api.vision import router as vision_router # Tier1 视觉理解
|
||||
from app.api.ragflow_ingestion import router as ragflow_router # Tier1 RAGFlow文档摄入
|
||||
|
||||
# 创建 API 路由器
|
||||
# 所有子路由都会挂载到这个路由器上
|
||||
@@ -274,11 +277,30 @@ api_router.include_router(admin_users_router, tags=["管理员用户管理"])
|
||||
from app.api.evaluations import router as evaluations_router
|
||||
api_router.include_router(evaluations_router, tags=["满意度评价"])
|
||||
|
||||
# 知识库自动迭代 API (P2-13)
|
||||
# 知识库自动迭代 API (Tier1 挂载)
|
||||
# POST /api/admin/knowledge-iteration/analyze — 触发分析
|
||||
# GET /api/admin/knowledge-iteration/suggestions — 获取建议列表
|
||||
# GET /api/admin/knowledge-iteration/suggestions — 获取建议列表(支持audience/confidence筛选)
|
||||
# GET /api/admin/knowledge-iteration/suggestions/{id} — 获取建议详情
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/approve — 审核通过
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/reject — 审核拒绝
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/rewrite — 改写提案
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/queue — 放入队列
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/dequeue-approve — 队列中审批
|
||||
# GET /api/admin/knowledge-iteration/stats — 获取统计
|
||||
# api_router.include_router(knowledge_iteration_router, prefix="/admin/knowledge-iteration", tags=["知识库自动迭代"]) # 暂未完成
|
||||
api_router.include_router(knowledge_iteration_router, prefix="/admin/knowledge-iteration", tags=["知识库自动迭代"])
|
||||
|
||||
# 独立审批队列 API (Tier1)
|
||||
# GET /api/admin/approval-queue/queued — 队列列表
|
||||
# GET /api/admin/approval-queue/queued/stats — 队列统计
|
||||
# POST /api/admin/approval-queue/queued/{id}/dequeue-approve — 队列中审批通过
|
||||
api_router.include_router(approval_queue_router, prefix="/admin/approval-queue", tags=["独立审批队列"])
|
||||
|
||||
# 视觉理解 API (Tier1)
|
||||
# POST /api/vision/analyze — 分析截图(multipart: image + conversation_id)
|
||||
# GET /api/vision/models — 可用视觉模型列表
|
||||
api_router.include_router(vision_router, prefix="/api/vision", tags=["视觉理解"])
|
||||
|
||||
# RAGFlow 文档摄入 API (Tier1)
|
||||
# POST /api/ragflow/ingest — 上传文档触发RAGFlow处理
|
||||
# GET /api/ragflow/tasks/{task_id} — 查询处理状态
|
||||
api_router.include_router(ragflow_router, prefix="/api/ragflow", tags=["RAGFlow文档摄入"])
|
||||
|
||||
@@ -90,10 +90,12 @@ class MFAVerifyResponse(BaseModel):
|
||||
Attributes:
|
||||
verified: 验证是否通过
|
||||
expires_in: 验证状态在 Redis 里的剩余秒数(1800s 滑动窗口)
|
||||
token: 首次绑定时签发的登录 token(可选,仅首次绑定场景返回)
|
||||
"""
|
||||
|
||||
verified: bool = Field(..., description="验证是否通过")
|
||||
expires_in: int = Field(..., description="Redis 验证标记剩余秒数(秒)")
|
||||
token: Optional[str] = Field(None, description="首次绑定成功后签发的登录 token")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -18,6 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.role import Role
|
||||
from app.models.role_mapping_rule import RoleMappingRule
|
||||
from app.models.user_role import UserRole
|
||||
from app.models.agent import Agent
|
||||
from app.services.wecom_service import WecomService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -59,26 +60,32 @@ class RoleMappingService:
|
||||
async def get_user_roles(self, employee_id: str) -> List[str]:
|
||||
"""获取用户的角色列表。
|
||||
|
||||
查询 user_roles 表,返回用户拥有的角色标识列表。
|
||||
查询 user_roles 表,同时回退到 agents.role 字段作为补充。
|
||||
|
||||
Args:
|
||||
employee_id: 企微 UserID
|
||||
|
||||
Returns:
|
||||
List[str]: 角色标识列表(如 ["user", "agent"])
|
||||
List[str]: 角色标识列表(如 ["user", "agent", "admin"])
|
||||
"""
|
||||
stmt = (
|
||||
select(Role.name)
|
||||
.join(UserRole, Role.id == UserRole.role_id)
|
||||
.where(UserRole.employee_id == employee_id)
|
||||
.where(
|
||||
# 过滤已过期的角色
|
||||
(UserRole.expires_at.is_(None)) | (UserRole.expires_at > datetime.now())
|
||||
)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
roles = [row[0] for row in result.all()]
|
||||
|
||||
# 回退到 agents.role 字段(兼容旧数据)
|
||||
agent_stmt = select(Agent.role).where(Agent.user_id == employee_id)
|
||||
agent_result = await self.db.execute(agent_stmt)
|
||||
agent_role = agent_result.scalar_one_or_none()
|
||||
if agent_role and agent_role not in roles:
|
||||
roles.append(agent_role)
|
||||
|
||||
# 如果没有角色,添加默认的 user 角色
|
||||
if not roles:
|
||||
roles = ["user"]
|
||||
|
||||
Reference in New Issue
Block a user