Telegram Login Widget — кнопка во iframe, которая возвращает поля профиля и подпись hash, посчитанную с токеном бота. Виджет удобен, но все поля полезной нагрузки (в том числе id, auth_date, first_name) может подделать злоумышленник. Доказывает, что данные пришли из Telegram, только проверка подписи на сервере.
В статье собираем рабочий путь проверки:
- принять payload виджета (
id,first_name,last_name,username,photo_url,auth_date,hash); - пересчитать HMAC-SHA-256 и сравнить с
hashчерезhash_equals; - ограничить окно
auth_date, чтобы украденный payload нельзя было крутить бесконечно; - привязать
telegram_idк пользователю в Yii2 (создать при первом входе).
Важно: виджет — это утверждение личности, а не готовая авторизация. Свою сессию вы всё равно выдаёте сами.
Что присылает виджет
После успешного входа Telegram добавляет поля в query-параметры callback (data-onauth или JS-callback). Типичный payload:
id=12345678
first_name=Alex
last_name=Ivanov
username=alex_ivanov
photo_url=https%3A%2F%2Ft.me%2Fi%2Fuserpic%2F...%2F...jpg
auth_date=1716300000
hash=9f2c5b1e8a4f4d0c...e6
id приходит строкой, но это всегда числовой user id. auth_date — Unix-секунды. hash — hex от HMAC-SHA-256 (64 символа).
Алгоритм проверки
Строка для подписи собирается из всех полей кроме hash: пары key=value, отсортированные по ключу, через перевод строки \n. Пустые значения отбрасываются.
secret_key = SHA256(bot_token) // 32 байта, raw
computed = HMAC-SHA-256(secret_key, data_check_string) // hex
Ключ HMAC — это бинарный SHA-256 токена бота (hash('sha256', $botToken, true)). Частая ошибка в туториалах — забыть флаг true и получить hex-строку вместо 32 байт.
Сетевых вызовов Bot API здесь нет: всё считается локально.
Верификатор на чистом PHP
<?php
declare(strict_types=1);
final class TelegramLoginVerifier
{
private const CLOCK_SKEW_SECONDS = 300; // 5 минут
public function __construct(
private readonly string $botToken,
private readonly int $now,
) {}
/**
* @param array<string, string> $payload
* @return array{ok: bool, reason?: string, profile?: array{id: int, first_name: string, last_name: ?string, username: ?string, photo_url: ?string}}
*/
public function verify(array $payload): array
{
$hash = $payload['hash'] ?? '';
unset($payload['hash']);
if ($hash === '' || !preg_match('/^[a-f0-9]{64}$/', $hash)) {
return ['ok' => false, 'reason' => 'bad_hash_format'];
}
$authDate = (int) ($payload['auth_date'] ?? 0);
if ($authDate <= 0 || abs($this->now - $authDate) > self::CLOCK_SKEW_SECONDS) {
return ['ok' => false, 'reason' => 'auth_date_expired'];
}
$pairs = [];
foreach ($payload as $k => $v) {
if ($v === '' || $v === null) {
continue;
}
$pairs[] = $k . '=' . $v;
}
sort($pairs, SORT_STRING);
$dataCheckString = implode("\n", $pairs);
$secretKey = hash('sha256', $this->botToken, true);
$computed = hash_hmac('sha256', $dataCheckString, $secretKey);
if (!hash_equals($computed, $hash)) {
return ['ok' => false, 'reason' => 'bad_signature'];
}
return [
'ok' => true,
'profile' => [
'id' => (int) $payload['id'],
'first_name' => (string) $payload['first_name'],
'last_name' => $payload['last_name'] ?? null,
'username' => $payload['username'] ?? null,
'photo_url' => $payload['photo_url'] ?? null,
],
];
}
}
Сравнивайте подпись через hash_equals, не через обычный ===.
Подключение в Yii2
Токен бота — только из params/ENV, не в коде:
// config/params.php
return [
'telegramBotToken' => getenv('TELEGRAM_BOT_TOKEN') ?: '',
];
В модели пользователя нужны поля вроде telegram_id (BIGINT UNIQUE NULL) и last_telegram_login_at. Контроллер:
<?php
declare(strict_types=1);
namespace app\controllers;
use Yii;
use yii\web\Controller;
use app\models\User;
use app\security\TelegramLoginVerifier;
final class TelegramAuthController extends Controller
{
public function actionCallback(): \yii\web\Response
{
$token = (string) Yii::$app->params['telegramBotToken'];
if ($token === '') {
throw new \yii\web\HttpException(500, 'Bot token not configured');
}
$payload = Yii::$app->request->get();
$verifier = new TelegramLoginVerifier($token, time());
$result = $verifier->verify($payload);
if (!$result['ok']) {
Yii::warning('Telegram login rejected: ' . $result['reason'], __METHOD__);
return $this->redirect(['site/login', 'error' => 'telegram_verification_failed']);
}
$profile = $result['profile'];
$user = User::findOne(['telegram_id' => $profile['id']]);
if ($user === null) {
$user = new User([
'telegram_id' => $profile['id'],
'username' => $profile['username'] ?? ('tg_' . $profile['id']),
'first_name' => $profile['first_name'],
'last_name' => $profile['last_name'],
'last_telegram_login_at' => time(),
]);
if (!$user->save(false)) {
throw new \yii\web\HttpException(500, 'Cannot create user');
}
} else {
$user->last_telegram_login_at = time();
$user->save(false, ['last_telegram_login_at']);
}
Yii::$app->user->login($user, 3600 * 24 * 30);
return $this->redirect(['site/index']);
}
}
После привязки telegram_id виджет не должен решать, какую строку пользователя выбрать: ищем только по этому id. Имя/username можно обновлять, id — нет.
Заметки для продакшена
- Окно по времени. 5 минут — разумный дефолт. Меньше — ломаются долгие подтверждения; больше — легче replay украденного payload.
- Ротация токена бота. После смены токена старые подписи виджета перестают проходить. «Мягкого» перехода нет — крутите токен, только если готовы к повторному входу.
- Домен Login Widget. Настраивается на my.telegram.org, не в BotFather. Один бот можно использовать и для виджета, и для Bot API; для виджета пользователю не нужен
/start. - Выход. Достаточно
Yii::$app->user->logout(). Telegram о вашей сессии не знает. - Не вызывайте Bot API «для перепроверки». Не нужна
getChat/getProfilePhotos: hash уже серверная подпись. Лишний HTTP только тащит токен в логи и добавляет задержку.
Если нужны и Login Widget, и Mini App / диалог с ботом для одного человека — храните telegram_id во внутренней записи с первого дня: это ключ связи.
Нужна готовая связка «сайт ↔ Telegram» под ключ — напишите в BotCreator.