From 4de730bf5a88d5a355ed614ea362fe1223680a4f Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Thu, 30 Apr 2026 19:50:17 +0300 Subject: [PATCH] feat: add Telegram OAuth login flow --- .env.example | 3 + README.md | 24 +- bot/app/web/frontend/src/App.svelte | 139 +++++------- bot/app/web/subscription_webapp.py | 338 +++++++++++++++++++++++++--- bot/app/web/webapp_auth.py | 180 +++++++++++++++ config/settings.py | 14 ++ requirements.txt | 1 + tests/test_security.py | 29 ++- 8 files changed, 611 insertions(+), 117 deletions(-) diff --git a/.env.example b/.env.example index c337e4e..9b9d4a1 100644 --- a/.env.example +++ b/.env.example @@ -46,6 +46,9 @@ WEBHOOK_SECRET_TOKEN= # WEBAPP_SESSION_TTL_SECONDS=86400 # Web App session lifetime (24h) WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600 # External browser login link lifetime +TELEGRAM_OAUTH_CLIENT_ID= # Telegram Web Login Client ID from BotFather; defaults to bot ID from BOT_TOKEN +TELEGRAM_OAUTH_CLIENT_SECRET= # Optional Telegram Web Login Client Secret; reserved for full OIDC code flow +TELEGRAM_OAUTH_REQUEST_ACCESS=write # Optional comma-separated permissions: write,phone; empty = OpenID profile only # Email login and account linking via SMTP (Brevo SMTP relay defaults) SMTP_HOST=smtp-relay.brevo.com # SMTP server diff --git a/README.md b/README.md index 8492b57..c50f36b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Remnawave Minishop -Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для автоматизации продажи и управления подписками панели **Remnawave**. Бот закрывает сценарий покупки, продления и работы с поддержкой прямо в чате, а Web App в едином интерфейсе показывает ссылку подключения, остаток времени, трафик, оплату и устройства, поддерживая вход через Telegram Mini Apps `initData`, Telegram Login Widget и одноразовый код по email. Под капотом — интеграция с API Remnawave для управления пользователями и подписками и набор платёжных шлюзов для приёма платежей. +Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для автоматизации продажи и управления подписками панели **Remnawave**. Бот закрывает сценарий покупки, продления и работы с поддержкой прямо в чате, а Web App в едином интерфейсе показывает ссылку подключения, остаток времени, трафик, оплату и устройства, поддерживая вход через Telegram Mini Apps `initData`, новый Telegram OAuth / OpenID Connect Login и одноразовый код по email. Под капотом — интеграция с API Remnawave для управления пользователями и подписками и набор платёжных шлюзов для приёма платежей. > 🍴 **Это глубоко переработанный форк [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop).** Здесь добавлены полноценный Web App / Mini App, вход по email и многое другое. Возможна миграция. @@ -78,6 +78,9 @@ Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для | `WEBAPP_TITLE` | Заголовок Web App. | `Моя подписка` | | `WEBAPP_PRIMARY_COLOR` | Основной цвет Web App. | `#00fe7a` | | `WEBAPP_LOGO_URL` | (Опционально) URL логотипа Web App. Если значение пустое, логотип не показывается вообще; если задано, он отображается в шапке и на экране логина. | `https://domain.com/logo.png` | + | `TELEGRAM_OAUTH_CLIENT_ID` | Client ID для нового Telegram OAuth / OpenID Connect Login из BotFather. Если пусто, используется числовой ID из `BOT_TOKEN`. | `1234567890` | + | `TELEGRAM_OAUTH_CLIENT_SECRET` | Client Secret из BotFather для Telegram OAuth Authorization Code Flow. | `tg_oauth_secret` | + | `TELEGRAM_OAUTH_REQUEST_ACCESS` | Дополнительные разрешения Telegram Login через запятую: `write`, `phone`. Пустое значение запрашивает только OpenID profile. | `write` | | `SMTP_HOST` | SMTP-сервер для кодов входа по email. Для Brevo: `smtp-relay.brevo.com`. | `smtp-relay.brevo.com` | | `SMTP_PORT` | SMTP-порт. Для Brevo обычно используется 587 с STARTTLS. | `587` | | `SMTP_FALLBACK_PORTS` | Дополнительные SMTP-порты через запятую. Пробуются после `SMTP_PORT`; порт `465` автоматически используется через SSL. Для Brevo удобно оставить `2525,465`. | `2525,465` | @@ -235,7 +238,7 @@ Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для ### Настройка Web App / Mini App -Web App запускается в том же контейнере, что и бот, но слушает отдельный порт `WEBAPP_SERVER_PORT` (по умолчанию `8081`). Внутри Web App пользователь авторизуется через Telegram Mini Apps `initData`; если страницу открыть вне Telegram, показывается официальный Telegram Login Widget. Также доступен вход по email через одноразовый код из письма, если настроен SMTP: после отправки письма код вводится в отдельном модальном окне подтверждения. После успешного входа страница обновляет данные сразу, без сообщений боту. +Web App запускается в том же контейнере, что и бот, но слушает отдельный порт `WEBAPP_SERVER_PORT` (по умолчанию `8081`). Внутри Telegram пользователь авторизуется через Telegram Mini Apps `initData`; если страницу открыть вне Telegram, используется новый Telegram OAuth / OpenID Connect Authorization Code Flow с PKCE, callback `/auth/telegram/callback`, `nonce` и серверной проверкой `id_token` по JWKS Telegram. Старый Login Widget больше не используется в UI. Также доступен вход по email через одноразовый код из письма, если настроен SMTP: после отправки письма код вводится в отдельном модальном окне подтверждения. После успешного входа страница обновляет данные сразу, без сообщений боту. 1. Укажите в `.env` публичный URL Web App и порт: @@ -247,6 +250,9 @@ Web App запускается в том же контейнере, что и б WEBAPP_TITLE="Моя подписка" WEBAPP_PRIMARY_COLOR="#00fe7a" WEBAPP_LOGO_URL= + TELEGRAM_OAUTH_CLIENT_ID= + TELEGRAM_OAUTH_CLIENT_SECRET= + TELEGRAM_OAUTH_REQUEST_ACCESS=write SMTP_HOST=smtp-relay.brevo.com SMTP_PORT=587 SMTP_FALLBACK_PORTS=2525,465 @@ -291,7 +297,17 @@ Web App запускается в том же контейнере, что и б } ``` -4. В BotFather настройте домен Mini App для бота (`/setdomain`) и укажите домен из `SUBSCRIPTION_MINI_APP_URL`. Этот же домен используется и Telegram Login Widget. +4. В BotFather настройте бота, Mini App и Telegram OAuth Login: + + - `@BotFather` → `/mybots` → выберите бота. + - **Bot Settings → Domain**: укажите домен без протокола и пути, например `app.domain.com`. + - **Bot Settings → Mini Apps**: задайте URL Mini App, например `https://app.domain.com/`. + - **Bot Settings → Web Login**: если BotFather показывает кнопку `Switch to OpenID Connect Login`, нажмите ее. + - **Bot Settings → Web Login**: скопируйте Client ID и Client Secret в `TELEGRAM_OAUTH_CLIENT_ID` и `TELEGRAM_OAUTH_CLIENT_SECRET`. + - **Web Login → Allowed URLs**: добавьте: + `https://app.domain.com/` + `https://app.domain.com/auth/telegram/callback` + - `TELEGRAM_OAUTH_REQUEST_ACCESS=write` разрешает боту написать пользователю после логина. Если дополнительные разрешения не нужны, оставьте переменную пустой. 5. Перезапустите контейнер: @@ -299,7 +315,7 @@ Web App запускается в том же контейнере, что и б docker compose up -d --build ``` -После этого кнопка «Моя подписка» в меню бота откроет Web App. Web App показывает текущую ссылку подключения, остаток времени, трафик, оплату и блок аккаунта. Пользователь может привязать email к Telegram-аккаунту через код из письма или привязать Telegram к email-аккаунту через Login Widget. После привязки вход работает обоими способами. +После этого кнопка «Моя подписка» в меню бота откроет Web App. Web App показывает текущую ссылку подключения, остаток времени, трафик, оплату и блок аккаунта. Пользователь может привязать email к Telegram-аккаунту через код из письма или привязать Telegram к email-аккаунту через Telegram OAuth Login. После привязки вход работает обоими способами. Для email-регистраций пользователь в панели Remnawave создается с анонимным username вида `em_`; email добавляется в описание пользователя панели и, если API панели принимает поле email, передается отдельным полем. Для Telegram-регистраций сохраняется существующая схема `tg_`. diff --git a/bot/app/web/frontend/src/App.svelte b/bot/app/web/frontend/src/App.svelte index 67b2cf9..108d4ed 100644 --- a/bot/app/web/frontend/src/App.svelte +++ b/bot/app/web/frontend/src/App.svelte @@ -34,7 +34,6 @@ import Input from "./lib/components/ui/input.svelte"; import PreviewBoard from "./PreviewBoard.svelte"; - const TELEGRAM_LOGIN_WIDGET_URL = "https://telegram.org/js/telegram-widget.js?23"; const MANUAL_LOGOUT_FLAG_KEY = "rw_webapp_manual_logout"; const LANGUAGE_LABELS = { ru: "Русский", @@ -77,6 +76,8 @@ emailAuthEnabled: true, telegramLoginBotUsername: "preview_bot", telegramLoginBotId: 1234567890, + telegramOAuthClientId: 1234567890, + telegramOAuthRequestAccess: ["write"], }, data: { ok: true, @@ -254,7 +255,6 @@ let avatarHashToken = ""; let token = MOCK ? "local-preview" : localStorage.getItem("rw_webapp_token") || ""; let csrfToken = MOCK ? "" : readCookie("rw_webapp_csrf") || ""; - let telegramLoginSdkPromise = null; let linkEmailResendCooldown = 0; let linkEmailResendTimer = null; let scrollLockApplied = false; @@ -345,6 +345,7 @@ $: userAgreementUrl = String(CFG.userAgreementUrl || "").trim(); $: supportUrl = String(appSettings?.support_url || CFG.supportUrl || "").trim(); $: telegramLoginBotId = Number(CFG.telegramLoginBotId || 0); + $: telegramOAuthClientId = Number(CFG.telegramOAuthClientId || telegramLoginBotId || 0); $: applyFavicon(CFG.logoUrl, brandEmoji); $: syncBodyScrollLock(paymentModalOpen || linkEmailOpen); $: if (!selectedPlan && plans.length) selectedPlan = plans[Math.min(1, plans.length - 1)]; @@ -538,6 +539,24 @@ const magicToken = readMagicLoginToken(); if (magicToken && (await finalizeMagicLogin(magicToken))) return; + const telegramAuthStatus = readTelegramAuthStatus(); + if (telegramAuthStatus === "success") { + clearManualLogoutFlag(); + clearAuthQuery(); + try { + await loadData(); + return; + } catch { + clearToken(); + } + } else if (telegramAuthStatus) { + clearAuthQuery(); + setAuthStatus( + telegramAuthStatus === "cancelled" ? t("wa_auth_telegram_cancelled") : t("wa_auth_telegram_not_confirmed"), + true, + ); + } + if (isManuallyLoggedOut()) { showLogin(); return; @@ -736,6 +755,11 @@ return localStorage.getItem("rw_webapp_referral") || ""; } + function readTelegramAuthStatus() { + const params = new URLSearchParams(window.location.search); + return (params.get("telegram_auth") || "").trim().toLowerCase() || null; + } + function readMagicLoginToken() { const params = new URLSearchParams(window.location.search); return (params.get("login_token") || "").trim() || null; @@ -757,7 +781,18 @@ function clearAuthQuery() { const url = new URL(window.location.href); - ["login_token", "login_purpose", "id", "first_name", "last_name", "username", "photo_url", "auth_date", "hash"].forEach((key) => + [ + "login_token", + "login_purpose", + "telegram_auth", + "id", + "first_name", + "last_name", + "username", + "photo_url", + "auth_date", + "hash", + ].forEach((key) => url.searchParams.delete(key), ); window.history?.replaceState?.({}, document.title, url.pathname + url.search + url.hash); @@ -792,7 +827,12 @@ authBusy = true; setAuthStatus(t("wa_auth_checking_telegram")); try { - const payload = source === "init_data" ? { init_data: authData } : { auth_data: authData }; + const payload = + source === "init_data" + ? { init_data: authData } + : source === "id_token" + ? { id_token: authData.id_token, nonce: authData.nonce } + : { auth_data: authData }; const referralParam = readReferralParam(); if (referralParam) payload.referral_code = referralParam; const response = await publicApi("/auth/token", payload); @@ -926,38 +966,12 @@ }, 1000); } - async function ensureTelegramLoginSdk() { - if (window.Telegram?.Login?.auth) return true; - if (telegramLoginSdkPromise) return telegramLoginSdkPromise; - - telegramLoginSdkPromise = new Promise((resolve) => { - const existingScript = Array.from(document.querySelectorAll("script")).find((node) => - String(node?.src || "").includes("telegram-widget.js"), - ); - const script = existingScript || document.createElement("script"); - const onReady = () => resolve(Boolean(window.Telegram?.Login?.auth)); - - if (!existingScript) { - script.async = true; - script.src = TELEGRAM_LOGIN_WIDGET_URL; - script.setAttribute("data-rw-telegram-login-sdk", "1"); - document.head.appendChild(script); - } - - if (window.Telegram?.Login?.auth) { - onReady(); - return; - } - - script.addEventListener("load", onReady, { once: true }); - script.addEventListener("error", () => resolve(false), { once: true }); - }).finally(() => { - if (!window.Telegram?.Login?.auth) { - telegramLoginSdkPromise = null; - } - }); - - return telegramLoginSdkPromise; + function buildTelegramOAuthStartUrl(purpose = "login") { + const url = new URL("/auth/telegram/start", window.location.origin); + url.searchParams.set("purpose", purpose); + const referralParam = readReferralParam(); + if (referralParam) url.searchParams.set("referral_code", referralParam); + return url.toString(); } async function openTelegramLogin() { @@ -967,36 +981,14 @@ return; } - if (!telegramLoginBotId) { + if (!telegramOAuthClientId) { setAuthStatus(t("wa_auth_telegram_not_configured"), true); return; } - const sdkReady = await ensureTelegramLoginSdk(); - if (!sdkReady || !window.Telegram?.Login?.auth) { - setAuthStatus(t("wa_auth_telegram_unavailable"), true); - return; - } - - setAuthStatus(""); - try { - window.Telegram.Login.auth( - { - bot_id: telegramLoginBotId, - request_access: "write", - lang: currentLang, - }, - async (telegramUser) => { - if (!telegramUser) { - setAuthStatus(t("wa_auth_telegram_cancelled"), true); - return; - } - await finalizeTelegramAuth(telegramUser, "auth_data"); - }, - ); - } catch { - setAuthStatus(t("wa_auth_telegram_unavailable"), true); - } + authBusy = true; + setAuthStatus(t("wa_auth_checking_telegram")); + window.location.assign(buildTelegramOAuthStartUrl("login")); } function setLinkEmailStatus(message, isError = false) { @@ -1109,29 +1101,12 @@ await linkTelegramAccountWithPayload({ init_data: tg.initData }); return; } - if (!telegramLoginBotId) { + if (!telegramOAuthClientId) { showToast(t("wa_auth_telegram_not_configured")); return; } - const sdkReady = await ensureTelegramLoginSdk(); - if (!sdkReady || !window.Telegram?.Login?.auth) { - showToast(t("wa_auth_telegram_unavailable")); - return; - } - window.Telegram.Login.auth( - { - bot_id: telegramLoginBotId, - request_access: "write", - lang: currentLang, - }, - async (telegramUser) => { - if (!telegramUser) { - showToast(t("wa_auth_telegram_cancelled")); - return; - } - await linkTelegramAccountWithPayload({ auth_data: telegramUser }); - }, - ); + linkTelegramBusy = true; + window.location.assign(buildTelegramOAuthStartUrl("link")); } async function updateAccountLanguage(nextValue) { diff --git a/bot/app/web/subscription_webapp.py b/bot/app/web/subscription_webapp.py index 45d161b..bf2d8d3 100644 --- a/bot/app/web/subscription_webapp.py +++ b/bot/app/web/subscription_webapp.py @@ -1,4 +1,5 @@ import asyncio +import base64 import hashlib import hmac import ipaddress @@ -22,9 +23,14 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import sessionmaker from bot.app.web.webapp_auth import ( + create_signed_telegram_oauth_state, + create_telegram_oauth_nonce, create_webapp_session_token, + validate_telegram_oauth_id_token, validate_telegram_login_widget_data, validate_telegram_webapp_init_data, + verify_signed_telegram_oauth_state, + verify_telegram_oauth_nonce, verify_webapp_session_token, ) from bot.services.crypto_pay_service import CryptoPayService @@ -63,6 +69,7 @@ WEBAPP_CSRF_COOKIE_NAME = "rw_webapp_csrf" WEBAPP_CSRF_HEADER_NAME = "X-CSRF-Token" WEBAPP_STATE_CHANGING_METHODS = {"POST", "PUT", "PATCH", "DELETE"} WEBAPP_CSRF_EXEMPT_PATHS = { + "/api/auth/telegram/nonce", "/api/auth/token", "/api/auth/email/request", "/api/auth/email/verify", @@ -175,11 +182,14 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: app.router.add_get("/invite", index_route) app.router.add_get("/devices", index_route) app.router.add_get("/settings", index_route) + app.router.add_get("/auth/telegram/start", telegram_oauth_start_route) + app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route) app.router.add_get("/health", health_route) app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_route) app.router.add_get("/subscription_webapp.css", css_asset_route) app.router.add_get("/subscription_webapp.min.{asset_hash}.js", js_asset_route) app.router.add_get("/subscription_webapp.js", js_asset_route) + app.router.add_post("/api/auth/telegram/nonce", telegram_oauth_nonce_route) app.router.add_post("/api/auth/token", auth_token_route) app.router.add_post("/api/auth/email/request", email_auth_request_route) app.router.add_post("/api/auth/email/verify", email_auth_verify_route) @@ -229,6 +239,103 @@ def _resolve_telegram_bot_id(bot_token: str) -> Optional[int]: return None +def _resolve_telegram_oauth_client_id(settings: Settings) -> Optional[int]: + configured_client_id = getattr(settings, "TELEGRAM_OAUTH_CLIENT_ID", None) + if configured_client_id: + try: + return int(configured_client_id) + except (TypeError, ValueError): + return None + return _resolve_telegram_bot_id(settings.BOT_TOKEN) + + +def _resolve_telegram_oauth_request_access(settings: Settings) -> List[str]: + raw_value = str(getattr(settings, "TELEGRAM_OAUTH_REQUEST_ACCESS", "") or "") + allowed = {"write", "phone"} + scopes = [] + for item in raw_value.split(","): + value = item.strip().lower() + if value in allowed and value not in scopes: + scopes.append(value) + return scopes + + +def _public_webapp_base_url(settings: Settings, request: web.Request) -> str: + configured_url = str(settings.SUBSCRIPTION_MINI_APP_URL or "").strip() + if configured_url: + parsed_url = urlsplit(configured_url) + if parsed_url.scheme and parsed_url.netloc: + return f"{parsed_url.scheme}://{parsed_url.netloc}" + + scheme = request.headers.get("X-Forwarded-Proto") or request.scheme + host = request.headers.get("X-Forwarded-Host") or request.headers.get("Host") or request.host + return f"{scheme}://{host}".rstrip("/") + + +def _telegram_oauth_callback_url(settings: Settings, request: web.Request) -> str: + return f"{_public_webapp_base_url(settings, request)}/auth/telegram/callback" + + +def _telegram_oauth_redirect_url(path: str = "/", *, status: Optional[str] = None) -> str: + target_path = path if path.startswith("/") else "/" + if target_path not in {"/", "/settings"}: + target_path = "/" + if not status: + return target_path + separator = "&" if "?" in target_path else "?" + return f"{target_path}{separator}telegram_auth={status}" + + +def _urlsafe_sha256(value: str) -> str: + digest = hashlib.sha256(value.encode("ascii")).digest() + return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") + + +async def _exchange_telegram_oauth_code( + request: web.Request, + *, + code: str, + code_verifier: str, + redirect_uri: str, +) -> Optional[Dict[str, Any]]: + settings: Settings = request.app["settings"] + client_id = _resolve_telegram_oauth_client_id(settings) + client_secret = str(getattr(settings, "TELEGRAM_OAUTH_CLIENT_SECRET", "") or "").strip() + if not client_id or not client_secret or not code or not code_verifier: + return None + + credentials = base64.b64encode(f"{client_id}:{client_secret}".encode("utf-8")).decode("ascii") + session = await _get_shared_http_session() + try: + async with session.post( + "https://oauth.telegram.org/token", + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": str(client_id), + "code_verifier": code_verifier, + }, + headers={ + "Authorization": f"Basic {credentials}", + "Content-Type": "application/x-www-form-urlencoded", + }, + timeout=ClientTimeout(total=15), + ) as response: + payload = await response.json(content_type=None) + if response.status >= 400: + logger.warning( + "Telegram OAuth token exchange failed with HTTP %s: %s", + response.status, + payload, + ) + return None + return payload if isinstance(payload, dict) else None + except Exception as exc: + logger.warning("Telegram OAuth token exchange failed: %s", exc) + return None + + async def webapp_logo_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() @@ -529,6 +636,8 @@ async def index_route(request: web.Request) -> web.Response: "apiBase": "/api", "telegramLoginBotUsername": request.app.get("bot_username") or "", "telegramLoginBotId": _resolve_telegram_bot_id(settings.BOT_TOKEN) or 0, + "telegramOAuthClientId": _resolve_telegram_oauth_client_id(settings) or 0, + "telegramOAuthRequestAccess": _resolve_telegram_oauth_request_access(settings), "supportUrl": cached["support_url"], "termsUrl": cached["terms_url"], "privacyPolicyUrl": cached["privacy_policy_url"], @@ -586,6 +695,203 @@ async def _serve_template_asset( return web.Response(text=text, content_type=content_type, charset="utf-8") +async def telegram_oauth_nonce_route(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + client_id = _resolve_telegram_oauth_client_id(settings) + if not client_id: + return _json_error(400, "telegram_oauth_not_configured", "Telegram OAuth is not configured") + + nonce = create_telegram_oauth_nonce( + settings, + ttl_seconds=settings.WEBAPP_LOGIN_TOKEN_TTL_SECONDS, + ) + return web.json_response( + { + "ok": True, + "nonce": nonce, + "client_id": client_id, + "request_access": _resolve_telegram_oauth_request_access(settings), + } + ) + + +async def telegram_oauth_start_route(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + client_id = _resolve_telegram_oauth_client_id(settings) + client_secret = str(getattr(settings, "TELEGRAM_OAUTH_CLIENT_SECRET", "") or "").strip() + if not client_id or not client_secret: + raise web.HTTPFound(_telegram_oauth_redirect_url("/", status="not_configured")) + + purpose = str(request.query.get("purpose") or "login").strip().lower() + if purpose not in {"login", "link"}: + purpose = "login" + + current_user_id = _extract_authenticated_user_id(request) + if purpose == "link" and not current_user_id: + raise web.HTTPFound(_telegram_oauth_redirect_url("/", status="unauthorized")) + + code_verifier = secrets.token_urlsafe(64) + code_challenge = _urlsafe_sha256(code_verifier) + nonce = create_telegram_oauth_nonce( + settings, + ttl_seconds=settings.WEBAPP_LOGIN_TOKEN_TTL_SECONDS, + ) + state = create_signed_telegram_oauth_state( + settings, + { + "purpose": purpose, + "user_id": int(current_user_id) if current_user_id else None, + "referral_code": str(request.query.get("referral_code") or "")[:128], + "code_verifier": code_verifier, + "nonce": nonce, + }, + ttl_seconds=settings.WEBAPP_LOGIN_TOKEN_TTL_SECONDS, + ) + + scopes = ["openid", "profile"] + for permission in _resolve_telegram_oauth_request_access(settings): + if permission == "phone": + scopes.append("phone") + elif permission == "write": + scopes.append("telegram:bot_access") + + auth_query = urlencode( + { + "client_id": str(client_id), + "redirect_uri": _telegram_oauth_callback_url(settings, request), + "response_type": "code", + "scope": " ".join(scopes), + "state": state, + "nonce": nonce, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + } + ) + raise web.HTTPFound(f"https://oauth.telegram.org/auth?{auth_query}") + + +async def telegram_oauth_callback_route(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + error = str(request.query.get("error") or "") + if error: + raise web.HTTPFound(_telegram_oauth_redirect_url("/", status="cancelled")) + + code = str(request.query.get("code") or "") + state = verify_signed_telegram_oauth_state(settings, str(request.query.get("state") or "")) + if not code or not state: + raise web.HTTPFound(_telegram_oauth_redirect_url("/", status="invalid_state")) + + token_payload = await _exchange_telegram_oauth_code( + request, + code=code, + code_verifier=str(state.get("code_verifier") or ""), + redirect_uri=_telegram_oauth_callback_url(settings, request), + ) + id_token = str(token_payload.get("id_token") or "") if token_payload else "" + telegram_user = await validate_telegram_oauth_id_token( + id_token, + client_id=int(_resolve_telegram_oauth_client_id(settings) or 0), + expected_nonce=str(state.get("nonce") or ""), + max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS, + ) + if not telegram_user: + raise web.HTTPFound(_telegram_oauth_redirect_url("/", status="invalid_token")) + + purpose = str(state.get("purpose") or "login") + redirect_path = "/settings" if purpose == "link" else "/" + async_session_factory: sessionmaker = request.app["async_session_factory"] + final_user_id: Optional[int] = None + async with async_session_factory() as session: + try: + if purpose == "link": + current_user_id = int(state.get("user_id") or 0) + db_user = await _link_telegram_to_user( + request, + session, + current_user_id=current_user_id, + telegram_user=telegram_user, + settings=settings, + ) + else: + db_user = await _ensure_user_from_telegram( + session, + telegram_user, + settings, + referral_param=str(state.get("referral_code") or ""), + ) + referral_applied = await _apply_referral_to_existing_user( + request, + session, + db_user, + str(state.get("referral_code") or "") or telegram_user.get("start_param"), + ) + if getattr(db_user, "_webapp_created", False) or referral_applied: + await _apply_referral_welcome_bonus_if_needed( + request, + session, + db_user, + str(state.get("referral_code") or "") or telegram_user.get("start_param"), + ) + + if db_user.is_banned: + await session.rollback() + raise web.HTTPFound(_telegram_oauth_redirect_url("/", status="banned")) + + final_user_id = int(db_user.user_id) + await session.commit() + except web.HTTPFound: + raise + except UserMergeConflictError: + await session.rollback() + raise web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="merge_conflict")) + except Exception: + await session.rollback() + logger.exception("Telegram OAuth callback failed") + raise web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="failed")) + + token = create_webapp_session_token(settings, int(final_user_id)) + response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success")) + _set_webapp_auth_cookies(response, settings, token, secrets.token_hex(32)) + raise response + + +async def _validate_telegram_auth_payload( + request: web.Request, + payload: Dict[str, Any], +) -> Optional[Dict[str, Any]]: + settings: Settings = request.app["settings"] + init_data = str(payload.get("init_data") or "") + if init_data: + return validate_telegram_webapp_init_data( + init_data, + settings.BOT_TOKEN, + max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS, + ) + + oauth_id_token = str(payload.get("id_token") or "") + if oauth_id_token: + nonce = str(payload.get("nonce") or "") + client_id = _resolve_telegram_oauth_client_id(settings) + if not client_id or not verify_telegram_oauth_nonce(settings, nonce): + return None + return await validate_telegram_oauth_id_token( + oauth_id_token, + client_id=client_id, + expected_nonce=nonce, + max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS, + ) + + auth_data = payload.get("auth_data") + if auth_data is not None: + return validate_telegram_login_widget_data( + auth_data, + settings.BOT_TOKEN, + max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS, + ) + + return None + + def _resolve_webapp_js_asset_name() -> str: minified_assets = [] for path in ASSET_DIR.glob("subscription_webapp.min.*.js"): @@ -612,22 +918,8 @@ def _strip_marked_block(html: str, start_marker: str, end_marker: str) -> str: async def auth_token_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] payload = await _read_json(request) - init_data = str(payload.get("init_data") or "") - auth_data = payload.get("auth_data") referral_param = str(payload.get("referral_code") or payload.get("start_param") or "") - telegram_user = None - if init_data: - telegram_user = validate_telegram_webapp_init_data( - init_data, - settings.BOT_TOKEN, - max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS, - ) - elif auth_data is not None: - telegram_user = validate_telegram_login_widget_data( - auth_data, - settings.BOT_TOKEN, - max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS, - ) + telegram_user = await _validate_telegram_auth_payload(request, payload) if not telegram_user: return _json_error(401, "invalid_auth", "Invalid Telegram auth data") @@ -1120,21 +1412,7 @@ async def account_telegram_link_route(request: web.Request) -> web.Response: user_id = _require_user_id(request) settings: Settings = request.app["settings"] payload = await _read_json(request) - init_data = str(payload.get("init_data") or "") - auth_data = payload.get("auth_data") - telegram_user = None - if init_data: - telegram_user = validate_telegram_webapp_init_data( - init_data, - settings.BOT_TOKEN, - max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS, - ) - elif auth_data is not None: - telegram_user = validate_telegram_login_widget_data( - auth_data, - settings.BOT_TOKEN, - max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS, - ) + telegram_user = await _validate_telegram_auth_payload(request, payload) if not telegram_user: return _json_error(401, "invalid_auth", "Invalid Telegram auth data") diff --git a/bot/app/web/webapp_auth.py b/bot/app/web/webapp_auth.py index 9eb3dd4..0dffccc 100644 --- a/bot/app/web/webapp_auth.py +++ b/bot/app/web/webapp_auth.py @@ -1,8 +1,10 @@ import base64 +import asyncio import hashlib import hmac import json import logging +import secrets import time from typing import Any, Dict, Optional from urllib.parse import parse_qsl @@ -13,6 +15,9 @@ logger = logging.getLogger(__name__) # 5 minutes clock skew tolerance for Telegram clients TELEGRAM_CLOCK_SKEW_SECONDS = 300 +TELEGRAM_OAUTH_ISSUER = "https://oauth.telegram.org" +TELEGRAM_OAUTH_JWKS_URL = "https://oauth.telegram.org/.well-known/jwks.json" +TELEGRAM_OAUTH_ALGORITHMS = ["RS256", "ES256", "EdDSA"] def _urlsafe_b64encode(raw: bytes) -> str: @@ -74,6 +79,181 @@ def verify_webapp_session_token(settings: Settings, token: str) -> Optional[int] return None +def create_telegram_oauth_nonce(settings: Settings, *, ttl_seconds: int = 600) -> str: + now = int(time.time()) + payload = { + "n": secrets.token_urlsafe(24), + "iat": now, + "exp": now + max(60, int(ttl_seconds)), + } + payload_part = _urlsafe_b64encode( + json.dumps(payload, separators=(",", ":")).encode("utf-8") + ) + signature = hmac.new( + _session_secret(settings), + f"telegram-oauth-nonce.{payload_part}".encode("ascii"), + hashlib.sha256, + ).digest() + return f"{payload_part}.{_urlsafe_b64encode(signature)}" + + +def verify_telegram_oauth_nonce(settings: Settings, nonce: str) -> bool: + if not nonce or "." not in nonce: + return False + + try: + payload_part, signature_part = nonce.split(".", 1) + expected_signature = hmac.new( + _session_secret(settings), + f"telegram-oauth-nonce.{payload_part}".encode("ascii"), + hashlib.sha256, + ).digest() + received_signature = _urlsafe_b64decode(signature_part) + if not hmac.compare_digest(expected_signature, received_signature): + return False + + payload = json.loads(_urlsafe_b64decode(payload_part).decode("utf-8")) + now = int(time.time()) + if int(payload.get("exp", 0)) < now: + return False + if int(payload.get("iat", 0)) > now + TELEGRAM_CLOCK_SKEW_SECONDS: + return False + return bool(payload.get("n")) + except Exception as exc: + logger.debug("Failed to verify Telegram OAuth nonce: %s", exc) + return False + + +def create_signed_telegram_oauth_state( + settings: Settings, + payload: Dict[str, Any], + *, + ttl_seconds: int = 600, +) -> str: + now = int(time.time()) + state_payload = { + **payload, + "iat": now, + "exp": now + max(60, int(ttl_seconds)), + } + payload_part = _urlsafe_b64encode( + json.dumps(state_payload, separators=(",", ":")).encode("utf-8") + ) + signature = hmac.new( + _session_secret(settings), + f"telegram-oauth-state.{payload_part}".encode("ascii"), + hashlib.sha256, + ).digest() + return f"{payload_part}.{_urlsafe_b64encode(signature)}" + + +def verify_signed_telegram_oauth_state( + settings: Settings, + state: str, +) -> Optional[Dict[str, Any]]: + if not state or "." not in state: + return None + + try: + payload_part, signature_part = state.split(".", 1) + expected_signature = hmac.new( + _session_secret(settings), + f"telegram-oauth-state.{payload_part}".encode("ascii"), + hashlib.sha256, + ).digest() + received_signature = _urlsafe_b64decode(signature_part) + if not hmac.compare_digest(expected_signature, received_signature): + return None + + payload = json.loads(_urlsafe_b64decode(payload_part).decode("utf-8")) + now = int(time.time()) + if int(payload.get("exp", 0)) < now: + return None + if int(payload.get("iat", 0)) > now + TELEGRAM_CLOCK_SKEW_SECONDS: + return None + return payload + except Exception as exc: + logger.debug("Failed to verify Telegram OAuth state: %s", exc) + return None + + +async def validate_telegram_oauth_id_token( + id_token: str, + *, + client_id: int, + expected_nonce: str, + max_age_seconds: int, +) -> Optional[Dict[str, Any]]: + """Validate Telegram OIDC ID token and return a Telegram-like user payload.""" + + if not id_token or not client_id or not expected_nonce: + return None + + try: + import jwt + from jwt import PyJWKClient + except Exception as exc: + logger.error( + "PyJWT is not installed; Telegram OAuth ID token validation is unavailable: %s", + exc, + ) + return None + + try: + jwks_client = PyJWKClient(TELEGRAM_OAUTH_JWKS_URL) + signing_key = await asyncio.to_thread( + jwks_client.get_signing_key_from_jwt, + id_token, + ) + claims = await asyncio.to_thread( + jwt.decode, + id_token, + signing_key.key, + algorithms=TELEGRAM_OAUTH_ALGORITHMS, + audience=str(client_id), + issuer=TELEGRAM_OAUTH_ISSUER, + leeway=TELEGRAM_CLOCK_SKEW_SECONDS, + options={"require": ["exp", "iat", "iss", "aud"]}, + ) + + if not hmac.compare_digest(str(claims.get("nonce") or ""), expected_nonce): + logger.warning("Telegram OAuth nonce mismatch.") + return None + + now = int(time.time()) + issued_at = int(claims.get("iat") or 0) + max_age = max(60, int(max_age_seconds)) + if issued_at > now + TELEGRAM_CLOCK_SKEW_SECONDS or now - issued_at > max_age: + logger.warning("Telegram OAuth ID token is stale.") + return None + + telegram_id_raw = claims.get("id") + if not telegram_id_raw: + return None + telegram_id = int(telegram_id_raw) + + full_name = str(claims.get("name") or "").strip() + first_name = str(claims.get("given_name") or "").strip() + last_name = str(claims.get("family_name") or "").strip() + if full_name and not first_name: + name_parts = full_name.split(None, 1) + first_name = name_parts[0] + if len(name_parts) > 1 and not last_name: + last_name = name_parts[1] + + return { + "id": telegram_id, + "username": claims.get("preferred_username") or claims.get("username"), + "first_name": first_name or full_name or "Telegram", + "last_name": last_name, + "photo_url": claims.get("picture"), + "language_code": claims.get("locale"), + } + except Exception as exc: + logger.warning("Failed to validate Telegram OAuth ID token: %s", exc) + return None + + def validate_telegram_webapp_init_data( init_data: str, bot_token: str, diff --git a/config/settings.py b/config/settings.py index bd2a598..2710a71 100644 --- a/config/settings.py +++ b/config/settings.py @@ -337,6 +337,18 @@ class Settings(BaseSettings): WEBAPP_SESSION_TTL_SECONDS: int = Field(default=24 * 60 * 60) WEBAPP_AUTH_MAX_AGE_SECONDS: int = Field(default=24 * 60 * 60) WEBAPP_LOGIN_TOKEN_TTL_SECONDS: int = Field(default=10 * 60) + TELEGRAM_OAUTH_CLIENT_ID: Optional[int] = Field( + default=None, + description="Telegram Web Login Client ID from BotFather. Defaults to the numeric bot ID from BOT_TOKEN.", + ) + TELEGRAM_OAUTH_CLIENT_SECRET: Optional[str] = Field( + default=None, + description="Telegram Web Login Client Secret from BotFather. Reserved for full OIDC authorization code integrations.", + ) + TELEGRAM_OAUTH_REQUEST_ACCESS: Optional[str] = Field( + default="write", + description="Comma-separated Telegram Login permissions to request: write,phone. Leave empty to request only OpenID profile.", + ) SMTP_HOST: str = Field(default="smtp-relay.brevo.com") SMTP_PORT: int = Field(default=587) @@ -895,6 +907,8 @@ class Settings(BaseSettings): 'USER_AGREEMENT_URL', 'SUBSCRIPTION_MINI_APP_URL', 'WEBAPP_LOGO_URL', + 'TELEGRAM_OAUTH_CLIENT_SECRET', + 'TELEGRAM_OAUTH_REQUEST_ACCESS', 'SMTP_USERNAME', 'SMTP_PASSWORD', 'SMTP_FROM_EMAIL', diff --git a/requirements.txt b/requirements.txt index 2ccc54e..fdf3584 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ email-validator==2.3.0 sqlalchemy[asyncio]==2.0.45 asyncpg==0.31.0 aiocryptopay==0.4.8 +PyJWT[crypto]==2.10.1 diff --git a/tests/test_security.py b/tests/test_security.py index b6f0e99..6545a08 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -7,7 +7,11 @@ from unittest.mock import AsyncMock from aiohttp import web from bot.app.web import subscription_webapp -from bot.app.web.webapp_auth import create_webapp_session_token +from bot.app.web.webapp_auth import ( + create_telegram_oauth_nonce, + create_webapp_session_token, + verify_telegram_oauth_nonce, +) from bot.services.crypto_pay_service import CryptoPayService from bot.handlers.user.payment import yookassa_webhook_route from bot.services.freekassa_service import FreeKassaService @@ -227,6 +231,29 @@ class WebAppSecurityTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(response.status, 400) self.assertIn("description_too_long", response.text) + def test_telegram_oauth_nonce_round_trips(self): + settings = SimpleNamespace( + WEBAPP_SESSION_SECRET="session-secret", + WEBAPP_SESSION_TTL_SECONDS=3600, + ) + nonce = create_telegram_oauth_nonce(settings, ttl_seconds=60) + + self.assertTrue(verify_telegram_oauth_nonce(settings, nonce)) + self.assertFalse(verify_telegram_oauth_nonce(settings, nonce + "tampered")) + + def test_telegram_oauth_client_id_defaults_to_bot_id(self): + settings = SimpleNamespace( + BOT_TOKEN="123456789:secret", + TELEGRAM_OAUTH_CLIENT_ID=None, + TELEGRAM_OAUTH_REQUEST_ACCESS="write, phone, unknown, write", + ) + + self.assertEqual(subscription_webapp._resolve_telegram_oauth_client_id(settings), 123456789) + self.assertEqual( + subscription_webapp._resolve_telegram_oauth_request_access(settings), + ["write", "phone"], + ) + def asyncio_run(coro): import asyncio