Files

119 lines
4.5 KiB
Python

from dataclasses import dataclass
from urllib.parse import urlparse
import httpx
class BitrixAuthError(RuntimeError):
pass
@dataclass(frozen=True)
class BitrixUser:
id: int
name: str
@dataclass(frozen=True)
class BitrixAuth:
member_id: str
domain: str
access_token: str
refresh_token: str
expires_in: int
user: BitrixUser
class BitrixClient:
"""Получает доверенный OAuth-контекст и проверяет пользователя."""
def __init__(
self,
client_id: str,
client_secret: str,
oauth_token_url: str,
client: httpx.Client | None = None,
) -> None:
self.client_id = client_id
self.client_secret = client_secret
self.oauth_token_url = oauth_token_url
# Клиент передается как внешняя зависимость для модульного тестирования.
self._client = client or httpx.Client(timeout=15)
# Соответственно, если клиент внешний, то класс
# этим ресурсом не управляет.
self._owns_client = client is None
def authorize(self, refresh_token: str) -> BitrixAuth:
try:
# Обмениваем рефреш-токен на новую пару токенов.
# https://apidocs.bitrix24.com/settings/oauth/auto-renewal.html
# https://apidocs.bitrix24.com/settings/oauth/simple-way.html
response = self._client.get(
self.oauth_token_url,
params={
"grant_type": "refresh_token",
"client_id": self.client_id,
"client_secret": self.client_secret,
"refresh_token": refresh_token,
},
)
response.raise_for_status()
except httpx.HTTPError:
# URL запроса содержит секреты, поэтому не пробрасываем его выше.
raise BitrixAuthError("Не удалось обновить OAuth-токен") from None
data = response.json()
if "error" in data:
raise BitrixAuthError(
str(data.get("error_description") or data["error"]))
# Получаем эндпоинт, с которым связаны наши токены.
endpoint = str(data.get("client_endpoint") or "")
parsed_endpoint = urlparse(endpoint)
if parsed_endpoint.scheme != "https" or not parsed_endpoint.hostname:
raise BitrixAuthError("Bitrix вернул некорректный REST endpoint")
# Сохраняем токен доступа и проверяем пользователя.
access_token = str(data["access_token"])
user = self._current_user(endpoint, access_token)
expected_user_id = data.get("user_id")
if expected_user_id is not None and user.id != int(expected_user_id):
raise BitrixAuthError(
"OAuth-токен принадлежит другому пользователю")
return BitrixAuth(
member_id=str(data["member_id"]),
domain=parsed_endpoint.hostname.lower(),
access_token=access_token,
refresh_token=str(data["refresh_token"]),
expires_in=int(data.get("expires_in", 3600)),
user=user,
)
def _current_user(self, endpoint: str, access_token: str) -> BitrixUser:
"""Получение информации о пользователе для проверки работоспособности."""
# https://apidocs.bitrix24.com/api-reference/user/user-current.html
response = self._client.post(
endpoint.rstrip("/") + "/user.current.json",
data={"auth": access_token},
)
response.raise_for_status()
data = response.json()
if "error" in data or not data.get("result"):
raise BitrixAuthError("Bitrix не подтвердил текущего пользователя")
user = data["result"]
name = " ".join(
part
for part in (
str(user.get("NAME") or "").strip(),
str(user.get("LAST_NAME") or "").strip(),
)
if part
)
return BitrixUser(id=int(user["ID"]), name=name or f"ID {user['ID']}")
def close(self) -> None:
if self._owns_client:
self._client.close()