WIP-CHECKPOINT[auth-refactor]: 固化工程师崩溃前部分成果 + 同树其他未提交WIP(仅源码,不含密钥/二进制)-- 待重激活工程师续作
This commit is contained in:
@@ -37,7 +37,7 @@ from app.models.role import Role
|
||||
from app.models.user_role import UserRole
|
||||
from app.services.wecom_service import WecomService
|
||||
from app.services.audit_log_service import record_audit_log
|
||||
from app.utils.response import AppException
|
||||
from app.utils.response import AppException, success_response
|
||||
from app.dependencies import get_redis
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -105,16 +105,18 @@ def _build_oauth_url(state: str, callback_url: str) -> str:
|
||||
@router.get("/sso/init")
|
||||
async def sso_init(
|
||||
request: Request,
|
||||
next: str = Query("/itdesk/", description="登录后跳转路径"),
|
||||
next: str = Query("/itagent/", description="登录后跳转路径"),
|
||||
redis_client = Depends(get_redis),
|
||||
):
|
||||
"""初始化 SSO: 生成 state,302 跳转到企微 OAuth2 授权页。
|
||||
|
||||
支持任意浏览器环境,用户通过企微扫码授权后自动登录。
|
||||
|
||||
Args:
|
||||
next: 登录成功后跳转路径,如 /itdesk/ /itagent/ /itadmin/
|
||||
"""
|
||||
# 后端第二道防线:非企微环境拒绝授权
|
||||
_require_wework_ua(request)
|
||||
# 注意:移除企微环境检测,允许在任意浏览器中使用
|
||||
# 用户通过企微扫码授权后即可自动登录
|
||||
|
||||
if not _sso_enabled():
|
||||
raise AppException(1001, "企微 SSO 未启用, 请用扫码登录")
|
||||
@@ -160,7 +162,7 @@ def _get_error_redirect_url(error_code: str, error_msg: str, next_path: str = "/
|
||||
|
||||
# 重定向到 Portal 的 ErrorPage,带错误参数
|
||||
# ErrorPage 期望格式:?code=xxx&message=yyy
|
||||
return f"{base.rstrip('/')}/itportal/error?code={error_code}&message={urllib.parse.quote(error_msg)}"
|
||||
return f"{base.rstrip('/')}/itdesk/error?code={error_code}&message={urllib.parse.quote(error_msg)}"
|
||||
|
||||
|
||||
@router.get("/sso/callback")
|
||||
@@ -176,20 +178,19 @@ async def sso_callback(
|
||||
):
|
||||
"""企微 OAuth 回调: 用 code 换 userid → 查 role → 生成 token → 跳 next。
|
||||
|
||||
支持任意浏览器环境,用户通过企微扫码授权后自动登录。
|
||||
异常时重定向到前端错误页面,避免白屏。
|
||||
所有未处理的异常都会记录详细日志(包含 traceback)。
|
||||
|
||||
Args:
|
||||
next: 原始请求的目标路径,用于错误重定向。如果 state 验证失败,使用此参数决定重定向位置。
|
||||
"""
|
||||
import traceback
|
||||
|
||||
# 默认 next 路径
|
||||
next_path = next or "/itdesk/"
|
||||
# 默认 next 路径(坐席端)
|
||||
next_path = next or "/itagent/"
|
||||
|
||||
try:
|
||||
# 后端第二道防线:非企微环境拒绝回调
|
||||
_require_wework_ua(request)
|
||||
# 注意:移除企微环境检测,允许在任意浏览器中使用
|
||||
|
||||
# 0. 处理企微返回的错误(用户拒绝授权等)
|
||||
if errcode is not None:
|
||||
@@ -247,6 +248,12 @@ async def sso_callback(
|
||||
|
||||
user_info = await wecom.get_user_info(user_id)
|
||||
name = user_info.get("name", user_id)
|
||||
# 同步头像到 employee 表 + 清缓存(要求 A;不阻塞登录)
|
||||
try:
|
||||
from app.services.avatar_service import sync_employee_avatar
|
||||
await sync_employee_avatar(db, redis_client, user_id, user_info.get("avatar", ""))
|
||||
except Exception as av_err:
|
||||
logger.warning(f"SSO 同步头像失败(不阻塞): user_id={user_id}, error={av_err}")
|
||||
except Exception as e:
|
||||
logger.error(f"SSO callback 调企微 API 失败: code={code[:8]}..., error={e}")
|
||||
return RedirectResponse(url=_get_error_redirect_url("api_failed", f"企业微信服务异常: {str(e)}"), status_code=302)
|
||||
@@ -349,10 +356,9 @@ async def sso_verify(
|
||||
):
|
||||
"""前端用 SSO token 换用户身份(token 一次性使用,用完删除)。
|
||||
|
||||
后端第二道防线:非企微环境拒绝验证。
|
||||
支持任意浏览器环境。
|
||||
"""
|
||||
# 后端第二道防线:非企微环境拒绝验证
|
||||
_require_wework_ua(request)
|
||||
# 注意:移除企微环境检测,允许在任意浏览器中使用
|
||||
|
||||
import json
|
||||
token_raw = await redis_client.get(f"wecom_sso:token:{sso_token}")
|
||||
@@ -363,10 +369,7 @@ async def sso_verify(
|
||||
await redis_client.delete(f"wecom_sso:token:{sso_token}")
|
||||
|
||||
payload = json.loads(token_raw.decode("utf-8"))
|
||||
return {
|
||||
"code": 0,
|
||||
"data": payload,
|
||||
}
|
||||
return success_response(data=payload)
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
@@ -402,13 +405,7 @@ async def refresh_token(
|
||||
)
|
||||
|
||||
logger.info(f"Token 刷新成功: employee_id={user_info.get('employee_id')}")
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"token": token, # 复用同一个 token,只延长 TTL
|
||||
"expires_in": TOKEN_TTL_SECONDS,
|
||||
},
|
||||
}
|
||||
return success_response(data={"token": token, "expires_in": TOKEN_TTL_SECONDS})
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
@@ -526,3 +523,179 @@ async def refresh_token_alias(
|
||||
|
||||
logger.warning(f"Token 刷新失败(alias): token 不存在或已过期")
|
||||
raise AppException(401, "Token 已过期,请重新登录")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/auth_wecom/jsdk-login — 企微 JS-SDK 免认证登录 (v1.8 新增)
|
||||
# --------------------------------------------------------------------------
|
||||
# 流程:
|
||||
# 1. 前端通过 wx.agentConfig 获取企微用户 userid
|
||||
# 2. 前端调用本接口,传入 userid
|
||||
# 3. 后端验证 userid 是否是坐席
|
||||
# 4. 生成 token,返回给前端
|
||||
# --------------------------------------------------------------------------
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class WecomJsdkLoginRequest(BaseModel):
|
||||
"""企微 JS-SDK 免认证登录请求"""
|
||||
userid: str = Field(..., description="企微用户 ID(从 wx.agentConfig 获取)")
|
||||
login_source: str = Field(default="wecom_jsdk", description="登录来源标识")
|
||||
|
||||
|
||||
@router.post("/jsdk-login")
|
||||
async def wecom_jsdk_login(
|
||||
body: WecomJsdkLoginRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis_client = Depends(get_redis),
|
||||
):
|
||||
"""企微 JS-SDK 免认证登录。
|
||||
|
||||
前端通过企微 JS-SDK (wx.agentConfig) 获取当前用户 ID,
|
||||
然后调用本接口进行免认证登录。
|
||||
|
||||
流程:
|
||||
1. 调用 /wecom/check-role 接口验证 userid 是否是坐席
|
||||
2. 查找或创建坐席记录
|
||||
3. 生成 token,存入 Redis
|
||||
4. 返回 token 和坐席信息
|
||||
|
||||
Args:
|
||||
body: 包含企微 userid
|
||||
db: 数据库会话
|
||||
redis_client: Redis 客户端
|
||||
|
||||
Returns:
|
||||
登录成功:{ code: 0, data: { token, employee_id, name, roles } }
|
||||
"""
|
||||
try:
|
||||
# 1. 验证用户是否具有坐席或管理员角色
|
||||
wecom_service = WecomService(redis_client)
|
||||
userid = body.userid
|
||||
|
||||
# 从数据库查询用户角色(支持坐席和管理员)
|
||||
from app.services.role_mapping_service import RoleMappingService
|
||||
role_service = RoleMappingService(db)
|
||||
user_roles = await role_service.get_user_roles(userid)
|
||||
|
||||
# 检查是否具有坐席或管理员角色
|
||||
has_agent_role = "agent" in user_roles
|
||||
has_admin_role = "admin" in user_roles
|
||||
|
||||
if not has_agent_role and not has_admin_role:
|
||||
# 无坐席或管理员角色,尝试从企微标签检测(兼容旧逻辑)
|
||||
tag_id = getattr(settings, "wecom_agent_tag_id", None)
|
||||
if tag_id:
|
||||
try:
|
||||
access_token = await wecom_service.get_access_token()
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/tag/get?access_token={access_token}&tagid={tag_id}"
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.get(url)
|
||||
result = resp.json()
|
||||
|
||||
if result.get("errcode", 0) == 0:
|
||||
user_list = result.get("userlist", [])
|
||||
user_ids = [
|
||||
u if isinstance(u, str) else u.get("userid", "")
|
||||
for u in user_list
|
||||
]
|
||||
if userid in user_ids:
|
||||
has_agent_role = True
|
||||
logger.info(f"企微 JS-SDK 免认证: userid={userid} 通过企微标签验证为坐席")
|
||||
except Exception as e:
|
||||
logger.warning(f"企微标签检测失败: {e}")
|
||||
|
||||
# 如果既没有坐席也没有管理员角色,拒绝登录
|
||||
if not has_agent_role and not has_admin_role:
|
||||
logger.warning(f"企微 JS-SDK 免认证失败: userid={userid} 没有坐席或管理员角色")
|
||||
raise AppException(403, "您没有坐席或管理员权限,无法使用此方式登录")
|
||||
|
||||
# 确定用户角色
|
||||
role_names = []
|
||||
if has_admin_role:
|
||||
role_names.append("admin")
|
||||
if has_agent_role:
|
||||
role_names.append("agent")
|
||||
|
||||
logger.info(f"企微 JS-SDK 免认证: userid={userid}, roles={role_names}")
|
||||
|
||||
# 3. 获取用户详细信息
|
||||
try:
|
||||
user_info = await wecom_service.get_user_info(userid)
|
||||
user_name = user_info.get("name", userid)
|
||||
# 同步头像到 employee 表 + 清缓存(要求 A;不阻塞登录)
|
||||
try:
|
||||
from app.services.avatar_service import sync_employee_avatar
|
||||
await sync_employee_avatar(db, redis_client, userid, user_info.get("avatar", ""))
|
||||
except Exception as av_err:
|
||||
logger.warning(f"JS-SDK 同步头像失败(不阻塞): userid={userid}, error={av_err}")
|
||||
except Exception as e:
|
||||
logger.warning(f"获取企微用户信息失败: {e}")
|
||||
user_name = userid
|
||||
|
||||
# 4. 查找或创建坐席记录
|
||||
from app.models.agent import Agent
|
||||
|
||||
stmt = select(Agent).where(Agent.user_id == userid)
|
||||
result = await db.execute(stmt)
|
||||
agent = result.scalars().first()
|
||||
|
||||
if not agent:
|
||||
# 首次登录,创建坐席记录
|
||||
agent = Agent(
|
||||
user_id=userid,
|
||||
name=user_name,
|
||||
status="online",
|
||||
current_load=0,
|
||||
max_load=5,
|
||||
)
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
logger.info(f"企微 JS-SDK 免认证创建坐席: user_id={userid}, name={user_name}")
|
||||
else:
|
||||
# 更新坐席状态
|
||||
agent.name = user_name
|
||||
agent.status = "online"
|
||||
agent.updated_at = datetime.now()
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
logger.info(f"企微 JS-SDK 免认证登录: user_id={userid}, name={user_name}")
|
||||
|
||||
# 5. 生成 token
|
||||
token = secrets.token_urlsafe(32)
|
||||
|
||||
# 6. 存储 token 到 Redis
|
||||
token_key = f"agent:token:{token}"
|
||||
await redis_client.setex(token_key, TOKEN_TTL_SECONDS, userid)
|
||||
|
||||
# 7. 记录审计日志
|
||||
await record_audit_log(
|
||||
db=db,
|
||||
employee_id=userid,
|
||||
action="wecom_jsdk_login",
|
||||
resource="auth",
|
||||
resource_id=userid,
|
||||
details={
|
||||
"name": user_name,
|
||||
"roles": role_names,
|
||||
"login_method": "wecom_jsdk",
|
||||
},
|
||||
result="success",
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"企微 JS-SDK 免认证登录成功: user_id={userid}, roles={role_names}")
|
||||
|
||||
return success_response(data={
|
||||
"token": token,
|
||||
"employee_id": userid,
|
||||
"name": user_name,
|
||||
"roles": role_names,
|
||||
})
|
||||
|
||||
except AppException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"企微 JS-SDK 免认证登录异常: {e}", exc_info=True)
|
||||
raise AppException(500, f"免认证登录失败: {str(e)}")
|
||||
|
||||
Reference in New Issue
Block a user