WIP-CHECKPOINT[auth-refactor]: 固化工程师崩溃前部分成果 + 同树其他未提交WIP(仅源码,不含密钥/二进制)-- 待重激活工程师续作

This commit is contained in:
Simon
2026-07-07 21:52:11 +08:00
parent 242c1967ff
commit fab75760e0
203 changed files with 21504 additions and 3345 deletions
+84 -146
View File
@@ -31,7 +31,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.dependencies import get_current_user, require_role
from app.dependencies import get_current_user, require_role, dep_wecom_service
from app.models.agent import Agent
from app.schemas.agent import AgentLogin, AgentResponse, AgentStatusUpdate
from app.services.wecom_service import WecomService
@@ -177,6 +177,8 @@ async def agent_login(
# - 企微验证失败(用户不存在) → 拒绝登录
# - 企微API不可达(网络故障) → 仅允许已注册坐席降级登录,新注册必须验证
wecom_verified = False
# 默认空头像,企微验证成功时覆盖;确保在 wecom 不可达(降级)时仍可安全引用
avatar = ""
try:
redis_client_verify = _get_redis()
try:
@@ -188,6 +190,14 @@ async def agent_login(
real_name = user_info.get("name", "")
if real_name:
body.name = real_name
# 【P1-02】每次坐席登录也强制更新头像(与 H5 登录保持一致,统一走 avatar_service
avatar = user_info.get("avatar", "")
if avatar:
try:
from app.services.avatar_service import sync_employee_avatar
await sync_employee_avatar(db, redis_client_verify, body.user_id, avatar)
except Exception as e:
logger.warning(f"同步员工头像失败(不阻塞登录): user_id={body.user_id}, error={e}")
logger.info(f"坐席企微身份验证通过: user_id={body.user_id}, name={real_name}")
finally:
try:
@@ -258,15 +268,18 @@ async def agent_login(
logger.info(f"坐席登录: user_id={body.user_id}, name={body.name}")
# 2. MFA 二次验证(已绑定 MFA 的坐席/管理员)
# v1.5: 坐席和管理员都需要 OTP 验证
# 决策3(三端认证重构 AUTH-04):移除「企微已登录+角色→免密直接进入」分支,
# 所有登录方式(扫码/账密/企微验证)均需 OTP 验证,统一安全水位。
# 执行MFA验证
if agent.mfa_enabled:
if not body.otp_code:
# 需要 OTP 验证,返回 require_otp 标记
# 需要 OTP 验证,返回 require_otp 标记(必须包含role字段,否则前端校验会失败)
return success_response(data={
"require_otp": True,
"message": "请输入OTP动态码",
"user_id": agent.user_id,
"name": agent.name,
"role": agent.role, # 必须包含role字段,供前端校验权限
})
else:
# 验证 OTP 码
@@ -292,6 +305,7 @@ async def agent_login(
employee_id=body.user_id,
name=body.name,
roles=roles,
avatar=avatar,
login_source="agent",
)
@@ -398,148 +412,6 @@ async def list_agents(
return success_response(data={"items": items})
# --------------------------------------------------------------------------
# OTP 绑定接口
# --------------------------------------------------------------------------
@router.post("/agents/otp-bind")
async def bind_agent_otp(
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
):
"""为当前坐席生成 OTP 密钥和二维码。
生成 TOTP 密钥,生成 otpauth:// URI 用于扫码绑定 Google Authenticator。
返回二维码(base64编码)和密钥,供用户手动输入备用。
Returns:
Dict: 二维码图片(base64)和密钥
"""
try:
# v0.7.1: 用 mfa_secret 替代 otp_secret
# 检查是否已绑定
if agent.mfa_secret:
# 已绑定,返回现有密钥的二维码
totp = pyotp.TOTP(agent.mfa_secret)
else:
# 生成新密钥
secret = pyotp.random_base32()
agent.mfa_secret = secret
# mfa_enabled 保持 False,等待首次验证后启用
db.add(agent)
await db.flush()
totp = pyotp.TOTP(secret)
# 生成 otpauth:// URI
otpauth_uri = totp.provisioning_uri(
name=f"IT支持服务:{agent.name}",
issuer_name="IT支持服务",
)
# 生成二维码图片
qr = qrcode.make(otpauth_uri)
buffer = io.BytesIO()
qr.save(buffer, format="PNG")
qr_base64 = base64.b64encode(buffer.getvalue()).decode()
logger.info(f"OTP绑定: agent={agent.user_id}, secret={agent.mfa_secret[:4]}...")
return success_response(data={
"qr_code": f"data:image/png;base64,{qr_base64}",
"secret": agent.mfa_secret,
})
except AppException:
raise
except Exception as e:
logger.error(f"OTP绑定异常: {e}", exc_info=True)
raise AppException(1007, f"OTP绑定失败: {str(e)}")
@router.post("/agents/otp-verify")
async def verify_agent_otp(
body: AgentLogin, # 复用 AgentLoginotp_code 为必填
db: AsyncSession = Depends(get_db),
):
"""验证并启用 OTP。
用户输入 OTP 码验证成功后,启用 OTP。
首次验证成功后 otp_enabled 设为 1。
Args:
body.otp_code: 用户输入的 OTP 码(必填)
Returns:
Dict: 验证结果
"""
try:
# 查找坐席
stmt = select(Agent).where(Agent.user_id == body.user_id)
result = await db.execute(stmt)
agent = result.scalars().first()
if not agent or not agent.mfa_secret:
raise AppException(1008, "请先绑定OTP")
# 验证 OTP 码
totp = pyotp.TOTP(agent.mfa_secret)
if not totp.verify(body.otp_code, valid_window=1):
raise AppException(1006, "OTP验证码错误")
# 验证成功,启用 MFA
agent.mfa_enabled = True
agent.mfa_bound_at = datetime.now()
agent.mfa_last_verified_at = datetime.now()
agent.updated_at = datetime.now()
db.add(agent)
await db.flush()
logger.info(f"OTP验证成功并启用: agent={agent.user_id}")
return success_response(data={
"mfa_enabled": True,
"message": "OTP验证成功,已启用",
})
except AppException:
raise
except Exception as e:
logger.error(f"OTP验证异常: {e}", exc_info=True)
raise AppException(1009, f"OTP验证失败: {str(e)}")
@router.post("/agents/otp-unbind")
async def unbind_agent_otp(
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
):
"""解绑 OTP。
解绑后 mfa_secret 和 mfa_enabled 都清空。
需要管理员操作。
Returns:
Dict: 解绑结果
"""
try:
agent.mfa_secret = None
agent.mfa_enabled = False
agent.mfa_bound_at = None
agent.mfa_last_verified_at = None
agent.updated_at = datetime.now()
db.add(agent)
await db.flush()
logger.info(f"OTP解绑: agent={agent.user_id}")
return success_response(data={"message": "OTP已解绑"})
except AppException:
raise
except Exception as e:
logger.error(f"OTP解绑异常: {e}", exc_info=True)
raise AppException(1010, f"OTP解绑失败: {str(e)}")
# --------------------------------------------------------------------------
# 本地密码管理接口(P0-#5
# --------------------------------------------------------------------------
@@ -595,6 +467,72 @@ async def update_agent_password(
raise AppException(1014, f"密码更新失败: {str(e)}")
# ============================================================================
# 忘记密码 - 企微扫码重置
# ============================================================================
class AgentPasswordResetByWecom(BaseModel):
"""通过企微扫码重置密码请求 Schema"""
code: str = Field(..., description="企微OAuth2授权码")
new_password: str = Field(..., min_length=6, max_length=128, description="新密码")
@router.post("/agents/password/reset-by-wecom")
async def reset_password_by_wecom(
body: AgentPasswordResetByWecom,
db: AsyncSession = Depends(get_db),
wecom_service: WecomService = Depends(dep_wecom_service),
):
"""通过企微扫码验证后重置密码。
适用于坐席忘记原密码的情况。通过企微OAuth2扫码验证身份后,
无需旧密码即可重置密码。
#91 新增端点。
Args:
body.code: 企微OAuth2授权码
body.new_password: 新密码(6-128位)
Returns:
Dict: 重置结果
"""
try:
# 1. 用 code 换取员工身份
user_info = await wecom_service.get_oauth_user_info(body.code)
employee_id = user_info.get("userid", "")
if not employee_id:
raise AppException(2007, "OAuth2授权失败:未获取到员工ID")
# 2. 查询该员工是否是坐席
from sqlalchemy import select
from app.models.agent import Agent
stmt = select(Agent).where(Agent.user_id == employee_id)
result = await db.execute(stmt)
agent = result.scalar_one_or_none()
if not agent:
raise AppException(1015, "该员工不是坐席,无法重置密码")
# 3. 重置密码
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()
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(1016, f"密码重置失败: {str(e)}")
# ============================================================================
# 企微 OAuth2 一键登录(坐席端)
# ============================================================================
@@ -749,5 +687,5 @@ async def oauth_callback(
"user_id": agent.user_id,
"name": employee_name or agent.name,
"role": agent.role,
"require_otp": agent.otp_secret is not None,
"require_otp": agent.mfa_secret is not None,
})