实现了 Teams 机器人所需的全功能组件,包括: - 基础命令解析与帮助卡片生成 - 租户验证与访问控制 - 自定义 UA 与媒体工具 - 消息分块、批注处理与会话管理 - 防抖、缓存与配置路由能力 - 投票、配对、审计与运行时状态管理 - TTS 语音合成与卡片构建工具 - 群组管理与权限控制逻辑
319 lines
10 KiB
Python
319 lines
10 KiB
Python
"""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
|