Добавление всех наработок за период практики.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from .app import create_app
|
||||
|
||||
__all__ = ["create_app"]
|
||||
@@ -0,0 +1,69 @@
|
||||
import atexit
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from flask import Flask, jsonify
|
||||
from dotenv import load_dotenv
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
from .binding import BindingService, SiteBindingRepository
|
||||
from .bitrix import BitrixAuthError, BitrixClient
|
||||
from .config import SiteConfig
|
||||
from .crypto import TokenCipher
|
||||
from .database import SiteDatabase
|
||||
from .routes import SiteInputError, create_blueprint
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
load_dotenv()
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
config = SiteConfig.from_env()
|
||||
app = Flask(__name__)
|
||||
app.config["PUBLIC_BASE_URL"] = config.public_base_url
|
||||
|
||||
if config.trust_proxy:
|
||||
# Используется конфигурация, при которой снаружи контейнера находится
|
||||
# обратный прокси nginx.
|
||||
app.wsgi_app = ProxyFix(
|
||||
app.wsgi_app,
|
||||
x_for=1,
|
||||
x_proto=1,
|
||||
x_host=1,
|
||||
)
|
||||
|
||||
database = SiteDatabase(config.database_url)
|
||||
database.open()
|
||||
bitrix = BitrixClient(
|
||||
config.bitrix_client_id,
|
||||
config.bitrix_client_secret,
|
||||
config.oauth_token_url,
|
||||
)
|
||||
bindings = BindingService(
|
||||
SiteBindingRepository(database),
|
||||
TokenCipher(config.token_encryption_key),
|
||||
config.bot_username,
|
||||
config.binding_ttl_seconds,
|
||||
)
|
||||
|
||||
app.extensions["database"] = database
|
||||
app.register_blueprint(create_blueprint(bindings, bitrix))
|
||||
atexit.register(database.close)
|
||||
atexit.register(bitrix.close)
|
||||
|
||||
@app.errorhandler(SiteInputError)
|
||||
def input_error(error: SiteInputError):
|
||||
return jsonify(error=str(error)), 400
|
||||
|
||||
@app.errorhandler(BitrixAuthError)
|
||||
def auth_error(error: BitrixAuthError):
|
||||
return jsonify(error=str(error)), 403
|
||||
|
||||
@app.errorhandler(httpx.HTTPError)
|
||||
def bitrix_error(error: httpx.HTTPError):
|
||||
logger.exception("Bitrix request failed")
|
||||
return jsonify(error="Не удалось проверить пользователя Bitrix24"), 502
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,98 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from .database import SiteDatabase
|
||||
from .crypto import TokenCipher
|
||||
|
||||
|
||||
def hash_token(token: str) -> bytes:
|
||||
return hashlib.sha256(token.encode("utf-8")).digest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BindingLink:
|
||||
url: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class SiteBindingRepository:
|
||||
"""Доступ сайта только к функциям выпуска токенов."""
|
||||
|
||||
def __init__(self, database: SiteDatabase) -> None:
|
||||
self.database = database
|
||||
|
||||
def issue(
|
||||
self,
|
||||
member_id: str,
|
||||
domain: str,
|
||||
bitrix_user_id: int,
|
||||
token_hash: bytes,
|
||||
token_expires_at: datetime,
|
||||
access_token: bytes,
|
||||
refresh_token: bytes,
|
||||
oauth_expires_at: datetime,
|
||||
) -> None:
|
||||
"""Сохраняет в БД информацию о токене, выданном пользователю."""
|
||||
query = """
|
||||
SELECT *
|
||||
FROM binding.issue_v1(%s, %s, %s, %s, %s, %s, %s, %s) \
|
||||
"""
|
||||
with self.database.transaction() as connection:
|
||||
connection.execute(
|
||||
query,
|
||||
(
|
||||
member_id,
|
||||
domain,
|
||||
bitrix_user_id,
|
||||
token_hash,
|
||||
token_expires_at,
|
||||
access_token,
|
||||
refresh_token,
|
||||
oauth_expires_at,
|
||||
),
|
||||
).fetchone()
|
||||
|
||||
|
||||
class BindingService:
|
||||
def __init__(
|
||||
self,
|
||||
repository: SiteBindingRepository,
|
||||
cipher: TokenCipher,
|
||||
bot_username: str,
|
||||
ttl_seconds: int,
|
||||
) -> None:
|
||||
self.repository = repository
|
||||
self.cipher = cipher
|
||||
self.bot_username = bot_username
|
||||
self.ttl_seconds = ttl_seconds
|
||||
|
||||
def issue(
|
||||
self,
|
||||
member_id: str,
|
||||
domain: str,
|
||||
bitrix_user_id: int,
|
||||
access_token: str,
|
||||
refresh_token: str,
|
||||
auth_expires_seconds: int,
|
||||
) -> BindingLink:
|
||||
"""Выдает ссылку для привязки аккаунта."""
|
||||
token = secrets.token_urlsafe(32)
|
||||
now = datetime.now(UTC)
|
||||
token_expires_at = now + timedelta(seconds=self.ttl_seconds)
|
||||
oauth_expires_at = now + timedelta(seconds=auth_expires_seconds)
|
||||
self.repository.issue(
|
||||
member_id,
|
||||
domain,
|
||||
bitrix_user_id,
|
||||
hash_token(token),
|
||||
token_expires_at,
|
||||
self.cipher.encrypt(access_token),
|
||||
self.cipher.encrypt(refresh_token),
|
||||
oauth_expires_at,
|
||||
)
|
||||
return BindingLink(
|
||||
url=f"https://t.me/{self.bot_username}?start=bind_{token}",
|
||||
expires_at=token_expires_at,
|
||||
)
|
||||
@@ -0,0 +1,118 @@
|
||||
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()
|
||||
@@ -0,0 +1,57 @@
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _required(name: str) -> str:
|
||||
value = os.getenv(name)
|
||||
if not value:
|
||||
raise RuntimeError(f"{name} is not set")
|
||||
return value
|
||||
|
||||
|
||||
def _as_bool(value: str | None, default: bool = False) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
return value.lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SiteConfig:
|
||||
"""Настройки HTTP-приложения."""
|
||||
|
||||
database_url: str
|
||||
public_base_url: str
|
||||
bot_username: str
|
||||
token_encryption_key: str
|
||||
bitrix_client_id: str
|
||||
bitrix_client_secret: str
|
||||
oauth_token_url: str
|
||||
binding_ttl_seconds: int = 600
|
||||
trust_proxy: bool = True
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "SiteConfig":
|
||||
public_base_url = _required("PUBLIC_BASE_URL").rstrip("/")
|
||||
parsed_url = urlparse(public_base_url)
|
||||
if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc:
|
||||
raise RuntimeError("PUBLIC_BASE_URL must be an absolute URL")
|
||||
|
||||
ttl = int(os.getenv("BINDING_TOKEN_TTL_SECONDS", "600"))
|
||||
if not 60 <= ttl <= 3600:
|
||||
raise RuntimeError("BINDING_TOKEN_TTL_SECONDS must be 60..3600")
|
||||
|
||||
return cls(
|
||||
database_url=_required("DATABASE_URL"),
|
||||
public_base_url=public_base_url,
|
||||
bot_username=_required("BOT_USERNAME").lstrip("@"),
|
||||
token_encryption_key=_required("TOKEN_ENCRYPTION_KEY"),
|
||||
bitrix_client_id=_required("BITRIX_CLIENT_ID"),
|
||||
bitrix_client_secret=_required("BITRIX_CLIENT_SECRET"),
|
||||
oauth_token_url=os.getenv(
|
||||
"BITRIX_OAUTH_TOKEN_URL",
|
||||
"https://oauth.bitrix.info/oauth/token/",
|
||||
),
|
||||
binding_ttl_seconds=ttl,
|
||||
trust_proxy=_as_bool(os.getenv("TRUST_PROXY"), default=True),
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
|
||||
class TokenCipher:
|
||||
"""Шифрует OAuth-токены перед передачей в БД."""
|
||||
|
||||
def __init__(self, key: str) -> None:
|
||||
try:
|
||||
self._fernet = Fernet(key.encode("ascii"))
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise RuntimeError("TOKEN_ENCRYPTION_KEY is invalid") from error
|
||||
|
||||
def encrypt(self, value: str) -> bytes:
|
||||
return self._fernet.encrypt(value.encode("utf-8"))
|
||||
@@ -0,0 +1,39 @@
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
|
||||
from psycopg import Connection
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg_pool import ConnectionPool
|
||||
|
||||
|
||||
class SiteDatabase:
|
||||
"""Данный класс представляет собой обертку над пулом соединений
|
||||
с базой данных PostgreSQL."""
|
||||
|
||||
def __init__(self, database_url: str) -> None:
|
||||
# Пул может содержать в себе максимум 5 соединений.
|
||||
self.pool = ConnectionPool(
|
||||
conninfo=database_url,
|
||||
min_size=1,
|
||||
max_size=5,
|
||||
open=False,
|
||||
# Фабрика для представления строк БД как словарей.
|
||||
kwargs={"row_factory": dict_row},
|
||||
)
|
||||
|
||||
def open(self) -> None:
|
||||
self.pool.open(wait=True)
|
||||
|
||||
def close(self) -> None:
|
||||
self.pool.close()
|
||||
|
||||
@contextmanager
|
||||
def transaction(self) -> Generator[Connection]:
|
||||
with self.pool.connection() as connection:
|
||||
with connection.transaction():
|
||||
yield connection
|
||||
|
||||
def ping(self) -> bool:
|
||||
"""Простая проверка подключения к БД."""
|
||||
with self.pool.connection() as connection:
|
||||
return connection.execute("SELECT 1").fetchone() is not None
|
||||
@@ -0,0 +1,7 @@
|
||||
import os
|
||||
|
||||
bind = f"{os.getenv('SITE_HOST', '0.0.0.0')}:{os.getenv('SITE_PORT', '8000')}"
|
||||
workers = int(os.getenv("SITE_WORKERS", "2"))
|
||||
accesslog = "-"
|
||||
errorlog = "-"
|
||||
timeout = 30
|
||||
@@ -0,0 +1,67 @@
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, current_app, jsonify, render_template, request
|
||||
|
||||
from .binding import BindingService
|
||||
from .bitrix import BitrixClient
|
||||
|
||||
|
||||
class SiteInputError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _field(payload: Mapping[str, Any], name: str) -> str:
|
||||
"""Проверка существования обязательного поля с именем name."""
|
||||
for key in (name, name.lower(), name.upper()):
|
||||
value = payload.get(key)
|
||||
if value is not None and str(value).strip():
|
||||
return str(value).strip()
|
||||
raise SiteInputError(f"Не передано поле {name}")
|
||||
|
||||
|
||||
def create_blueprint(
|
||||
bindings: BindingService,
|
||||
bitrix: BitrixClient,
|
||||
) -> Blueprint:
|
||||
blueprint = Blueprint("site", __name__)
|
||||
|
||||
@blueprint.get("/")
|
||||
def index():
|
||||
return jsonify(
|
||||
service="bitrix-telegram-binding",
|
||||
public_url=current_app.config["PUBLIC_BASE_URL"],
|
||||
)
|
||||
|
||||
@blueprint.get("/health")
|
||||
def health():
|
||||
database = current_app.extensions["database"]
|
||||
try:
|
||||
available = database.ping()
|
||||
except Exception:
|
||||
available = False
|
||||
status = "ok" if available else "error"
|
||||
return jsonify(status=status), 200 if available else 503
|
||||
|
||||
@blueprint.post("/bitrix/bind")
|
||||
def bind():
|
||||
payload = request.get_json(silent=True) or request.form
|
||||
refresh_token = _field(payload, "REFRESH_ID")
|
||||
|
||||
# OAuth-ответ дает доверенные ID портала и пользователя.
|
||||
auth = bitrix.authorize(refresh_token)
|
||||
link = bindings.issue(
|
||||
auth.member_id,
|
||||
auth.domain,
|
||||
auth.user.id,
|
||||
auth.access_token,
|
||||
auth.refresh_token,
|
||||
auth.expires_in,
|
||||
)
|
||||
return render_template(
|
||||
"binding.html",
|
||||
user=auth.user,
|
||||
link=link,
|
||||
)
|
||||
|
||||
return blueprint
|
||||
@@ -0,0 +1,38 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Привязка Telegram</title>
|
||||
<style>
|
||||
body {
|
||||
font: 16px sans-serif;
|
||||
max-width: 560px;
|
||||
margin: 48px auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
a {
|
||||
display: inline-block;
|
||||
padding: 12px 18px;
|
||||
color: white;
|
||||
background: #168acd;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
small {
|
||||
display: block;
|
||||
margin-top: 16px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Привязка Telegram</h1>
|
||||
<p>{{ user.name }}, откройте бота и подтвердите привязку.</p>
|
||||
<a href="{{ link.url }}" target="_blank" rel="noopener">Открыть Telegram</a>
|
||||
<small>Ссылка одноразовая и действует до {{ link.expires_at.strftime('%H:%M
|
||||
UTC') }}.</small>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user