
Авторизация пользователя в Telegram mini-app сейчас как никогда важна. Но что если вам необходимо сделать это в вашей сервисной/микросервисной архитектуре?
Вот и у меня встал такой вопрос при разработке.
Всем привет! Меня зовут Гурский Алексей, я FullStack разработчик и сейчас я расскажу вам как это работает на самом деле.
Для тех кто не хочет читать то может посмотреть сразу на backend реализацию: https://github.com/palachX/laravel-telegram-example
Код написан для примера пожалуйста сильно не ругайтесь, но можете написать свои комментарии по улучшению.
Задача
Авторизация пользователя посредством отправки смс-кода на указанный номер телефона. После чего при открытии mini-app пользователь должен увидеть главную страницу сайта и информацию личного кабинета.
Проблемы
Сервис авторизации уже написан для обычного frontend и мы не хотели бы его усложнять или костылить логикой Telegram.
Решение
Исходя из проблем и задачи было принято решение: мы должны реализовать Telegram-Service в нашей сервисной архитектуре. Он будет отвечать за обработку всей бизнес логики для Telegram, хранить данные пользователей, а также токены для доступа. И послужит в некотором роде backend-клиентом.
До старта
До начала выполнения работ вы должны были создать и привязать своего телеграм бота к вебхуку информации про это написано очень много, поэтому опускаю этот момент.
Схемы
Ссылка на схему drawio: https://drive.google.com/file/d/1PIBkS3qy7Gart1nleAPo9x3s79J-EVhx/view?usp=sharing



3 небольше схемы но работы предстоит много...
Предисловие
Саму реализацию авторизации, отправку смс-кодов, а также обработку состояний пользователя я не буду описывать, это много кода для статьи, но всё описано в проект-примере: https://github.com/palachX/laravel-telegram-example
Там я написал пример того как можно обрабатывать ответы пользователя.
А также по схемам вы сможете реализовать именно тут функционал который устроит именно вас. Я же для вас опишу самый последний шаг проверки данных InitData которые приходят к нам от frotnend.
Код Frontend
Я пишу на Vue/Nuxt в качестве примера буду показывать код на Nuxt (NoSSR).
Нам необходимо подключить telegram-mini-app в своё приложение идём в nuxt.config.ts/js и вставляем в блок app.head массив script
app: { head: { script: [ { src: 'https://telegram.org/js/telegram-web-app.js?59' }, ], }, },Если у вас ещё нет единой точки для входа в приложение тогда мы сейчас её напишем
Создаём composable useBootstrap внутри нам необходимо получить данные InitData отправить их на telegram-service получить токен, с помощью которого мы уже получим нужные нам данные пользователя:
export function useBootstrap(initData: string | null | undefined) { const loading = ref(true) // TODO Создайте нужные для вас composable в данном случае код написан для примера const { loadInitData, telegramResponse } = useTelegramInitData() const tokensStore = useTokensStore() /** * Хранилка useTelegramDataStore необходима для того чтобы ваше приложение понимало открыто ли Mini-App к примеру в моём случае мне необходимо было скрыть некоторый контент для Mini-App */ const telegramDataStore = useTelegramDataStore() /** * Если есть данные от телеграмм, то делаем запрос в телеграмм-сервис */ const initTelegram = async (): Promise<string | null> => { if (!initData) { telegramDataStore.hasTelegramInitData = false console.warn('useBootstrap: initData is null') return null } // TODO через свой $fetch или useApi делаете запрос на свой backend await loadInitData(initData) const response = telegramResponse.value telegramDataStore.hasTelegramInitData = !!response?.data.token return response?.data.token ?? null } const handleAuth = async (): Promise<void> => { // TODO реализация получения данных авторизированного пользователя, вам необходимо взять полученные токен и отправить запрос на нужный сервис } const initApp () => { try { const telegramToken = await initTelegram() if (telegramToken) { tokensStore.setAccessToken(telegramToken) } await handleAuth() } catch (error: unknown) { await handleError(error) } finally { loading.value = false } } }Идём в файл app.vue - это стандартный файл в nuxt пишем:
const initData: string | null | undefined = window?.Telegram?.WebApp?.initData const { initApp } = useBootstrap(initData) initApp()
Для тех кто пишет на TypeScript лайфхак чтобы Window.Telegram не выдавал ошибку
Добавьте папку types в корень проекта
Внутри создайте type.d.ts
Добавьте в него код:
declare global { interface Window { Telegram: { WebApp: { initData: string | null } | null } | null } } export {}
На frontend работа окончена теперь переходим к реализации backend
Реализация backend
Данные на вход
<?php declare(strict_types=1); namespace App\UseCases\V1\TelegramInitData; use Spatie\LaravelData\Attributes\MapName; use Spatie\LaravelData\Data; use Spatie\LaravelData\Mappers\SnakeCaseMapper; #[MapName(SnakeCaseMapper::class)] final class DataInput extends Data { public function __construct( public readonly string $initData ) { } }
Данные на выход
<?php declare(strict_types=1); namespace App\UseCases\V1\TelegramInitData; use Carbon\Carbon; use Spatie\LaravelData\Attributes\MapName; use Spatie\LaravelData\Mappers\SnakeCaseMapper; use Spatie\LaravelData\Resource; #[MapName(SnakeCaseMapper::class)] final class DataOutput extends Resource { public function __construct( public readonly string $token, public readonly ?Carbon $expiresAt, ) { } }
Класс для обработки данных
<?php declare(strict_types=1); namespace App\UseCases\V1\TelegramInitData; use App\Repositories\UserRepository; use App\Repositories\UserTokenRepository; use InvalidArgumentException; use JsonException; final readonly class Handler { public function __construct( private UserRepository $userRepository, private UserTokenRepository $userTokenRepository, private string $botToken, ) { } /** * @throws JsonException */ public function handle(DataInput $dataInput): DataOutput { $initData = $dataInput->initData; /** * @var array{ * query_id?: string, * user?: string, * auth_date?: string, * hash?: string, * signature?: string, * chat_instance?: string, * chat_type?: string, * start_param?: string, * can_send_after?: string * } $parsed */ $parsed = []; parse_str($initData, $parsed); if (! isset($parsed['hash'])) { throw new InvalidArgumentException('Invalid init data hash', 400); } /** @var string $hash */ $hash = $parsed['hash']; $this->assertValidHash( initData: $initData, receivedHash: $hash, token: $this->botToken, ); if (! isset($parsed['user'])) { throw new InvalidArgumentException('Invalid init data user', 400); } /** @var string $user */ $user = $parsed['user']; /** * @var array{ * id: int, * is_bot: bool, * first_name?: string, * last_name?: string, * username?: string * } $userDecode */ $userDecode = json_decode($user, true, 512, JSON_THROW_ON_ERROR); $user = $this->userRepository->getUserByChatId($userDecode['id']); $tokenData = $this->userTokenRepository->getByUserId($user->id); if ($tokenData === null) { throw new InvalidArgumentException('Invalid init data token', 401); } return new DataOutput( token: $tokenData->token, expiresAt: $tokenData->expires_at ); } private function assertValidHash(string $initData, string $receivedHash, string $token): void { /** * @var array<string, string> $data */ $data = []; parse_str($initData, $data); unset($data['hash']); ksort($data); $checkString = collect($data) ->map(function ($value, $key) { if (is_array($value)) { $value = json_encode($value, JSON_THROW_ON_ERROR); } return $key.'='.$value; }) ->implode("\n"); $secretKey = hash_hmac('sha256', $token, 'WebAppData', true); $calculatedHash = bin2hex(hash_hmac('sha256', $checkString, $secretKey, true)); if (! hash_equals($calculatedHash, $receivedHash)) { throw new InvalidArgumentException('Invalid init data hash', 400); } } }
Контроллер
<?php declare(strict_types=1); namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; use App\UseCases\V1\TelegramInitData\DataInput; use App\UseCases\V1\TelegramInitData\Handler; use Illuminate\Http\JsonResponse; use JsonException; class TelegramDataController extends Controller { /** * @throws JsonException */ public function handle(DataInput $data, Handler $handler): JsonResponse { return new JsonResponse(['data' => $handler->handle($data)]); } }
Тестирование успешного кейса
<?php declare(strict_types=1); namespace Telegram\UseCase; use App\Models\User; use App\Models\UserToken; use Tests\ApiTestCase; use Tests\TelegramSetup; final class TelegramInitHandlerTest extends ApiTestCase { use TelegramSetup; private const string URL_DATA = '/api/v1/telegram/init-data'; protected function setUp(): void { parent::setUp(); $this->telegramSetup(); } public function testSuccess(): void { $user = User::factory()->createOne(); UserToken::factory()->for($user)->createOne(); $authDate = now()->timestamp; $payload = [ 'query_id' => 'AAHdF6IQAAAAAN0XohDhrOrc', 'user' => [ 'id' => $user->telegram_id, 'is_bot' => false, 'username' => $user->username, ], 'auth_date' => $authDate, ]; ksort($payload); $dataForHash = collect($payload) ->map(function ($value, $key) { if (is_array($value)) { $value = json_encode($value, JSON_THROW_ON_ERROR); } return $key.'='.$value; }) ->implode("\n"); $secretKey = hash_hmac('sha256', $this->tgBotToken, 'WebAppData', true); $hash = bin2hex(hash_hmac('sha256', $dataForHash, $secretKey, true)); $initDataBase = http_build_query([ 'query_id' => $payload['query_id'], 'user' => json_encode($payload['user'], JSON_THROW_ON_ERROR), 'auth_date' => $payload['auth_date'], ]); $initData = $initDataBase.'&hash='.$hash; $this->postJson(self::URL_DATA, [ 'init_data' => $initData, ])->assertOk(); } }
Маршрутизатор api.php
Route::prefix('v1')->group(function () { Route::prefix('telegram')->group(function () { // Отлавливание ответов от telegram Route::post('webhook', [TelegramWebhookController::class, 'handle']); // Авторизация пользователя Route::post('init-data', [TelegramDataController::class, 'handle']); }); });
Если вы как и я хотите при регистрации DI не писать внутри конструктора получение конфига, то вы можете зарегистрировать класс обработчика в ServiceProvider вручную и сразу передать в конструктор нужные конфиги:
<?php declare(strict_types=1); namespace App\Providers; use App\Repositories\UserRepository; use App\Repositories\UserStateRepository; use App\Repositories\UserTokenRepository; use App\Telegram\Factories\CommandHandlerFactory\CommandsHandlersFactory; use App\Telegram\Factories\StateHandlerFactory\StatesHandlersFactory; use App\Telegram\Interfaces\BaseHandler; use App\Telegram\Services\TelegramService; use App\UseCases\V1\TelegramInitData\Handler as InitDataHandler; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider { /** * Register any application services. */ public function register(): void { } /** * Bootstrap any application services. */ public function boot(): void { $this->app->bind(InitDataHandler::class, function () { /** @var string $botToken */ $botToken = config('telegram-bot.bot_token'); return new InitDataHandler(app(UserRepository::class), app(UserTokenRepository::class), $botToken); }); } }
Итог
Пользователь открывает телеграм бота
Указывает номер телефона
Подтверждает код который к нему пришёл
Открывает mini-app = он авторизирован и видит личный кабинет
Сервис работает отлично. Бизнес получил требуемый функционал - все счастливы!
P.S. Моя первая статья на habr, я немного волнуюсь, вы можете высказывать критику любого вида, а также задавать свои вопросы на которые я постараюсь ответить.
Моя реализация, мои схемы и мой пример проекта это не истина, возможно для вас что-то подойдёт и я помог вам и направил вас, а возможно это не то что вы искали.
Спасибо, что прочитали до конца!
