Авторизация пользователя в 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

Отправка смс-кода пользователю
Отправка смс-кода пользователю
Схема авторизации пользвателя при подтверждении кода
Схема авторизации пользвателя при подтверждении кода
Отправка данных InitData через mini-app и возврат клиенту токена
Отправка данных InitData через mini-app и возврат клиенту токена

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 не выдавал ошибку

  1. Добавьте папку types в корень проекта

  2. Внутри создайте type.d.ts

  3. Добавьте в него код:

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);
        });
    }
}

Итог

  1. Пользователь открывает телеграм бота

  2. Указывает номер телефона

  3. Подтверждает код который к нему пришёл

  4. Открывает mini-app = он авторизирован и видит личный кабинет

Сервис работает отлично. Бизнес получил требуемый функционал - все счастливы!

P.S. Моя первая статья на habr, я немного волнуюсь, вы можете высказывать критику любого вида, а также задавать свои вопросы на которые я постараюсь ответить.

Моя реализация, мои схемы и мой пример проекта это не истина, возможно для вас что-то подойдёт и я помог вам и направил вас, а возможно это не то что вы искали.

Спасибо, что прочитали до конца!