diff --git a/api/v2/routes/auth/telegram.py b/api/v2/routes/auth/telegram.py index fee5e2e5..9295302a 100644 --- a/api/v2/routes/auth/telegram.py +++ b/api/v2/routes/auth/telegram.py @@ -28,6 +28,18 @@ from utils.telegram_login import verify_telegram_login router = APIRouter() +def _get_oidc_credentials() -> tuple[str, str]: + try: + from config import TELEGRAM_CLIENT_ID, TELEGRAM_CLIENT_SECRET + cid = str(TELEGRAM_CLIENT_ID).strip() + secret = str(TELEGRAM_CLIENT_SECRET).strip() + if cid and secret: + return cid, secret + except ImportError: + pass + return "", "" + + class LoginTelegramWebAppRequest(BaseModel): init_data: str = PydanticField(..., min_length=1) @@ -91,6 +103,112 @@ async def login_telegram_webapp( return LoginResponse(identity_id=identity.id) +class LoginTelegramOIDCRequest(BaseModel): + code: str = PydanticField(..., min_length=1) + redirect_uri: str = PydanticField(..., min_length=1) + code_verifier: str = PydanticField(default="", description="PKCE code_verifier") + + +@router.post("/login-telegram-oidc", response_model=LoginResponse) +async def login_telegram_oidc( + body: LoginTelegramOIDCRequest, + request: Request, + response: Response, + session: AsyncSession = Depends(get_session), +): + """Вход через Telegram OIDC (authorization code flow). Обменивает code на id_token, верифицирует JWT.""" + import base64 + + import aiohttp + import jwt as pyjwt + + client_id, client_secret = _get_oidc_credentials() + if not client_id or not client_secret: + raise HTTPException(status_code=503, detail="Telegram OIDC не настроен: отсутствуют TELEGRAM_CLIENT_ID / TELEGRAM_CLIENT_SECRET") + + token_data = { + "grant_type": "authorization_code", + "code": body.code, + "redirect_uri": body.redirect_uri, + } + if body.code_verifier: + token_data["code_verifier"] = body.code_verifier + + basic = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode() + headers = {"Authorization": f"Basic {basic}", "Content-Type": "application/x-www-form-urlencoded"} + + async with aiohttp.ClientSession() as http: + async with http.post("https://oauth.telegram.org/token", data=token_data, headers=headers) as resp: + if resp.status != 200: + err_text = await resp.text() + logger.warning("[Auth] Telegram OIDC token exchange failed: {} {}", resp.status, err_text[:200]) + raise HTTPException(status_code=401, detail="Не удалось обменять код авторизации") + token_response = await resp.json() + + id_token = token_response.get("id_token") + if not id_token: + raise HTTPException(status_code=401, detail="Telegram не вернул id_token") + + async with aiohttp.ClientSession() as http: + async with http.get("https://oauth.telegram.org/.well-known/jwks.json") as resp: + jwks_data = await resp.json() + + try: + signing_key = pyjwt.PyJWKClient.__new__(pyjwt.PyJWKClient) + from jwt.api_jwk import PyJWKSet + jwk_set = PyJWKSet.from_dict(jwks_data) + unverified_header = pyjwt.get_unverified_header(id_token) + kid = unverified_header.get("kid") + key = None + for k in jwk_set.keys: + if k.key_id == kid: + key = k + break + if not key: + raise HTTPException(status_code=401, detail="Ключ подписи не найден в JWKS") + + claims = pyjwt.decode( + id_token, + key.key, + algorithms=["RS256"], + audience=client_id, + issuer="https://oauth.telegram.org", + ) + except pyjwt.ExpiredSignatureError: + raise HTTPException(status_code=401, detail="ID токен истёк") + except pyjwt.InvalidTokenError as exc: + logger.warning("[Auth] Telegram OIDC JWT invalid: {}", exc) + raise HTTPException(status_code=401, detail="Невалидный ID токен") + + logger.info("[Auth] Telegram OIDC claims: {}", {k: v for k, v in claims.items() if k not in ("iat", "exp", "iss", "aud")}) + + tg_id = claims.get("id") or claims.get("telegram_id") + if not tg_id: + raise HTTPException(status_code=401, detail="Не удалось определить пользователя из id_token") + + tg_id_int = int(tg_id) + if tg_id_int > 2**53: + raise HTTPException(status_code=401, detail=f"Некорректный Telegram ID: {tg_id}") + + identity = await idb.get_or_create_identity_for_tg(session, tg_id_int) + await bind_identity_actor(request, session, identity) + token = await idb.issue_token_for_identity(session, identity) + + if getattr(identity, "is_admin", False): + from database.site_state import mark_site_initialized + await mark_site_initialized(session) + + logger.info( + "[Auth] Login success: identity={}, tg_id={}, ip={}, method=telegram_oidc", + identity.id, + tg_id, + _client_ip(request), + ) + set_auth_cookie(response, token, request) + set_is_admin_cookie(response, identity, request) + return LoginResponse(identity_id=identity.id) + + @router.post("/link-telegram", response_model=IdentityResponse) async def link_telegram( body: LinkTelegramRequest, diff --git a/api/v2/routes/root.py b/api/v2/routes/root.py index 9b360145..7348b51b 100644 --- a/api/v2/routes/root.py +++ b/api/v2/routes/root.py @@ -84,9 +84,16 @@ async def telegram_widget_bot(): """Имя бота и имя проекта для веб-клиента.""" bot_username = str(USERNAME_BOT or "").replace("@", "").strip() project_name = (PROJECT_NAME or "Solo").strip() if isinstance(PROJECT_NAME, str) else "Solo" + telegram_client_id = "" + try: + from config import TELEGRAM_CLIENT_ID + telegram_client_id = str(TELEGRAM_CLIENT_ID).strip() + except ImportError: + pass return { "bot_username": bot_username, "project_name": project_name, + "telegram_client_id": telegram_client_id, } diff --git a/api/v2/routes/web.py b/api/v2/routes/web.py index 7881fd1f..c2fb9430 100644 --- a/api/v2/routes/web.py +++ b/api/v2/routes/web.py @@ -317,7 +317,14 @@ async def get_web_page_variants( variant: str | None = Query(default=None), session: AsyncSession = Depends(get_session), ): - current, variants = await _resolve_variant(session, slug, variant) + try: + current, variants = await _resolve_variant(session, slug, variant) + except HTTPException: + raise + except Exception as exc: + from logger import logger + logger.warning("[web] variants resolve failed for slug={}: {}", slug, exc) + raise HTTPException(status_code=404, detail="Страница или вариант не найдены") active = next((item for item in variants if item.is_active), current) return WebPageVariantsResponse( slug=slug, diff --git a/handlers/tariffs/buy/__init__.py b/handlers/tariffs/buy/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/main.py b/main.py old mode 100644 new mode 100755 diff --git a/requirements.txt b/requirements.txt index 1856bde0..cc87171b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,6 +20,7 @@ cffi==1.17.1 charset-normalizer==3.4.0 click==8.2.2 cryptography==45.0.5 +PyJWT[crypto]>=2.8.0 Deprecated==1.2.14 distro==1.9.0 fastapi==0.128.7