ForcePilot/backend/package/yuxi/channels/adapters/msteams/sso.py
Kris bd60c15df0 feat(msteams): 新增完整的 Microsoft Teams 适配器模块
实现了 Teams 机器人所需的全功能组件,包括:
- 基础命令解析与帮助卡片生成
- 租户验证与访问控制
- 自定义 UA 与媒体工具
- 消息分块、批注处理与会话管理
- 防抖、缓存与配置路由能力
- 投票、配对、审计与运行时状态管理
- TTS 语音合成与卡片构建工具
- 群组管理与权限控制逻辑
2026-05-12 00:46:44 +08:00

319 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Microsoft Teams Bot Framework SSO (Single Sign-On) 处理。
处理 signin/tokenExchange 和 signin/verifyState invoke 交互,
实现 Bot Framework OAuth 卡片授权流程、SSO Token 持久化存储、
以及 DM/Channel/Group 三级 SSO 授权检查。
"""
from __future__ import annotations
import json
import time
from pathlib import Path
from typing import Any
import aiohttp
from yuxi.utils.logging_config import logger
SIGNIN_TOKEN_EXCHANGE = "signin/tokenExchange"
SIGNIN_VERIFY_STATE = "signin/verifyState"
BOT_USER_TOKEN_SERVICE = "https://token.botframework.com/api/usertoken/GetToken"
BOT_USER_TOKEN_EXCHANGE = "https://token.botframework.com/api/usertoken/ExchangeToken"
SSO_TOKEN_STORE_FILENAME = "msteams-sso-tokens.json"
SSO_TOKEN_TTL_SECONDS = 86400
def is_signin_invoke(activity: dict[str, Any]) -> bool:
name = activity.get("name", "")
return name in (SIGNIN_TOKEN_EXCHANGE, SIGNIN_VERIFY_STATE)
def is_token_exchange(activity: dict[str, Any]) -> bool:
return activity.get("name", "") == SIGNIN_TOKEN_EXCHANGE
def is_verify_state(activity: dict[str, Any]) -> bool:
return activity.get("name", "") == SIGNIN_VERIFY_STATE
def extract_signin_token(activity: dict[str, Any]) -> dict[str, Any]:
value = activity.get("value", {}) or {}
return {
"token": value.get("token", ""),
"id": value.get("id", ""),
"state": value.get("state", ""),
}
def build_oauth_card(
connection_name: str,
title: str = "Sign in",
text: str = "Please sign in to continue.",
button_text: str = "Sign in",
) -> dict[str, Any]:
return {
"type": "AdaptiveCard",
"version": "1.5",
"body": [
{"type": "TextBlock", "size": "Large", "weight": "Bolder", "text": title},
{"type": "TextBlock", "text": text, "wrap": True},
],
"actions": [
{
"type": "Action.OpenUrl",
"title": button_text,
"url": f"https://token.botframework.com/api/oauth/signin?signin={connection_name}",
}
],
}
def build_token_exchange_response(
activity_id: str,
status_code: int = 200,
) -> dict[str, Any]:
return {
"id": activity_id,
"type": "invokeResponse",
"status": status_code,
}
def build_sso_auth_activity(
text: str = "Authentication successful",
connection_name: str = "",
) -> dict[str, Any]:
activity: dict[str, Any] = {
"type": "message",
"text": text,
"textFormat": "markdown",
}
if connection_name:
activity["channelData"] = {
"oauthConnectionName": connection_name,
}
return activity
class SSOTokenStore:
"""SSO Token 文件持久化存储。"""
def __init__(self, storage_dir: str | None = None):
self._storage_dir = Path(storage_dir or str(Path.home() / ".yuxi" / "msteams"))
self._storage_dir.mkdir(parents=True, exist_ok=True)
self._tokens: dict[str, dict[str, Any]] = {}
self._load()
@property
def file_path(self) -> Path:
return self._storage_dir / SSO_TOKEN_STORE_FILENAME
def _load(self) -> None:
if not self.file_path.exists():
return
try:
data = json.loads(self.file_path.read_text(encoding="utf-8"))
self._tokens = data.get("tokens", {})
except (json.JSONDecodeError, OSError):
pass
def _save(self) -> None:
try:
self.file_path.write_text(
json.dumps({"tokens": self._tokens}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
except OSError as e:
logger.error(f"MSTeams SSO token store: failed to save: {e}")
def store(self, user_id: str, token_data: dict[str, Any]) -> None:
self._tokens[user_id] = {
"token_data": token_data,
"stored_at": time.time(),
}
self._save()
def get(self, user_id: str) -> dict[str, Any] | None:
entry = self._tokens.get(user_id)
if not entry:
return None
stored_at = entry.get("stored_at", 0)
if time.time() - stored_at > SSO_TOKEN_TTL_SECONDS:
self.remove(user_id)
return None
return entry.get("token_data")
def remove(self, user_id: str) -> None:
self._tokens.pop(user_id, None)
self._save()
def clear(self) -> None:
self._tokens.clear()
self._save()
class SSOHandler:
def __init__(
self,
connection_name: str = "",
enabled: bool = False,
verify_state_fallback: bool = False,
):
self.connection_name = connection_name
self.enabled = enabled
self.verify_state_fallback = verify_state_fallback
self._verified_states: set[str] = set()
self._token_store = SSOTokenStore()
self._session: aiohttp.ClientSession | None = None
@property
def token_store(self) -> SSOTokenStore:
return self._token_store
async def _ensure_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
return self._session
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
self._session = None
def add_verified_state(self, state: str) -> None:
self._verified_states.add(state)
def is_state_verified(self, state: str) -> bool:
return state in self._verified_states
async def exchange_token(
self,
user_token: str,
bot_token: str,
connection_name: str = "",
) -> dict[str, Any]:
conn_name = connection_name or self.connection_name
if not conn_name:
return {"error": "missing_connection_name"}
headers = {
"Authorization": f"Bearer {bot_token}",
"Content-Type": "application/json",
}
body = {
"userToken": user_token,
"connectionName": conn_name,
"channelId": "msteams",
}
try:
session = await self._ensure_session()
async with session.post(BOT_USER_TOKEN_EXCHANGE, headers=headers, json=body) as resp:
if resp.status == 200:
result = await resp.json()
return {
"success": True,
"token": result.get("token", ""),
"expiration": result.get("expiration", ""),
"connectionName": result.get("connectionName", ""),
}
body_text = await resp.text()
logger.warning(f"SSO token exchange failed: HTTP {resp.status} - {body_text[:300]}")
return {"error": f"exchange_failed_{resp.status}", "detail": body_text[:300]}
except Exception as e:
logger.error(f"SSO token exchange error: {e}")
return {"error": "network_error", "detail": str(e)}
async def get_user_token(
self,
user_id: str,
bot_token: str,
connection_name: str = "",
) -> dict[str, Any]:
conn_name = connection_name or self.connection_name
if not conn_name:
return {"error": "missing_connection_name"}
headers = {
"Authorization": f"Bearer {bot_token}",
}
try:
session = await self._ensure_session()
params = {"userId": user_id, "connectionName": conn_name, "channelId": "msteams"}
async with session.get(BOT_USER_TOKEN_SERVICE, headers=headers, params=params) as resp:
if resp.status == 200:
result = await resp.json()
return {"success": True, **result}
body_text = await resp.text()
return {"error": f"get_token_failed_{resp.status}", "detail": body_text[:300]}
except Exception as e:
logger.error(f"SSO get user token error: {e}")
return {"error": "network_error", "detail": str(e)}
def check_sso_authorization(
self,
chat_type: str,
conversation_id: str,
dm_policy: str = "open",
group_policy: str = "open",
) -> bool:
"""三重检查DM / Channel / Group 三级 SSO 授权。
Returns True 如果该 chat_type 允许 SSO。
"""
if chat_type in ("direct",):
return dm_policy != "disabled"
if chat_type in ("channel",):
return group_policy != "disabled"
if chat_type in ("group", "thread"):
return group_policy != "disabled"
return False
async def handle_signin(self, activity: dict[str, Any]) -> dict[str, Any] | None:
if not self.enabled:
logger.debug("MSTeams SSO: SSO not enabled")
return None
if is_token_exchange(activity):
token_info = extract_signin_token(activity)
from_info = activity.get("from", {}) or {}
user_id = from_info.get("aadObjectId", "") or from_info.get("id", "")
logger.info(f"MSTeams SSO: token exchange from {from_info.get('name', 'unknown')}")
if token_info.get("token"):
self._token_store.store(
user_id,
{
"token": token_info["token"],
"state": token_info.get("state", ""),
"connection_name": self.connection_name,
},
)
return {
"activity_id": activity.get("id", ""),
"response": build_token_exchange_response(activity.get("id", ""), 200),
"token_info": token_info,
"user_id": user_id,
}
if is_verify_state(activity):
state = (activity.get("value", {}) or {}).get("state", "")
self.add_verified_state(state)
logger.info(f"MSTeams SSO: state verified: {state[:20]}...")
return {
"activity_id": activity.get("id", ""),
"response": build_token_exchange_response(activity.get("id", ""), 200),
"state": state,
}
return None