Представьте заявку №1042. На сайте она в списке со статусом «в работе», и в боте та же строка с тем же статусом. Хороший знак? Пока неизвестно. Возможны два варианта: это одна запись, которую читают два интерфейса, или две записи, случайно похожие друг на друга. И есть вопрос поострее: чем доказано, что человек в Telegram и человек в браузере — один и тот же человек? Если ответа нет, «единый backend» превращается в удобный способ показать чужую заявку чужому пользователю.
Эта статья про то, как собрать общий слой бизнес-логики для сайта и бота и при этом не слить в одну кашу то, что должно оставаться раздельным: способы входа, права и жизненные циклы доступа. Примеры будут на Django, потому что это конкретный и знакомый фреймворк. Сама идея от него не зависит, а вот названия функций и настроек, разумеется, зависят.
Аккаунт — это ваша запись, а не чей-то username
Первое решение определяет всё остальное: в базе должна жить собственная сущность «аккаунт» с собственным внутренним идентификатором. Не username из Telegram, не email, не номер телефона. Username можно сменить или освободить, email можно потерять, а внутренний ключ вашей базы остаётся тем же. Заявки, статусы и права привязываются именно к нему.
Каналы при этом становятся отдельными записями-«удостоверениями», которые ссылаются на аккаунт. Карта идентификаторов выглядит так:
| Идентификатор | Откуда берётся | Что доказывает | Чего не доказывает |
|---|---|---|---|
| account_id | ваша база | существование аккаунта | ничего о каналах |
| Telegram id (числовой) | профиль Telegram, initData, update бота | что это конкретный Telegram-пользователь | что он владелец сайтового аккаунта |
| sub из OIDC | ID token Telegram Login | субъект в рамках издателя токена (iss) | тождество с числовым id без отдельного решения |
| username | профиль | ничего надёжного | постоянства и единого владельца |
| сессия сайта | ваш сервер | что браузер недавно прошёл вход | что Telegram-доступ ещё жив |
Обратите внимание на строку про sub. В документации Telegram Login scope openid отдаёт sub, iss, iat и exp, а scope profile добавляет числовой id, имя и username; sub и id — разные поля, и автоматически отождествлять их нельзя (Telegram: Log In With Telegram). Практический вывод: выберите одно поле как ключ связи, зафиксируйте это решение в коде и не смешивайте. Если сегодня ключом стал sub вместе с iss, то завтра бот, который знает только числовой id чата, не должен «угадывать» соответствие по совпадению цифр.
В таблице базы это удобно выразить двумя ограничениями. Первое: пара «тип канала + внешний идентификатор» уникальна — один Telegram-пользователь не может быть привязан к двум аккаунтам. Второе: связь ссылается на account_id, и у одного аккаунта допустимо несколько связей. PostgreSQL умеет UNIQUE на несколько столбцов, а PRIMARY KEY исключает NULL (PostgreSQL: Constraints). Заодно полезная мелочь из той же документации: пустая строка — не NULL. Если «нет внешнего id» записать как '', уникальность вдруг начнёт спорить сама с собой, поэтому отсутствие значения храните как NULL либо вообще не создавайте строку.
Как связать два канала, не доверяя совпадениям
Допустим, человек уже вошёл на сайт (скажем, по почте и паролю) и хочет подключить Telegram, чтобы получать статусы заявок в чат. Или наоборот: он написал боту, а потом открыл сайт. Угроза в обоих случаях одна — чтобы кто-то посторонний не привязал свой Telegram к чужому аккаунту или чужой аккаунт к своему Telegram.
Безопасное связывание держится на простом принципе: связь создаётся только действием, которое доказывает контроль над обоими концами в одном потоке. Типовой короткий путь:
- Пользователь, уже вошедший на сайте, нажимает «Подключить Telegram». Это изменяющий запрос, поэтому он идёт методом POST с CSRF-токеном.
- Backend создаёт запись о попытке связывания: случайное значение достаточной длины, account_id, срок жизни в считанные минуты, признак «не использовано». В базе хранится не сам токен, а его хеш, чтобы утечка таблицы не давала готовых ссылок.
- Сайт показывает ссылку на бота вида t.me/имя_бота?start=значение. Документация Telegram прямо называет такое применение: deep link передаёт start-параметр в /start, и это может быть auth token для связывания с аккаунтом другой платформы. Допустимы буквы, цифры, подчёркивание и дефис, длина ограничена (Telegram: Deep Linking), так что генерируйте значение в этом алфавите, например base64url без символа «=».
- Пользователь открывает бота, бот получает /start со значением. Backend находит попытку по хешу, проверяет срок и признак использования, затем в одной транзакции помечает попытку использованной и создаёт связь между account_id и Telegram id отправителя.
- Сайт и бот сообщают результат: на сайте появляется «Telegram подключён», в боте — короткое подтверждение.
Здесь важна оговорка из самой документации: start-параметр — это способ донести строку до бота, но не готовый протокол безопасности. Безопасность создаёт то, что вы вокруг него построили: одноразовость, короткий срок, привязка к аккаунту, вошедшему в браузере, и пометка «использовано» в той же транзакции, что и создание связи. Транзакция даёт «всё или ничего» для операций базы (PostgreSQL: Transactions), поэтому либо связь создана и токен погашен, либо ни то ни другое. А вот сообщение боту «готово» в эту атомарность не входит: это внешний вызов, и гарантии транзакции на него не распространяются. Отправляйте его после COMMIT и будьте готовы, что оно потеряется — статус связи при этом остаётся верным.
Что делать, если Telegram-пользователь уже связан с другим аккаунтом? Не перезаписывать молча. Показать отказ с понятным текстом и предложить отвязать старую связь из того аккаунта, где человек способен войти. Автослияние аккаунтов «потому что совпал username» — именно тот сценарий, ради которого и строится вся эта конструкция защиты.
Обратное направление (сначала бот, потом сайт) устроено зеркально. Бот создаёт одноразовый код для конкретного Telegram id, человек вводит его или переходит по ссылке на сайте, находясь в нужной сессии, и сайт подтверждает связь. Принцип тот же: два независимых доказательства в одном коротком окне.
Три входа — три разных вопроса
Дальше начинается самое частое недоразумение: «пользователь пришёл из Telegram, значит, он авторизован». Это слишком грубо. Есть как минимум три разных ситуации, и каждая отвечает на свой вопрос.
Вход на сайт через Telegram Login
Текущая документация описывает библиотеку Telegram Login и OIDC Authorization Code Flow с PKCE; старый iframe-виджет там указан как архивный (документация Telegram Login). Для этого входа регистрируют разрешённые origins и redirect URI. Поток такой: backend генерирует state (защита от CSRF) и code verifier для PKCE, отправляет пользователя на авторизацию, получает код и обменивает его на токены.
Критичный шаг — проверка ID token. Это не «раскодировали JSON и прочитали поля». Нужно проверить подпись по JWKS, затем iss (издатель — тот, которого вы ожидаете), aud (токен выпущен для вашего клиента, а не чужого) и exp (срок не истёк). Любая из четырёх проверок не пройдена — вход отклонён. Токен с правильными полями, но с неподтверждённой подписью — это просто текст, который кто-то написал. Отдельно имейте в виду: Django сам по себе Telegram OIDC не поддерживает, это нужно подключать отдельной интеграцией или библиотекой, а проверки выше делает ваш код либо выбранная вами OIDC-библиотека.
После успешной проверки вы получаете sub (и, при scope profile, числовой id). Дальше смотрим в таблицу связей: есть ли связанный аккаунт? Если да — открываем сессию этого аккаунта. Если нет — это либо регистрация нового аккаунта, либо предложение войти способом, который у человека уже есть, и подключить Telegram изнутри. Автоматически присоединять Telegram к существующему аккаунту по совпавшей почте или имени нельзя: совпадение текста не доказывает владение.
Если вам нужно писать пользователю в личку после входа, запрашивается scope telegram:bot_access. Но это отдельное разрешение на отправку сообщений, а не право на объекты вашего приложения: заявки по-прежнему защищает ваша логика прав.
Mini App и initData
Если часть интерфейса живёт внутри Telegram как Mini App, она получает initData. Его проверяют на сервере, а initDataUnsafe доверенным считать нельзя; поле auth_date позволяет отсекать слишком старые данные (Telegram: Mini App data validation). Что это даёт: уверенность, что запрос пришёл из Mini App вашего бота от конкретного Telegram-пользователя. Чего это не даёт: сессии сайта. Подпись WebApp — не авторизация сайта, и у неё свой срок давности. Не смешивайте auth_date Mini App с exp в OIDC: это разные механизмы с разной семантикой, и подставлять одно вместо другого — путь к ошибке.
Практичный вариант: на каждый запрос Mini App backend проверяет initData, определяет Telegram id, ищет связанный account_id и только потом обращается к общему сервису заявок. Либо обменивает проверенный initData на собственную короткоживущую серверную сессию Mini App. В обоих случаях источником прав остаётся ваша база.
Обычный вход на сайт и его сессия
На самом сайте работает привычная сессия. В Django по умолчанию данные сессии хранятся на сервере (в базе), а в cookie лежит только идентификатор. Вариант cached_db пишет и в базу, и в кэш, а локальный memory cache небезопасен при нескольких процессах (Django: How to use sessions). Для нашей задачи есть жирная причина выбрать серверные сессии: signed-cookie сессии подписаны, но не зашифрованы, их содержимое читается клиентом, а logout не инвалидирует украденную копию cookie до истечения срока. Если вам нужен управляемый отзыв доступа, держите сессии на сервере или добавьте собственную проверку актуальности прав.
Изменяющие запросы с сайта защищает CSRF middleware: он сверяет cookie и токен, проверяет Origin, а при HTTPS без Origin смотрит Referer. Безопасные методы должны быть без побочных эффектов (Django: CSRF protection). Но запомните ограничение: CSRF защищает от запросов с чужого сайта от имени вошедшего браузера и не отвечает на вопрос «имеет ли этот человек право на заявку №1042». Это вопрос уже к вашему сервису.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Заявка проходит через один сервис
Теперь собственно «один backend». Его суть не в том, что бот ходит на сайт по HTTP, а сайт — на бота. Суть в том, что у вас есть один слой доменной логики, например функция или класс create_request(account_id, payload, idempotency_key), и обе поверхности — веб-вью и обработчик бота — вызывают только его. Всё, что связано с Telegram или с HTTP-формой, остаётся снаружи: разбор update, проверка initData, рендер страницы.
Что делает такой сервис, прежде чем создать заявку:
- принимает уже определённый account_id, а не «Telegram id из текста сообщения»;
- проверяет право этого аккаунта выполнять действие (создать заявку, увидеть чужую — нет);
- валидирует данные одинаково для обоих каналов: если на сайте телефон обязателен, в боте он тоже обязателен;
- записывает заявку и служебные данные (источник канала, ключ повторов) в одной транзакции.
Источник канала — полезное поле «откуда создана», но не повод для разной логики прав. Заявка не «сайтовая» и не «ботовая»; она заявка аккаунта.
Для чтения действует то же правило: каждый запрос статуса фильтруется по account_id, а не по заявке, которую вам назвали. Фраза «покажи заявку 1042» должна превращаться в запрос «найди заявку 1042, принадлежащую этому аккаунту», а не «найди заявку 1042». Последняя форма — классическая дыра, когда номер угадывается перебором. Разделяйте и права самой базы: SELECT, INSERT, UPDATE и DELETE — разные права, а владелец объекта обладает полномочиями, не сводящимися к чтению (PostgreSQL: Privileges). Поэтому приложению разумно подключаться отдельной ролью с минимально нужным набором, а не владельцем схемы. Например, роли веб-процесса и воркера бота могут получить только те операции, которые им реально нужны.
Повторы: когда один update приходит дважды
Бот принимает события по webhook. Если ваш сервер ответил неуспешным HTTP-кодом, Telegram повторит доставку (Telegram: setWebhook), и повторы прекращаются после некоторого числа попыток: очередь не хранится бессрочно (Telegram Bot API: Update). Из этого следуют две вещи. Во-первых, один и тот же update может прилететь дважды, особенно если вы успели создать заявку, но не успели ответить 200. Во-вторых, молчаливый простой сервера может стоить вам потерянных событий, поэтому бизнес-критичные шаги лучше не прятать только за webhook, а давать пользователю способ переспросить статус.
Защита проста по замыслу. Update_id уникально идентифицирует update, и документация указывает на его пригодность для отбрасывания повторов. Заведите таблицу обработанных событий с уникальным ключом по update_id и записывайте в неё в той же транзакции, что и создание заявки. Повторная доставка упрётся в ограничение уникальности, и вы ответите успехом, ничего не создавая второй раз. Но не стройте логику на вечной непрерывности нумерации: после долгого отсутствия updates следующий идентификатор может выбираться случайно. Update_id — ключ для дедупликации, а не счётчик, по которому проверяют «не пропустили ли мы что-то».
Для сайта аналог — ключ идемпотентности формы: при открытии страницы сервер выдаёт значение, и повторная отправка (двойной клик, сбой сети, кнопка «назад») не создаёт вторую заявку. Заметьте: просто присваивать новое значение на каждую попытку не помогает. Идемпотентность появляется, когда повтор приходит с тем же ключом, а сервер умеет это распознать. Для бота роль такого ключа играет update_id, для сайта — значение, выданное форме.
И последний нюанс. Webhook подтверждают через secret_token, который Telegram передаёт в специальном заголовке. Проверяйте его на входе в обработчик до разбора тела: иначе любой, знающий URL, может отправить вам «update» и создать заявку от имени чужого Telegram id.
Три разных «выключателя»
Теперь самый интересный момент, ради которого всё и задумывалось. Что должно случиться, когда доступ в одном канале пропадает? Здесь легко ошибиться в обе стороны: либо оставить открытым то, что надо закрыть, либо снести лишнее. Различим три события.
Выход с сайта. Пользователь нажал «выйти». В Django это logout, который использует flush: удаляются данные текущей сессии и её cookie. Но это обещание про текущую сессию, а не про сессии на других устройствах. Если нужен выход «со всех устройств», это отдельная функция: например, удалить все серверные сессии аккаунта или хранить у аккаунта счётчик версии прав и сравнивать его при каждом запросе. На связь с Telegram выход не влияет: бот продолжит присылать статусы, и это нормально, ведь человек вышел из браузера, а не отвязывал чат.
Блокировка бота пользователем. Когда человек блокирует или разблокирует бота в приватном чате, приходит my_chat_member. Это событие о доступе бота к чату, а не о судьбе аккаунта на сайте. Разумная реакция: пометить Telegram-канал как «недоступен для уведомлений», перестать отправлять туда сообщения и показать на сайте подсказку «уведомления в Telegram отключены». Аккаунт, заявки и сайтовая сессия остаются как были. Если человек разблокирует бота, канал можно вернуть в рабочее состояние. Вот и вся логика.
Разрыв связи аккаунтов. Это уже ваше собственное, осознанное действие: пользователь или администратор нажимает «отвязать Telegram». Backend помечает связь отключённой (или удаляет её), и с этой секунды Telegram id перестаёт соответствовать account_id. Документация Telegram не описывает отдельного endpoint для отзыва токенов, поэтому на него рассчитывать не стоит: отзыв доступа делается в вашей базе. И главное правило, которое делает это работающим: права проверяются при каждом действии, а не однажды при входе. Бот, получив сообщение, не доверяет прошлому «мы же уже знакомы». Он каждый раз ищет активную связь по Telegram id, и если её нет, отвечает «подключите аккаунт», ничего не показывая.
| Событие | Что закрывается | Что остаётся |
|---|---|---|
| Выход с сайта | текущая сессия браузера | связь с Telegram, доступ бота к заявкам |
| Блокировка бота | отправка сообщений в этот чат | аккаунт, заявки, сессия сайта |
| Отвязка Telegram | доступ из бота и Mini App к заявкам аккаунта | аккаунт и вход на сайт другим способом |
Отдельно подумайте о случае, когда потерян Telegram-аккаунт целиком: телефон украли или номер перешёл другому человеку. Тогда злоумышленник получит доступ к боту и увидит заявки, пока связь активна. Поэтому на сайте нужна кнопка отвязки, доступная тем, кто способен войти другим способом, и желательно, чтобы чувствительные действия (смена контактов, удаление заявки) в боте требовали подтверждения на сайте. Это ваше решение об уровнях доверия, а не требование Telegram.
Репетиция до переключения
Прежде чем отдавать этот backend пользователям, прогоните несколько сценариев, которые ловят именно ошибки связывания, а не общую работоспособность. Проверьте на тестовых аккаунтах:
- токен связывания использован дважды: второй раз должен получить отказ;
- токен истёк: связь не создаётся, а пользователь видит понятное сообщение;
- Telegram уже связан с другим аккаунтом: никакой перезаписи;
- один и тот же update доставлен дважды: заявка одна;
- ID token с неверным aud, неверным iss или просроченным exp отклоняется, так же как и токен с битой подписью;
- отвязка Telegram: следующее сообщение боту не показывает заявок;
- пользователь A подставляет номер заявки пользователя B: получает «не найдено».
Эти проверки быстрее всего гонять на отдельном стенде, не касаясь боевых данных. Для такого стенда подойдёт обычный VPS: у MAATRIX это NVMe-диски, процессоры AMD EPYC и root-доступ, так что можно поднять backend, базу и webhook-обработчик на одной машине. Тарифы начинаются с Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe), а для связки «Django, PostgreSQL и воркер бота» больше запаса даёт, например, Uab (2 vCPU, 4 ГБ, 80 ГБ). Это ориентир по ресурсам, а не обещание ёмкости под конкретное число пользователей: нагрузку лучше измерить. Если вы ещё выбираете, где разместить самого бота, пригодится материал VPS для Telegram-бота: как выбрать сервер и запустить бота 24/7.
Когда всё сходится
Вернёмся к заявке №1042. Теперь на вопрос из начала есть ответ. Это одна запись, у которой один владелец: account_id в вашей базе. Сайт показывает её тому, у кого есть активная сессия этого аккаунта, бот — тому, чей Telegram id связан с этим же аккаунтом через подтверждённую одноразовую процедуру. Статус меняется в одном месте, поэтому оба интерфейса видят одно и то же.
А что если человек потерял доступ к одному из каналов? Выход с сайта не отключает бот, блокировка бота не стирает сайт, а отвязка закрывает бот, не трогая вход в браузере. Чужой человек, завладевший одним каналом, не получает второй автоматически: для связывания нужны оба доказательства сразу, а право на каждую заявку проверяется заново при каждом обращении. Именно так «один backend» перестаёт быть риском и становится тем, чем задумывался, — одним источником правды с несколькими дверями, у каждой из которых свой замок.