
Авторизация пользователя в 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, я немного волнуюсь, вы можете высказывать критику любого вида, а также задавать свои вопросы на которые я постараюсь ответить.
Моя реализация, мои схемы и мой пример проекта это не истина, возможно для вас что-то подойдёт и я помог вам и направил вас, а возможно это не то что вы искали.
Спасибо, что прочитали до конца!
