WIP-CHECKPOINT[auth-refactor]: 固化工程师崩溃前部分成果 + 同树其他未提交WIP(仅源码,不含密钥/二进制)-- 待重激活工程师续作
This commit is contained in:
+84
-146
@@ -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, # 复用 AgentLogin,otp_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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user