78f60c6857
P0 修复: - /api/ready import 错误 (_get_engine + settings.create_redis_client) - 删 agent.otp_secret/otp_enabled 双字段 (migration 026) - 重建 021_rbac migration (IF NOT EXISTS 兼容) P1 新增: - 企微 SSO (auth_wecom_sso.py, useWeChatWorkSSO composable, PortalSelect UA 检测) - RBAC 5 角色 × 4 资源 × 4 操作 × 3 范围 (rbac_service + seed_rbac + require_permission) - audit_log 模型 + migration 027 + 服务 + API - 管理后台 RBAC 权限矩阵 UI (PermissionsMatrix.vue) 质量: - pytest 405 passed / 33 pre-existing failed / 4 xfailed (v0.7.1 引入失败 = 0) - conftest GBK patch 强制 UTF-8 读 .env - .gitignore 排除 *.b64 (含 admin token 凭据) - DEPLOY-v0.7.1.md 7 步 runbook + 4 坑 + 回滚预案
229 lines
8.0 KiB
Python
229 lines
8.0 KiB
Python
# =============================================================================
|
|
# 企微IT智能服务台 — 企微入口 SSO(v0.7.1 新增)
|
|
# =============================================================================
|
|
# 说明: 解决 v0.7.0 hotfix1 用户报告的"企微工作台进入应用也要扫码"问题。
|
|
#
|
|
# 流程:
|
|
# 1. 前端 PortalSelect.vue 加载时检测 navigator.userAgent
|
|
# 2. 如果是 MicroMessenger / wxwork / DingTalk 等企微内置浏览器
|
|
# → 调 /api/auth_wecom/sso/init?next=/itdesk/
|
|
# 3. 后端生成企微 OAuth2 授权 URL,302 跳转用户去企微授权
|
|
# 4. 企微回调 /api/auth_wecom/sso/callback?code=...&state=...
|
|
# 5. 用 code 换 userid,查 role (user/agent/admin),生成 token
|
|
# 6. 302 跳转到 next 路径 + token query param
|
|
# 7. 前端用 token 调 get_current_user 拉身份信息
|
|
#
|
|
# 配置要求:
|
|
# - 企微管理后台 → 应用 → 网页授权及 JS-SDK → 可信域名: itsupport.servyou.com.cn
|
|
# - 企微管理后台 → 应用 → 网页授权及 JS-SDK → 回调域: itsupport.servyou.com.cn
|
|
# - 环境变量 WECOM_SSO_ENABLED=true 启用(默认 false,避免老用户被打扰)
|
|
# =============================================================================
|
|
|
|
import logging
|
|
import secrets
|
|
import urllib.parse
|
|
from datetime import datetime, timedelta
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, Query, Request
|
|
from fastapi.responses import RedirectResponse
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.config import settings
|
|
from app.database import get_db
|
|
from app.models.role import Role
|
|
from app.models.user_role import UserRole
|
|
from app.services.wecom_service import WecomService
|
|
from app.utils.response import AppException
|
|
from app.dependencies import get_redis
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/auth_wecom", tags=["企微 SSO"])
|
|
|
|
# OAuth state 在 Redis 的 TTL (5 分钟,够用户授权 + 回调)
|
|
OAUTH_STATE_TTL = 300
|
|
# SSO token 长度
|
|
SSO_TOKEN_BYTES = 32
|
|
|
|
|
|
def _sso_enabled() -> bool:
|
|
"""检查是否启用企微 SSO。"""
|
|
import os
|
|
if os.getenv("WECOM_SSO_ENABLED", "false").lower() == "true":
|
|
return True
|
|
if getattr(settings, "wecom_sso_enabled", False):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _get_oauth_callback_url(request: Request) -> str:
|
|
"""拼接 OAuth 回调 URL (绝对地址)。
|
|
|
|
企微要求 redirect_uri 必须用可信域名(itsupport.servyou.com.cn)。
|
|
不读 request.base_url 因为它可能是 127.0.0.1:8000(开发环境)。
|
|
"""
|
|
# 优先用 settings 里的配置
|
|
base = getattr(settings, "wecom_sso_callback_base", None)
|
|
if not base:
|
|
# 兜底: 读环境变量,默认生产域名
|
|
import os
|
|
base = os.getenv("WECOM_SSO_CALLBACK_BASE", "https://itsupport.servyou.com.cn")
|
|
return f"{base.rstrip('/')}/api/auth_wecom/sso/callback"
|
|
|
|
|
|
def _build_oauth_url(state: str, callback_url: str) -> str:
|
|
"""拼企微 OAuth2 授权 URL。
|
|
|
|
文档: https://developer.work.weixin.qq.com/document/path/91022
|
|
"""
|
|
params = {
|
|
"appid": settings.wecom_corp_id,
|
|
"redirect_uri": callback_url,
|
|
"response_type": "code",
|
|
"scope": "snsapi_base", # 静默授权
|
|
"state": state,
|
|
"agentid": settings.wecom_agent_id,
|
|
}
|
|
query = urllib.parse.urlencode(params)
|
|
return f"https://open.weixin.qq.com/connect/oauth2/authorize?{query}#wechat_redirect"
|
|
|
|
|
|
@router.get("/sso/init")
|
|
async def sso_init(
|
|
request: Request,
|
|
next: str = Query("/itdesk/", description="登录后跳转路径"),
|
|
redis_client = Depends(get_redis),
|
|
):
|
|
"""初始化 SSO: 生成 state,302 跳转到企微 OAuth2 授权页。
|
|
|
|
Args:
|
|
next: 登录成功后跳转路径,如 /itdesk/ /itagent/ /itadmin/
|
|
"""
|
|
if not _sso_enabled():
|
|
raise AppException(1001, "企微 SSO 未启用, 请用扫码登录")
|
|
|
|
# 1. 生成 state(防 CSRF + 携带 next 路径)
|
|
state = secrets.token_urlsafe(24)
|
|
state_payload = {
|
|
"next": next,
|
|
"created_at": datetime.now().isoformat(),
|
|
}
|
|
await redis_client.setex(
|
|
f"wecom_sso:state:{state}",
|
|
OAUTH_STATE_TTL,
|
|
str(state_payload).encode("utf-8"),
|
|
)
|
|
|
|
# 2. 拼企微 OAuth URL
|
|
callback_url = _get_oauth_callback_url(request)
|
|
oauth_url = _build_oauth_url(state, callback_url)
|
|
|
|
logger.info(f"SSO init: state={state[:8]}..., next={next}")
|
|
return RedirectResponse(url=oauth_url, status_code=302)
|
|
|
|
|
|
@router.get("/sso/callback")
|
|
async def sso_callback(
|
|
code: str = Query(..., description="企微 OAuth2 授权 code"),
|
|
state: str = Query(..., description="防 CSRF state"),
|
|
redis_client = Depends(get_redis),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""企微 OAuth 回调: 用 code 换 userid → 查 role → 生成 token → 跳 next。"""
|
|
# 1. 校验 state(防 CSRF)
|
|
state_key = f"wecom_sso:state:{state}"
|
|
state_raw = await redis_client.get(state_key)
|
|
if not state_raw:
|
|
raise AppException(1002, "SSO state 已过期或无效, 请重新进入")
|
|
|
|
# 删除 state(一次性)
|
|
await redis_client.delete(state_key)
|
|
|
|
import ast
|
|
state_data = ast.literal_eval(state_raw.decode("utf-8"))
|
|
next_path = state_data.get("next", "/itdesk/")
|
|
|
|
# 2. 用 code 换 userid
|
|
wecom = WecomService(redis_client)
|
|
try:
|
|
oauth_info = await wecom.get_oauth_user_info(code)
|
|
user_id = oauth_info.get("userid", "")
|
|
if not user_id:
|
|
raise AppException(1003, "企微 OAuth 返回 userid 为空")
|
|
|
|
user_info = await wecom.get_user_info(user_id)
|
|
name = user_info.get("name", user_id)
|
|
except Exception as e:
|
|
logger.error(f"SSO callback 调企微 API 失败: code={code[:8]}..., error={e}")
|
|
raise AppException(1004, f"企微身份识别失败: {str(e)}")
|
|
finally:
|
|
try:
|
|
await wecom.close()
|
|
except Exception:
|
|
pass
|
|
|
|
# 3. 查 role (user/agent/admin)
|
|
role_stmt = (
|
|
select(Role)
|
|
.join(UserRole, Role.id == UserRole.role_id)
|
|
.where(UserRole.employee_id == user_id)
|
|
)
|
|
role_result = await db.execute(role_stmt)
|
|
roles = role_result.scalars().all()
|
|
|
|
if not roles:
|
|
# 没有绑定角色: 跳"无权限"页
|
|
logger.warning(f"SSO: user_id={user_id} 没绑定任何角色")
|
|
return RedirectResponse(url=f"/itdesk/no-role?user_id={user_id}", status_code=302)
|
|
|
|
# 4. 选最高权限角色 (admin > agent > user)
|
|
role_priority = {"admin": 3, "agent": 2, "user": 1}
|
|
best_role = max(roles, key=lambda r: role_priority.get(r.name, 0))
|
|
role_name = best_role.name
|
|
|
|
# 5. 生成 SSO token(随机 + Redis 存 8 小时)
|
|
sso_token = secrets.token_urlsafe(SSO_TOKEN_BYTES)
|
|
sso_payload = {
|
|
"user_id": user_id,
|
|
"name": name,
|
|
"role": role_name,
|
|
"created_at": datetime.now().isoformat(),
|
|
}
|
|
import json
|
|
await redis_client.setex(
|
|
f"wecom_sso:token:{sso_token}",
|
|
8 * 3600, # 8 小时
|
|
json.dumps(sso_payload, ensure_ascii=False).encode("utf-8"),
|
|
)
|
|
|
|
# 6. 跳转到 next + token
|
|
separator = "&" if "?" in next_path else "?"
|
|
redirect_url = f"{next_path}{separator}sso_token={sso_token}"
|
|
|
|
logger.info(f"SSO 成功: user_id={user_id}, role={role_name}, next={next_path}")
|
|
return RedirectResponse(url=redirect_url, status_code=302)
|
|
|
|
|
|
@router.get("/sso/verify")
|
|
async def sso_verify(
|
|
sso_token: str = Query(..., description="SSO token"),
|
|
redis_client = Depends(get_redis),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""前端用 SSO token 换用户身份(token 一次性使用,用完删除)。"""
|
|
import json
|
|
token_raw = await redis_client.get(f"wecom_sso:token:{sso_token}")
|
|
if not token_raw:
|
|
raise AppException(1005, "SSO token 已过期或无效")
|
|
|
|
# 一次性 token(防止泄漏后被滥用)
|
|
await redis_client.delete(f"wecom_sso:token:{sso_token}")
|
|
|
|
payload = json.loads(token_raw.decode("utf-8"))
|
|
return {
|
|
"code": 0,
|
|
"data": payload,
|
|
}
|