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:
Simon
2026-07-08 21:54:57 +08:00
parent 6f0fbbb066
commit 400ce3ddcb
27 changed files with 2822 additions and 312 deletions
+93 -21
View File
@@ -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})