ForcePilot/backend/package/yuxi/channel/extensions/googlechat/auth.py
Kris c4e301a5e0 feat(googlechat): 新增Google Chat渠道完整实现
新增Google Chat插件的所有核心功能模块,包括账户配置、消息收发、权限控制、媒体处理、会话管理等完整能力
2026-05-21 10:49:33 +08:00

195 lines
6.1 KiB
Python

from __future__ import annotations
import json
import logging
import re
import time
import hashlib
from google.oauth2 import service_account
from google.oauth2 import id_token as google_id_token
from google.auth.transport import requests as google_requests
import httpx
import jwt
from yuxi.channel.extensions.googlechat.types import ResolvedGoogleChatAccount
logger = logging.getLogger(__name__)
CHAT_API_SCOPE = "https://www.googleapis.com/auth/chat.bot"
CERTS_URL = "https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com"
CERTS_TTL = 600
_STANDARD_CHAT_ISSUER = "chat@system.gserviceaccount.com"
_ADDON_ISSUER_PATTERN = re.compile(r"^service-\d+@gcp-sa-gsuiteaddons\.iam\.gserviceaccount\.com$")
_auth_cache: dict[str, tuple[str, service_account.Credentials]] = {}
_MAX_AUTH_CACHE = 32
_cert_cache: tuple[float, dict[str, str]] | None = None
_http_client: httpx.AsyncClient | None = None
async def _get_http() -> httpx.AsyncClient:
global _http_client
if _http_client is None:
_http_client = httpx.AsyncClient(timeout=httpx.Timeout(30.0))
return _http_client
def _normalize_private_key(key: str) -> str:
return key.replace("\\n", "\n")
def _build_auth_key(sa_info: dict, sa_file: str | None) -> str:
if sa_file:
return f"file:{sa_file}"
if sa_info:
key_hash = hashlib.sha256(
json.dumps({"client_email": sa_info.get("client_email"), "project_id": sa_info.get("project_id")}).encode()
).hexdigest()[:16]
return f"inline:{key_hash}"
return "none"
def get_or_create_credentials(
account_id: str, sa_info: dict, sa_file: str | None
) -> service_account.Credentials:
key = _build_auth_key(sa_info, sa_file)
cached = _auth_cache.get(account_id)
if cached and cached[0] == key:
return cached[1]
private_key = _normalize_private_key(sa_info.get("private_key", ""))
creds = service_account.Credentials.from_service_account_info(
{
**sa_info,
"private_key": private_key,
},
scopes=[CHAT_API_SCOPE],
)
if len(_auth_cache) >= _MAX_AUTH_CACHE:
oldest = next(iter(_auth_cache))
_auth_cache.pop(oldest)
_auth_cache[account_id] = (key, creds)
return creds
async def get_access_token(account: ResolvedGoogleChatAccount) -> str | None:
if not account.service_account_info:
return None
creds = get_or_create_credentials(
account.account_id,
account.service_account_info,
account.service_account_file,
)
request = google_requests.Request()
creds.refresh(request)
return creds.token
def decode_unverified_jwt_payload(token: str) -> dict:
import base64
parts = token.split(".")
if len(parts) < 2:
return {}
try:
padded = parts[1] + "=" * (4 - len(parts[1]) % 4)
decoded = base64.b64decode(padded.replace("-", "+").replace("_", "/"), validate=False)
return json.loads(decoded)
except Exception:
return {}
async def _google_verify_id_token(token: str, audience: str) -> dict:
request = google_requests.Request()
return google_id_token.verify_oauth2_token(token, request, audience)
async def _fetch_chat_certs() -> dict[str, str]:
global _cert_cache
now = time.monotonic()
if _cert_cache and now - _cert_cache[0] < CERTS_TTL:
return _cert_cache[1]
http = await _get_http()
resp = await http.get(CERTS_URL)
resp.raise_for_status()
certs = resp.json()
_cert_cache = (now, certs)
return certs
async def _verify_signed_jwt_with_certs(token: str, audience: str, certs: dict[str, str]) -> dict:
header = jwt.get_unverified_header(token)
kid = header.get("kid", "")
cert = certs.get(kid)
if not cert:
raise jwt.InvalidTokenError(f"No matching cert for kid: {kid}")
return jwt.decode(token, cert, algorithms=["RS256"], audience=audience)
async def verify_webhook_token(
token: str, audience_type: str, audience: str, app_principal: str | None
) -> tuple[bool, dict, str | None]:
payload = decode_unverified_jwt_payload(token)
iss = payload.get("iss", "")
if iss == _STANDARD_CHAT_ISSUER:
try:
if audience_type == "app-url":
verified = await _google_verify_id_token(token, audience)
elif audience_type == "project-number":
certs = await _fetch_chat_certs()
verified = await _verify_signed_jwt_with_certs(token, audience, certs)
else:
return False, payload, f"unsupported audience_type: {audience_type}"
return True, verified, None
except Exception as e:
return False, payload, f"token verification failed: {e}"
if _ADDON_ISSUER_PATTERN.match(iss):
if app_principal and payload.get("sub") != app_principal:
return False, payload, "token sub does not match appPrincipal"
try:
certs = await _fetch_chat_certs()
verified = await _verify_signed_jwt_with_certs(token, audience, certs)
return True, verified, None
except Exception as e:
return False, payload, f"add-on token verification failed: {e}"
return False, payload, f"invalid issuer: {iss}"
async def probe_account(account: ResolvedGoogleChatAccount) -> dict:
token = await get_access_token(account)
if not token:
return {"ok": False, "status": "no_token", "error": "Failed to obtain access token"}
http = await _get_http()
try:
resp = await http.get(
"https://chat.googleapis.com/v1/spaces?pageSize=1",
headers={"Authorization": f"Bearer {token}"},
timeout=10.0,
)
resp.raise_for_status()
return {"ok": True, "status": "connected"}
except httpx.HTTPStatusError as e:
return {"ok": False, "status": f"http_{e.response.status_code}", "error": str(e)}
except Exception as e:
return {"ok": False, "status": "error", "error": str(e)}
async def close_auth_http() -> None:
global _http_client
if _http_client:
await _http_client.aclose()
_http_client = None