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
+197 -24
View File
@@ -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)}")