Спойлер: Причина написания статьи - сломалась авторизация в Telegram боте Mini App после обновления Bot API 8.0, решение смотри внизу.

Всех приветствую! Относительно не так давно решил написать телеграмм бота под один небольшой проект, и под эту задачу решил изучить функционал Mini App и встроить работу с ним в своего бота (офф дока - https://core.telegram.org/bots/webapps).

Вообще, хочу пролить свет на понятие телеграмм бота не для разработчиков. Это отображение вашей программы в уже готовом приложении. Делаю на этом акцент, так как часто сталкиваюсь с непониманием трудозатрат только потому, что конечный результат называется "ботиком". Неискушенный заказчик считает, что задача от этого сильно упрощается, ведь это всего лишь ботик. Например, Вася с Петей на разных коленках сделают его за 5 минут в перерыве между парами за булочку с повидлом, а ты хочешь еще и денег? Для тех, кто больше занимается бэкендом, телеграмм бот без Mini App - отличное решение. Можно написать довольно сложный проект, интерфейс которого не надо придумывать, а только выбрать из набора допустимых возможностей - то что лучше подходит под вашу задачу. Но, к сожалению, такой вариант не всегда подходит, и в какой-то момент надо кастомизировать ваше приложение - вот тут и выходит на сцену Mini App.

Для того, что бы пощупать эту технологию, обычно добавляют ссылку на ресурс в боте. И вуаля, сайт чудесным образом открывается по кнопке из ботика прямо в телеге.
Но дальше начинаются сложности (возможно не у всех, но у меня они точно были). Возникает потребность авторизоваться на этом ресурсе (сайте) через функционал телеграмм бота.

Дано:

  • Есть пользователь бота. Пользователем бота назовем юзера, который начал общение с ботом, посредством команды /start.

  • Есть некий сервис (бэкенд + фронтенд) помимо бота, который мы и хотим интегрировать в ботика.

Задача:

  • Авторизовать пользователя в вашей система из вашего Телеграм бота по нажатию на кнопку открытия Mini APP. Например, зачем это надо: в вашей системе есть две роли - администратор и пользователь. Если авторизуется администратор, то он увидит некий "секретный" график, доступный только этой роли. Если авторизуется пользователь, то он график уже не увидит, а увидит, например, информацию о погоде.

То есть, мы хотим сделать "бесшовную" авторизацию в нашем сервисе из бота по одному нажатию на кнопку. Это будет эквивалентно тому, если бы вы зашли на ваш сайт и авторизовались, введя логин и пароль. Но для этого логин/пароль надо вводить и помнить, а наша задача этого избежать.

Если обратиться к офф доке (https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app), то из нее мы должны почерпнуть базу:
при нажатии на кнопень, мы передаем данные как query params на нашу страничку и получаем их в специальном объекте на фронте - Telegram.WebApp.initData.

Для того, что бы все работало, к нашему фронту надо подключить файлик https://telegram.org/js/telegram-web-app.js, который как раз и будет заполнять значениями данный объект.

Пример:

Ваш сайт работает на домене https://my-best-examples.ru.

Как только вы нажмете на кнопку для открытия вашего Mini App-a (этого же сайта), будет отправлен такой запрос https://my-best-examples.ru/#tgWebAppData=query_id%3DAA...

Для успешной авторизации пользователя нужно:

  • Получить данные этой строки (в нашем случае можно их взять из Telegram.WebApp.initData) - это будет некий объект с набором таких данных:

{
  "query_id": "AH8SAAAAAJaofxLgBX3V",
  "user": {"id":123123,"first_name":"Example" ...},
  "auth_date": 1826400455,
  "hash": "44444ee44b55e126d14b106555555555555c77777777c8f12fb04a9d5",
  "tgWebAppVersion": 7.8,
  "tgWebAppThemeParams": {"accent_text_color":"#168acd","bg_color":"#ffffff" ...}
  ...
}

Ваша задача:

  • отсортировать по алфавиту параметры auth_date, query_id, user и записать их в одну строку вот так: data_check_string = "auth_date=<auth_date>\nquery_id=<query_id>\nuser=<user>"

  • Далее следуем алгоритму из документации:

    data_check_string = <наша подготовленная строка>
    secret_key = HMAC_SHA256(<токен вашего бота>, "WebAppData")
    if (hex(HMAC_SHA256(data_check_string, secret_key)) == hash) {
    // data is from Telegram
    }

    "WebAppData" - это некое контрольное слово в виде строки (я сначала не понял, что тут имелось ввиду, поэтому детализирую):
    hash - это строковое значение переменной из данных, которые прилетели в форму (заметьте, что в data_check_string хэша не должно быть). То есть это хэш данных, которые были отправлены к вам на вашу страничку, но не всех, а только "белого списка" параметров. Если все сделать правильно, то до версии Bot Api 7.8 так и работало, но есть одно НО!

  • Великое и ужасное НО:

    С обновлением версии Bot Api 8.0 - 17 ноября 2024 такая авторизация сломалось. Мои исследования проблемы подсказок в этой офф доке на нашли, но я заметил в описании структуры принимаемых данных новое поле и новый тип авторизации для сторонних систем. А именно поле "siganture" в объекте WebAppInitData:

    Добавили новое поле - signature
    Добавили новое поле - signature

Поле добавили, функционал расширили, но о нас не подумали, и в итоге авторизация перестала работать, потому что хэш уже не тот что раньше. Теперь надо это поле добавлять в строку data_check_string вот так:

"auth_date=<auth_date>\nquery_id=<query_id>\nsignature=<signature>\nuser=<user>"

Ждем обновления в офф доке.

Комментарии (14)


  1. noRoman
    21.11.2024 12:49

    Отвечу кодом

    …
    private function signValid(array $sign): bool
      $list = [];
      ksort($sign);
      foreach ($sign as $name => $val) {
          if ($name == 'hash') {
              continue;
          }
      
          $list[] = $name . '=' . (is_array($val) ? json_encode($val, JSON_UNESCAPED_UNICODE) : $val);
      }
      
      $secret_key = hash_hmac('sha256', $_ENV['TG_BOT_TOKEN'], "WebAppData", true);
      $hash = bin2hex(hash_hmac('sha256', implode("\n", $list), $secret_key, true));
      return strcmp($hash, $sign['hash']) === 0;
    }
    …


    1. JavaKrasava Автор
      21.11.2024 12:49

      Спасибо, в вашем решении я не заметил фильтрацию ключей, возможно вы делаете это до.
      Расчетный хэш на данный момент получается из параметров auth_date, query_id, signature, user (то что у вас должно остаться в массиве list). А буквально неделю назад поля signature не участвовало в этом расчете.
      Благодарю за пример


      1. nighthtr
        21.11.2024 12:49

        нужно не брать конкретные поля, а исключить hash и signature


      1. noRoman
        21.11.2024 12:49

        Я беру из window.Telegram.WebApp.initDataUnsafe и игнорю hash

        Не нужна фильтрация больше никакая. В итоге мне все равно что они там добавят.


        1. nighthtr
          21.11.2024 12:49

          Получается тебе можно подсунуть любые данные (устаревшие, чужие, несуществующие) и ты им доверяешь со всеми вытекающими


          1. noRoman
            21.11.2024 12:49

            А для чего выше код проверки подписи!?!?

            private function signValid(array $sign)…


            1. nighthtr
              21.11.2024 12:49

              Извиняюсь, без контекста комментарий прочитал.

              Это получается в unsafe они не добавляли подпись и все работает по старому? Я на initData ориентировался и пришлось переделывать на новый метод проверки.


              1. noRoman
                21.11.2024 12:49

                initData надо проверять и initDataUnsafe надо проверять.

                Вот разница из доки:

                initData - A string with raw data transferred to the Mini App, convenient for validating data.WARNING: Validate data from this field before using it on the bot's server.


                initDataUnsafe - An object with input data transferred to the Mini App.WARNING: Data from this field should not be trusted. You should only use data from initData on the bot's server and only after it has been validated.


                PS всегда данные от клиента надо проверять. Правило 1: на стороне клиента сидит хакер.


        1. JavaKrasava Автор
          21.11.2024 12:49

          Спасибо, сделаю так же


  1. BarakAdama
    21.11.2024 12:49

    Пожалуйста, не используйте хаб Habr для статей не о Хабре.


    1. JavaKrasava Автор
      21.11.2024 12:49

      Здравствуйте, накосячил, каюсь)


  1. nighthtr
    21.11.2024 12:49

    /**
     * @param array<string, mixed> $data
     * @return AccessToken
     */
    public function auth(array $data): AccessToken
    {
        if (Arr::has($data, 'signature')) {
            $check_params = Arr::except($data, ['hash', 'signature']);
    
            ksort($check_params);
    
            foreach ($check_params as $param => $value) {
                $check_params[$param] = "$param=$value";
            }
    
            $data_check_string = config('telegram.bot_id') . ":WebAppData\n";
            $data_check_string .= implode("\n", $check_params);
    
            try {
                $public_key = sodium_hex2bin(config('telegram.public_key'));
                $signature = base64_decode(strtr(Arr::get($data, 'signature'), '-_', '+/'));
    
                if (sodium_crypto_sign_verify_detached($signature, $data_check_string, $public_key)) {
                    $auth_date = Arr::get($data, 'auth_date');
    
                    if (time() - $auth_date < 86400) {
                        $user = $this->firstOrCreate(
                            json_decode(Arr::get($data, 'user'), true),
                        );
                    }
                }
            } catch (SodiumException $e) {
                //
            }
        }
    
        return isset($user)
            ? $this->createAccessToken($user, md5(json_encode($data)))
            : new AccessToken();
    }


    что вы собрались ждать в обновлении доки, если там все уже есть?


    1. JavaKrasava Автор
      21.11.2024 12:49

      Здравствуйте, в доке есть две примера авторизации:
      1. Validating data received via the Mini App - про который я и написал.
      2. Validating data for Third-Party Use - собственно ваш пример.

      Я написал про 1-ый вариант, и для этого варианта нет правильно работающего примера на данный момент в документации. Поправьте меня если ошибаюсь.


      1. nighthtr
        21.11.2024 12:49

        а два варианта то я и упустил))


        сейчас решил проверить, вернул свой код на старую реализацию и все работает как и раньше. не понятно только почему у меня тоже поломалась авторизация. может они что то на своей стороне сломали? т.к. обновление вроде было 17.11.2024, а поломалось все 20.11.2024. на текущий момент 23.11.2024 опять все работает)

        судя по тому, что вы пишете, вы в data_check_string собираете только auth_datequery_iduser, а надо собирать all received fields, кроме hash.