Merge branch 'dev' into feature/new-tariffs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=<client-id-из-botfather>
|
||||
TELEGRAM_OAUTH_CLIENT_SECRET=<client-secret-из-botfather>
|
||||
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_<referral_code>`; email добавляется в описание пользователя панели и, если API панели принимает поле email, передается отдельным полем. Для Telegram-регистраций сохраняется существующая схема `tg_<telegram_id>`.
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -265,7 +266,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;
|
||||
@@ -477,6 +477,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 || changeModalOpen || changeConfirmOpen || topupModalOpen || linkEmailOpen);
|
||||
$: if (!tariffMode && !selectedPlan && plans.length) selectedPlan = plans[Math.min(1, plans.length - 1)];
|
||||
@@ -678,6 +679,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;
|
||||
@@ -893,6 +912,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;
|
||||
@@ -914,7 +938,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);
|
||||
@@ -949,7 +984,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);
|
||||
@@ -1083,38 +1123,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() {
|
||||
@@ -1124,36 +1138,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) {
|
||||
@@ -1266,29 +1258,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) {
|
||||
|
||||
@@ -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",
|
||||
@@ -184,11 +191,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)
|
||||
@@ -242,6 +252,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()
|
||||
@@ -542,6 +649,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"],
|
||||
@@ -599,6 +708,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"):
|
||||
@@ -625,22 +931,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")
|
||||
@@ -1133,21 +1425,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")
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -343,6 +343,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)
|
||||
@@ -925,6 +937,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',
|
||||
|
||||
@@ -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
|
||||
|
||||
+28
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user