实现了 Teams 机器人所需的全功能组件,包括: - 基础命令解析与帮助卡片生成 - 租户验证与访问控制 - 自定义 UA 与媒体工具 - 消息分块、批注处理与会话管理 - 防抖、缓存与配置路由能力 - 投票、配对、审计与运行时状态管理 - TTS 语音合成与卡片构建工具 - 群组管理与权限控制逻辑
217 lines
7.8 KiB
Python
217 lines
7.8 KiB
Python
"""Microsoft Teams 凭据管理。
|
|
|
|
支持 Secret、Certificate (Federated) 和 Managed Identity 三种凭据模式,
|
|
以及 Delegated Auth (OAuth 2.0 Refresh Token)。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import aiohttp
|
|
|
|
from yuxi.utils.logging_config import logger
|
|
|
|
TOKEN_URL_TEMPLATE = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
|
|
BOT_SCOPE = "https://api.botframework.com/.default"
|
|
GRAPH_SCOPE = "https://graph.microsoft.com/.default"
|
|
TOKEN_GRACE_PERIOD_S = 60
|
|
|
|
DELEGATED_STORE_FILENAME = "msteams-delegated.json"
|
|
DELEGATED_REFRESH_BUFFER_S = 300
|
|
|
|
|
|
class FederatedCredential:
|
|
def __init__(
|
|
self,
|
|
client_id: str,
|
|
tenant_id: str = "common",
|
|
certificate_path: str = "",
|
|
certificate_thumbprint: str = "",
|
|
use_managed_identity: bool = False,
|
|
):
|
|
self.client_id = client_id
|
|
self.tenant_id = tenant_id
|
|
self.certificate_path = certificate_path
|
|
self.certificate_thumbprint = certificate_thumbprint
|
|
self.use_managed_identity = use_managed_identity
|
|
|
|
@property
|
|
def auth_type(self) -> str:
|
|
if self.use_managed_identity:
|
|
return "managed_identity"
|
|
if self.certificate_path:
|
|
return "certificate"
|
|
return "secret"
|
|
|
|
async def get_token(self, scope: str) -> str | None:
|
|
if self.use_managed_identity:
|
|
return await self._get_managed_identity_token(scope)
|
|
if self.certificate_path:
|
|
return await self._get_certificate_token(scope)
|
|
return None
|
|
|
|
async def _get_managed_identity_token(self, scope: str) -> str | None:
|
|
endpoint = os.getenv("MSI_ENDPOINT", "http://169.254.169.254/metadata/identity/oauth2/token")
|
|
params = {
|
|
"api-version": "2019-08-01",
|
|
"resource": scope.rstrip("/.default"),
|
|
"client_id": self.client_id,
|
|
}
|
|
try:
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.get(
|
|
endpoint,
|
|
params=params,
|
|
headers={"Metadata": "true"},
|
|
) as resp:
|
|
if resp.status == 200:
|
|
result = await resp.json()
|
|
return result.get("access_token")
|
|
logger.warning(f"Managed Identity token failed: HTTP {resp.status}")
|
|
except Exception as e:
|
|
logger.warning(f"Managed Identity token error: {e}")
|
|
return None
|
|
|
|
async def _get_certificate_token(self, scope: str) -> str | None:
|
|
try:
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.backends import default_backend
|
|
import jwt as pyjwt
|
|
|
|
cert_path = Path(self.certificate_path)
|
|
if not cert_path.exists():
|
|
logger.error(f"Certificate not found: {self.certificate_path}")
|
|
return None
|
|
|
|
with open(cert_path, "rb") as f:
|
|
private_key = serialization.load_pem_private_key(f.read(), password=None, backend=default_backend())
|
|
|
|
token_url = TOKEN_URL_TEMPLATE.format(tenant=self.tenant_id)
|
|
now = int(time.time())
|
|
assertion = pyjwt.encode(
|
|
{
|
|
"aud": token_url,
|
|
"iss": self.client_id,
|
|
"sub": self.client_id,
|
|
"jti": os.urandom(16).hex(),
|
|
"nbf": now,
|
|
"exp": now + 600,
|
|
},
|
|
private_key,
|
|
algorithm="RS256",
|
|
headers={"x5t": self.certificate_thumbprint} if self.certificate_thumbprint else {},
|
|
)
|
|
|
|
data = {
|
|
"client_id": self.client_id,
|
|
"client_assertion": assertion,
|
|
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
|
"scope": scope,
|
|
"grant_type": "client_credentials",
|
|
}
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.post(token_url, data=data) as resp:
|
|
if resp.status == 200:
|
|
result = await resp.json()
|
|
return result.get("access_token")
|
|
body = await resp.text()
|
|
logger.warning(f"Certificate token failed: HTTP {resp.status} - {body[:200]}")
|
|
except ImportError:
|
|
logger.error("cryptography and PyJWT required for certificate auth")
|
|
except Exception as e:
|
|
logger.error(f"Certificate token error: {e}")
|
|
return None
|
|
|
|
|
|
class DelegatedAuthStore:
|
|
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 / DELEGATED_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"))
|
|
except (json.JSONDecodeError, OSError):
|
|
return
|
|
self._tokens = data.get("tokens", {})
|
|
|
|
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 delegated store: failed to save: {e}")
|
|
|
|
def store_token(self, user_id: str, access_token: str, refresh_token: str, expires_in: int = 3600) -> None:
|
|
self._tokens[user_id] = {
|
|
"access_token": access_token,
|
|
"refresh_token": refresh_token,
|
|
"expires_at": time.time() + expires_in,
|
|
}
|
|
self._save()
|
|
|
|
def get_token(self, user_id: str) -> dict[str, Any] | None:
|
|
entry = self._tokens.get(user_id)
|
|
if not entry:
|
|
return None
|
|
return entry
|
|
|
|
async def refresh_token(
|
|
self,
|
|
user_id: str,
|
|
client_id: str,
|
|
client_secret: str,
|
|
) -> str | None:
|
|
entry = self._tokens.get(user_id)
|
|
if not entry or not entry.get("refresh_token"):
|
|
return None
|
|
|
|
token_url = TOKEN_URL_TEMPLATE.format(tenant="common")
|
|
data = {
|
|
"client_id": client_id,
|
|
"client_secret": client_secret,
|
|
"refresh_token": entry["refresh_token"],
|
|
"grant_type": "refresh_token",
|
|
"scope": GRAPH_SCOPE,
|
|
}
|
|
try:
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.post(token_url, data=data) as resp:
|
|
if resp.status == 200:
|
|
result = await resp.json()
|
|
new_token = result.get("access_token", "")
|
|
new_refresh = result.get("refresh_token", "")
|
|
if new_token:
|
|
self.store_token(
|
|
user_id,
|
|
new_token,
|
|
new_refresh or entry["refresh_token"],
|
|
result.get("expires_in", 3600),
|
|
)
|
|
return new_token
|
|
logger.warning(f"Delegated token refresh failed: HTTP {resp.status}")
|
|
except Exception as e:
|
|
logger.error(f"Delegated token refresh error: {e}")
|
|
return None
|
|
|
|
def is_expired(self, user_id: str) -> bool:
|
|
entry = self._tokens.get(user_id)
|
|
if not entry:
|
|
return True
|
|
return time.time() >= entry.get("expires_at", 0) - DELEGATED_REFRESH_BUFFER_S
|