Нормальная авторизация на сайте через Telegram

Подготовлено ElKornacio: t.me/elkornacio

Telegram Auth Cover

Telegram - один из самых популярных мессенджеров в мире. Если верить статистике, то в 2024 году насчитывается около миллиарда пользователей Telegram.

Возникает вопрос, если почти каждый восьмой человек в мире использует этот мессенджер, то почему мы не видим авторизацию через Telegram практически нигде? Когда последний раз вы, открывая форму логина, рядом с кнопками "Войти через Google" или "Войти через Facebook" видели "Войти через Telegram"?

Думаю, почти никогда.

Причина проста: у Telegram совершенно ужасная авторизация в вебе. Сказать, что она сделана криво - не сказать ничего.

Речь идёт, конечно, про Telegram Login for Web.

У вас может быть установлен Telegram Desktop, в котором вы авторизованы, рядом с вами лежит телефон, с установленным и авторизованным Telegram. Но... Telegram Login for Web попросит вас авторизоваться ещё раз, и визуально все будет выглядеть так, как будто кто-то пытается своровать ваш аккаунт: неожиданное всплывающее окно, вас сходу просят указать номер телефона, после чего вам придет сообщение, в котором надо нажать "Подтвердить".

В идеальном мире, это должно быть так: я нажимаю кнопку "Войти через Telegram", меня сразу же кидает на установленное приложение, в нём я нажимаю "Подтвердить", всё.

Сегодня именно это мы и сделаем: авторизацию в Telegram через десктоп или телефон, в один клик.

Общий процесс

Если пользователь в вебе откроет ссылку вида https://t.me/MyBot?start=1234567890, то его автоматически кинет в установленное приложение Telegram, а бот MyBot получит всю информацию о пользователе, а также параметр start, который мы передали в ссылке.

Соответственно, что нам нужно:

  1. Сгенерировать одноразовую сесиию, передать её ID в ссылке для бота.
  2. [Опционально] отобразить юзеру QR-код со ссылкой, чтобы он мог авторизоваться используя телефон.
  3. Подключить Node.js к API Telegram ботов, поймать /start сообщение с ID сессии и данными пользователя.
  4. Оповестить браузер (пуллингом или по WebSocket), что юзер успешно авторизовался.

Вариант 1: простой, один сайт = один бот

Фронт должен:

  1. Локально генерировать рандомный ID, кидать юзера в бота с этим ID как start-параметром
  2. Пуллингом делать запросы на сервер, спрашивая "не произошла ли авторизация для этого ID"
  3. Если произошла - либо сохранить куку (браузер сделает это сам), либо положить токен в localStorage/sessionStorage.

Бот-бекенд должен:

  1. Слушать входящие сообщения вида /start *, и вытаскивать из них параметр
  2. Сохранять информацию пользователя в БД, авторизовывать его (генерируя токен или куку)
  3. Отдавать эту куку (или токен) через пуллинг на фронт

Начнём с фронта, для простоты - пусть это будет ванильный HTML/JS, без фреймворков.

Copy

const randomId = Math.random().toString(36).substring(2, 15); const linkEl = document.getElementById('telegram-login'); linkEl.href = `https://t.me/MyBot?start=tga_${randomId}`; // этот обработчик сработает при клике по ссылке, он будет делать запросы на бекенд, проверяя, не произошла ли авторизация linkEl.addEventListener('click', async () => { const get = url => fetch(url).then(res => res.json()); const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); let attempts = 0; // совершаем 30 попыток с паузой в секунду между ними while (attempts < 30) { const data = await get(`/auth/telegram?id=${randomId}`); if (data.success) { handleSuccessfulAuth(data); break; } attempts++; await sleep(1000); } }); function handleSuccessfulAuth(data) { console.log('Мы успешно авторизовались!', data); // тут можно сохранить токен или выполнить ещё какую-то логику } // ваш html: <a href="#" id="telegram-login"> Авторизоваться через Telegram </a>;

Отлично, теперь, когда юзер нажмет на кнопку, его кинет в Telegram, и бот получит всю информацию о нём, включая start-параметр.

Займёмся бекендом. Вновь же, для простоты: Express.js и node-telegram-bot-api. Для временного хранения данных использую @tiny-utils/temporary. Для авторизации будем использовать куки.

Copy

const express = require('express'); const cookieParser = require('cookie-parser'); const TelegramBot = require('node-telegram-bot-api'); const { TemporaryMap } = require('@tiny-utils/temporary'); const BOT_TOKEN = '1234567890:AAH-1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ'; // ваш токен const app = express(); app.use(cookieParser()); const bot = new TelegramBot(BOT_TOKEN, { polling: true }); // тут мы храним связку ID -> авторизационный ключ const map = new TemporaryMap(1 * 60 * 1000); // 1 минута bot.on('message', async msg => { if (msg.from && msg.text?.startsWith('/start')) { const id = msg.text.split(' ')[1]; if (id && id.startsWith('tga_')) { const sessionId = id.slice(4); // удаляем префикс tga_ // тут ваша логика сохранения пользователя в базу const userInDatabase = await saveUserToDatabase(msg.from); // а тут ваша логика генерации токена - например, JWT const token = await generateToken(userInDatabase); map.set(id, token); } } }); // этот endpoint будет вызываться из фронта для получения статуса авторизации app.get('/auth/telegram', (req, res) => { const { id } = req.query; if (map.has(id)) { const token = map.get(id); // нашли токен? записываем в куки res.setCookie('auth', token); return res.json({ success: true }); } else { return res.json({ success: false }); } });

Вариант 2: делаем всё безопаснее, один бот, много сайтов

Внимательный читатель в предыдущем пункте мог подумать "мало того, что Math.random не крипто-стойкий, так ещё и заспамить БД можно, просто открывая бота со /start параметром с разных юзеров" (оставим за скобками то, что такое количество Telegram-аккаунтов сделает эту атаку дико неэффективной).

Дополнительно - а что если я хочу не просто авторизовать пользователя, а прикрепить его Telegram к уже существующему аккаунту? Это вполне популярное решение - на том же Хабре у меня один аккаунт, в который я могу войти через несколько соцсетей. В таком случае, уже на этапе получения ID сессии для ссылки на бота, я должен как-то прикрепить свой текущий авторизованный аккаунт.

Меняем логику фронта:

Copy

// делаем запрос с куками const get = url => fetch(url, { credentials: 'include' }).then(res => res.json()); const sessionId = await get('/auth/telegram/init-session'); const linkEl = document.getElementById('telegram-login'); linkEl.href = `https://t.me/MyBot?start=tga_${sessionId}`; // оставляем ниже всё как есть (логику авторизации по клику)

И переделываем бекенд:

Copy

// новые импорты: import { randomBytes } from '@tiny-utils/crypto'; import { uint8ArrayToHex } from '@tiny-utils/bytes'; // ... остальные импорты как были, допустим у нас появился middleware, который в req.user кладёт данные текущего пользователя // создаём ещё один map, который будет хранить связку ID сессии -> сайт + ID пользователя const sessionMap = new TemporaryMap(1 * 60 * 1000); // 1 минута bot.on('message', async msg => { if (msg.from && msg.text?.startsWith('/start')) { const id = msg.text.split(' ')[1]; if (id && id.startsWith('tga_')) { const sessionId = id.slice(4); // удаляем префикс tga_ const session = sessionMap.get(sessionId); if (!session) { bot.sendMessage(msg.from.id, 'Сессия не найдена'); return; } const { userId, origin } = session; // тут ваша логика сохранения пользователя в базу, по userId можно привязать к существующему аккаунту // по origin - определить сайт, откуда пришёл пользователь const userInDatabase = await saveUserToDatabase(origin, userId, msg.from); // а тут ваша логика генерации токена - например, JWT const token = await generateToken(userInDatabase); map.set(id, token); } } }); app.get('/auth/telegram/init-session', (req, res) => { if (!req.headers.origin) { return res.status(400).json({ error: 'Origin is required' }); } const id = uint8ArrayToHex(randomBytes(8)); // 16 символов рандомного hex // сохраняем данные для бота sessionMap.set(id, { userId: req.user ? req.user.id : null, origin: req.headers.origin, }); return res.json(id); }); // endpoint /auth/telegram не изменился. при желании, в него можно добавить логику проверки origin

Вариант 3: хардкор - React, QR-код, Passport.js, WebSocket