ЧТО МЫ МОЖЕМ СОЗДАТЬ
Поток двухсторонней аутентификации:
1. Приложение React Mini считывает initData от @twa-dev/sdk и прикрепляет его к каждому запросу API в качестве пользовательского заголовка. 2. Сервер PHP проверяет этот заголовок с помощью HMAC-SHA-256 (токен бота в качестве ключа), отклоняет все поддельные или устаревшие, а затем выдает недолговечный JWT, привязанный к проверенному Телеграм ID. 3. Защищенные конечные точки проверяют JWT и отклоняют запросы, когда Телеграм ID утверждение не соответствует тому, что сервер ожидает для этого ресурса.
Мы not утверждают, что это предотвращает определенную атаку, которая контролирует как клиента, так и сеть. Что он делает, так это поднимает планку, чтобы «контролировать путь подписи Telegram или украсть ваш токен бота», что является реалистичной моделью угрозы для мини-приложения.
---
1. Модель угроз, кратко
Три вещи, от которых вам действительно нужно защититься в мини-приложении, которое вызывает ваш API:
- Воспроизведение украденного initData: остановить с помощью Дата авторизации истечения срока действия и небольшого одноразового сеанса. - Ковка Телеграм ID: остановитесь, пересчитав HMAC на сервере с помощью токена бота. Клиент никогда не выбирает идентификатор пользователя; это делает подпись. - Повторное использование JWT, выданного пользователю A, против ресурса пользователя B: остановить, связав JWT с Телеграм ID и проверять его на каждой защищенной конечной точке, а не только при входе в систему.
Вот и весь чек-лист. Все, что ниже, соответствует одному из них.
---
2. Сторона реакции: судно initData при каждом запросе
Установите SDK и HTTP-клиент. Мы используем приносить напрямую, чтобы учебник не зависел от транспорта, но тот же шаблон работает с перехватчиками axios.
npm i @twa-dev/sdk
Минимальная клиентская обертка:
// src/api/client.js
import WebApp from '@twa-dev/sdk';
const API_BASE = import.meta.env.VITE_API_BASE;
export async function api(path, options = {}) {
const initData = WebApp.initData; // raw query string, including hash
const initDataUnsafe = WebApp.initDataUnsafe; // parsed object, for client-side hints only
const headers = {
'Content-Type': 'application/json',
'X-Tg-Init-Data': initData,
...(options.headers || {}),
};
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`API ${res.status}: ${text}`);
}
return res.json();
}
Кнопка входа
// src/Login.jsx
import WebApp from '@twa-dev/sdk';
import { api } from './api/client';
export function Login() {
async function signIn() {
WebApp.readyToString();
const { token, user } = await api('/auth/telegram', { method: 'POST' });
localStorage.setItem('tg_jwt', token);
console.log('Signed in as', user.telegram_id);
}
return <button onClick={signIn}>Sign in with Telegram</button>;
}
Ряд моментов, которые следует иметь в виду:
- WebApp.initData это необработанная строка, которую вводит Telegram. Отправить что, а не ресериализованная версия. Сервер должен точно проанализировать его. - WebApp.initDataUnsafe для подсказок пользовательского интерфейса (отображение имени пользователя, скрытие кнопки для неадминистраторов). Никогда доверять ему на сервере. Сервер пересчитывает все из initData и токена бота.
---
3. Сторона PHP: проверить initData и отчеканить JWT
Мы разделили это на три маленькие части: верификатор, помощник JWT и маршрут.
Верификатор реализует официальную проверку Telegram:
1. Парсинг initData ХХХ значение ключа пары, разделенные &, сохраняя значения, декодированные URL-адресом. 2. Создайте data_check_string путем объединения каждой пары кроме хэшей, отсортировано по ключу, объединено с \n. 3. Вычислить HMAC-SHA-256(key=BOT_TOKEN, msg="WebAppData" + data_check_string). 4. Сравните с хэшей используя hash_equals. 5. Отклонить, если Дата авторизации старше ~5 минут (или независимо от продолжительности вашей сессии).
// src/Telegram/InitDataVerifier.php
namespace App\Telegram;
final class InitDataVerifier
{
public function __construct(private readonly string $botToken) {}
/** @return array<string,string> */
public function verify(string $initData, int $maxAgeSeconds = 300): array
{
$pairs = [];
foreach (explode('&', $initData) as $pair) {
if ($pair === '' || !str_contains($pair, '=')) continue;
[$k, $v] = explode('=', $pair, 2);
$pairs[urldecode($k)] = urldecode($v);
}
if (!isset($pairs['hash'], $pairs['auth_date'], $pairs['user'])) {
throw new \DomainException('initData missing required fields');
}
$receivedHash = $pairs['hash'];
unset($pairs['hash']);
ksort($pairs);
$dataCheckString = implode("\n", array_map(
fn($k, $v) => "$k=$v",
array_keys($pairs),
array_values($pairs)
));
$secret = hash_hmac('sha256', $this->botToken, 'WebAppData', true);
$computed = hash_hmac('sha256', $dataCheckString, $secret);
if (!hash_equals($computed, $receivedHash)) {
throw new \DomainException('initData signature mismatch');
}
if (time() - (int)$pairs['auth_date'] > $maxAgeSeconds) {
throw new \DomainException('initData expired');
}
return $pairs;
}
}
Помощник JWT с использованием firebase/php-jwt. Держите секрет подальше от репозитория; прочитайте его из окружающей среды.
// src/Auth/JwtIssuer.php
namespace App\Auth;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
final class JwtIssuer
{
public function __construct(
private readonly string $secret,
private readonly string $algo = 'HS256',
private readonly int $ttlSeconds = 3600,
) {}
public function issueForTelegramUser(int $telegramId, array $extra = []): string
{
$now = time();
return JWT::encode([
'sub' => (string)$telegramId,
'tg' => $telegramId,
'iat' => $now,
'nbf' => $now,
'exp' => $now + $this->ttlSeconds,
] + $extra, $this->secret, $this->algo);
}
/** @return object decoded claims */
public function decode(string $token): object
{
return JWT::decode($token, new Key($this->secret, $this->algo));
}
}
Маршрут. Обратите внимание на три вещи: мы читаем Bot Token и JWT секрет от окружения, мы JSON-декодируем пользователя и потяните id только после прохождения проверки HMAC, и мы чеканим JWT, чей суб является проверенным Телеграм ID.
// public/index.php (or your router)
use App\Telegram\InitDataVerifier;
use App\Auth\JwtIssuer;
$botToken = getenv('TELEGRAM_BOT_TOKEN') ?: '';
$jwtSecret = getenv('APP_JWT_SECRET') ?: '';
if ($botToken === '' || $jwtSecret === '') {
http_response_code(500); exit('server misconfigured');
}
$verifier = new InitDataVerifier($botToken);
$issuer = new JwtIssuer($jwtSecret);
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$method = $_SERVER['REQUEST_METHOD'];
if ($path === '/auth/telegram' && $method === 'POST') {
$initData = $_SERVER['HTTP_X_TG_INIT_DATA'] ?? '';
if ($initData === '') { http_response_code(400); exit('missing initData'); }
try {
$claims = $verifier->verify($initData);
} catch (\Throwable $e) {
http_response_code(401); exit('invalid initData');
}
$user = json_decode($claims['user'], true);
if (!is_array($user) || !isset($user['id'])) {
http_response_code(400); exit('user field malformed');
}
$token = $issuer->issueForTelegramUser((int)$user['id']);
header('Content-Type: application/json');
echo json_encode([
'token' => $token,
'user' => ['telegram_id' => (int)$user['id'], 'name' => $user['first_name'] ?? ''],
]);
return true;
}
Это весь процесс входа в систему. Клиент хранит его, сервер не доверяет ничему, что он не подписал.
---
4. Защищенные конечные точки: привяжите JWT к Телеграм ID
Одного JWT недостаточно. Если ваш маршрут обрабатывает ресурс, который принадлежит конкретному пользователю Telegram (заказ, подписка, черновик), обработчик должен сравнить формула изобретения->tg против владельца ресурса, прежде чем что-либо делать.
// src/Auth/require_telegram_user.php
function requireTelegramUser(JwtIssuer $issuer, int $expectedTelegramId): object
{
$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/^Bearer (.+)$/', $auth, $m)) {
http_response_code(401); exit('missing bearer');
}
try {
$claims = $issuer->decode($m[1]);
} catch (\Throwable $e) {
http_response_code(401); exit('invalid token');
}
if ((int)($claims->tg ?? 0) !== $expectedTelegramId) {
http_response_code(403); exit('telegram id mismatch');
}
return $claims;
}
И маршрут, который его использует:
if ($path === '/orders' && $method === 'GET') {
$telegramId = (int)($_GET['telegram_id'] ?? 0);
requireTelegramUser($issuer, $telegramId);
$orders = $db->fetchAll('orders', ['telegram_id' => $telegramId]);
header('Content-Type: application/json');
echo json_encode($orders);
return true;
}
JWT для пользователя A будет отклонен пользователем B Телеграм ID, даже если оба подписаны одним и тем же секретом. То есть «обязательность», на которую ссылается заголовок.
---
5. Примечания к производству (необязательно, но оно того стоит)
Они не требуются для работы потока, но они представляют собой разницу между демонстрацией и тем, что вы можете отправить.
- Хранение токенов на клиенте. localStorage подходит для мини-приложения, потому что источником является WebView Telegram, а не враждебная страница. Если вы обслуживаете один и тот же пакет из общедоступного домена, отдайте предпочтение файлу cookie HttpOnly, установленному /auth/telegram. - Окно воспроизведения. 5 минут - это значение по умолчанию выше. Сократите его до 60 секунд, если вы чеканите одноразовые монеты; удлините его, только если у вас есть строгий поток обновления, связанный с устройством. - Обновить Выдать вторую конечную точку /auth/refresh который принимает initData снова, повторно проверяет его и возвращает свежий JWT. Сделайте not принять запрос на обновление без свежего initData подписью. Ведение журнала Журнал событий Телеграм ID, Дата авторизациии усеченный хэш. Никогда не регистрируйте RAW initData строка запроса — содержит пользовательские данные. - Перекос часов. Сравнить с Дата авторизации используя время сервера, а не утверждения клиента. время Нормально. Webhook vs Mini App auth. Эта статья посвящена приложению Mini initData. Поток виджета входа в систему использует другую полезную нагрузку (id, first_name, Дата авторизации, хэшей, ...) и другой data_check_stringНе смешивайте их.
---
6. Что проверять сквозно
Короткий ручной тест перед отправкой:
1. Откройте мини-приложение в Telegram. /auth/telegram возвращает 200 и JWT. 2. Воспроизведите то же самое X-Tg-Init-Data header from круг Через 6 минут. Ожидайте 401 "initData expired". 3. Изменить один байт из initData и повторите его. Ожидайте 401 "invalid initData". 4. Отправить действительный JWT для пользователя A, но запрос /orders?telegram_id=B. Ожидайте 403.
Если все четыре пройдены, ваш путь аутентификации делает то, что должен.
---
Если вы запускаете Mini App в производство и хотите команду, которая уже отгрузила такой поток, BotCreator - это студия, которая строит ботов Telegram и Mini Apps из конца в конец и стоит посмотреть. Для более глубокого погружения в поверхность API бота (веб-перехватчики, лимиты скорости, полезные нагрузки обратного вызова) их ссылка на API бота Telegram является полезной закладкой.