Files

194 lines
5.9 KiB
Python

"""Валидация Telegram WebApp initData, Login Widget и OIDC id_token."""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import time
from dataclasses import dataclass
from typing import Any, Mapping
from urllib.parse import parse_qsl
import jwt
from jwt import PyJWKClient
logger = logging.getLogger(__name__)
_JWKS_URL = "https://oauth.telegram.org/.well-known/jwks.json"
_ISSUER = "https://oauth.telegram.org"
_jwks_client: PyJWKClient | None = None
@dataclass(frozen=True, slots=True)
class WebAppUser:
id: int
first_name: str
last_name: str | None = None
username: str | None = None
language_code: str | None = None
is_premium: bool = False
@property
def full_name(self) -> str:
if self.last_name:
return f"{self.first_name} {self.last_name}"
return self.first_name
class InitDataError(ValueError):
pass
def telegram_client_id_from_token(bot_token: str) -> str:
"""Числовой Client ID бота = часть BOT_TOKEN до ':'."""
raw = (bot_token or "").strip()
if ":" in raw:
return raw.split(":", 1)[0].strip()
return raw
def parse_and_validate_init_data(
init_data: str,
bot_token: str,
*,
max_age_sec: int = 86400,
) -> WebAppUser:
if not init_data or not init_data.strip():
raise InitDataError("empty initData")
pairs = dict(parse_qsl(init_data, keep_blank_values=True))
received_hash = pairs.pop("hash", None)
if not received_hash:
raise InitDataError("missing hash")
data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(pairs.items()))
secret_key = hmac.new(b"WebAppData", bot_token.encode(), hashlib.sha256).digest()
calculated = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(calculated, received_hash):
raise InitDataError("invalid hash")
auth_date = int(pairs.get("auth_date") or 0)
if auth_date and abs(time.time() - auth_date) > max_age_sec:
raise InitDataError("initData expired")
user_raw = pairs.get("user")
if not user_raw:
raise InitDataError("missing user")
user = json.loads(user_raw)
return WebAppUser(
id=int(user["id"]),
first_name=str(user.get("first_name") or "User"),
last_name=user.get("last_name"),
username=user.get("username"),
language_code=user.get("language_code"),
is_premium=bool(user.get("is_premium")),
)
def parse_and_validate_login_widget(
data: Mapping[str, Any],
bot_token: str,
*,
max_age_sec: int = 86400,
) -> WebAppUser:
"""Проверка данных legacy Telegram Login Widget (HMAC)."""
payload = {str(k): str(v) for k, v in data.items() if v is not None and str(v) != ""}
received_hash = payload.pop("hash", None)
if not received_hash:
raise InitDataError("missing hash")
data_check_string = "\n".join(f"{k}={payload[k]}" for k in sorted(payload.keys()))
secret_key = hashlib.sha256(bot_token.encode("utf-8")).digest()
calculated = hmac.new(
secret_key,
data_check_string.encode("utf-8"),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(calculated, received_hash):
raise InitDataError("invalid hash")
auth_date = int(payload.get("auth_date") or 0)
if not auth_date or abs(time.time() - auth_date) > max_age_sec:
raise InitDataError("login data expired")
if "id" not in payload:
raise InitDataError("missing id")
return WebAppUser(
id=int(payload["id"]),
first_name=str(payload.get("first_name") or "User"),
last_name=payload.get("last_name"),
username=payload.get("username"),
)
def _get_jwks_client() -> PyJWKClient:
global _jwks_client
if _jwks_client is None:
_jwks_client = PyJWKClient(_JWKS_URL, cache_keys=True, lifespan=3600)
return _jwks_client
def parse_and_validate_id_token(
id_token: str,
client_id: str,
*,
nonce: str | None = None,
) -> WebAppUser:
"""Проверка OIDC id_token нового Telegram Login (JWKS)."""
token = (id_token or "").strip()
aud = str(client_id or "").strip()
if not token:
raise InitDataError("missing id_token")
if not aud:
raise InitDataError("telegram client_id not configured")
try:
signing_key = _get_jwks_client().get_signing_key_from_jwt(token)
payload = jwt.decode(
token,
signing_key.key,
algorithms=["RS256", "RS384", "RS512", "PS256", "ES256", "ES384", "EdDSA"],
audience=aud,
issuer=_ISSUER,
options={"require": ["exp", "iat", "iss", "aud"]},
)
except jwt.PyJWTError as exc:
logger.info("telegram id_token invalid: %s", exc)
raise InitDataError("invalid id_token") from exc
if nonce and payload.get("nonce") and str(payload.get("nonce")) != str(nonce):
raise InitDataError("nonce mismatch")
tid_raw = payload.get("id")
if tid_raw is None:
tid_raw = payload.get("sub")
try:
tid = int(tid_raw)
except (TypeError, ValueError) as exc:
raise InitDataError("missing user id in id_token") from exc
given = str(payload.get("given_name") or "").strip()
family = str(payload.get("family_name") or "").strip() or None
full = str(payload.get("name") or "").strip()
if given:
first_name = given
last_name = family
elif full:
parts = full.split(None, 1)
first_name = parts[0]
last_name = parts[1] if len(parts) > 1 else family
else:
first_name = "User"
last_name = family
username = payload.get("preferred_username") or payload.get("username")
return WebAppUser(
id=tid,
first_name=first_name,
last_name=last_name,
username=str(username) if username else None,
)