Аутентификация Telegram WebApp в JavaScript

Сегодня я расскажу о хитром механизме аутентификации в Telegram WebApp Bot. Что такое бот Telegram WebApp? Это просто возможность запустить WebView с вашим сайтом внутри Telegram. Вы можете прочитать больше здесь".

Зачем нам здесь авторизация?

Ваш сайт должен открываться только в Telegram WebView. Итак, что, если бы кто-то сделал это в браузере? «Хакеры» могут использовать поддельные пользовательские данные, идентификаторы и так далее. Нам нужно защитить наш API от людей за пределами Telegram.

Как?

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

Шаг 1: передача данных через запросы

Telegram SDK устанавливает пользовательские данные в глобальном масштабе вашего приложения. Есть данные о пользователях, их смартфонах, цветовых темах и многом другом. Вы можете найти это здесь:

window.Telegram.WebApp

Итак, есть поле, в котором нам нужно проверять наши сообщения. Telegram предоставляет специальное поле initData. Мы должны использовать это поле для проверки всех запросов от Telegram WebApp к нашему серверу.

const { initData } = window.Telegram.WebApp

Это просто строка, похожая на строку запроса GET, которая содержит несколько полей:

auth_date=<auth_date>&query_id=<query_id>&user=<user>&hash=<hash>

Как их использовать, я расскажу ниже, а пока мы должны передавать эту строку с нашими запросами с помощью HTTP-заголовков или файлов cookie или даже просто дополнительного поля в теле вашего запроса (не рекомендуется). Я использую Axios для отправки запросов, поэтому мой код выглядит так:

axios.defaults.headers.common['Telegram-Data'] = window?.Telegram?.WebApp?.initData;

Вот и все. Необходимые данные будут в наших запросах.

Шаг 2: создание промежуточного ПО аутентификации

Я использую Nest.js в своем проекте, но способ создания промежуточного ПО почти одинаков в Express.js и Nest.js.

Во-первых, мы должны создать промежуточное ПО с помощью нескольких строк кода:

export function telegramAuthMiddleware(req, res, next) {
    // take initData from headers
    const iniData = req.headers[
      'telegram-data'
      ];
    // use our helpers (see bellow) to validate string
    // and get user from it
    const user = checkAuthorization(iniData);

    // add uses to  the request "context" for the future
    if (user) {
      req.user = user;
      next();
    // or if the validation is failed response 401
    } else {
      res.writeHead(401, { 'content-type': 'application/json' });
      res.write('unauthorized');
      res.end();
    }
}

Этот код просто берет initData из заголовков и использует вспомогательную функцию для их проверки.

Шаг 3: анализ initData

Я собираюсь описать процесс, а затем покажу вам код.

  1. Нам нужно разобрать строку initData
  2. Возьмите поле хэш из этой строки и сохраните его на будущее.
  3. Отсортируйте остальные поля в алфавитном порядке
  4. Соедините эти поля с помощью разрыва строки (\n). Почему? Да просто так! Телеграм хочет!

Итак, после этих шагов у нас есть две строки: hash, telegramCheckString,и metaData (содержит пользователя, auth_date и query_id)

Давайте посмотрим на код:

function parseAuthString(iniData) {
  // parse string to get params
  const searchParams = new URLSearchParams(iniData);
  
  // take the hash and remove it from params list
  const hash = searchParams.get('hash');
  searchParams.delete('hash');

  // sort params
  const restKeys = Array.from(searchParams.entries());
  restKeys.sort(([aKey, aValue], [bKey, bValue]) => aKey.localeCompare(bKey));

  // and join it with \n
  const dataCheckString = restKeys.map(([n, v]) => `${n}=${v}`).join('\n');

  
  return {
    dataCheckString,
    hash,
    // get metaData from params
    metaData: {
      user: JSON.parse(searchParams.get('user')),
      auth_date: searchParams.get('auth_date'),
      query_id: searchParams.get('query_id'),
    },
  };
}

Шаг 4: проверка хэша

Это последняя глава нашего путешествия. Нам нужно проанализировать initData, используя функцию из предыдущего шага и немного криптографии.

Мы должны идти по этому пути:

  1. Напишите функцию для кодирования сообщения, используя алгоритм sh256 и ключ.
  2. Разобрать строку, используя функцию из предыдущего шага
  3. Создайте секретный ключ, закодировав Telegram Bot Token с помощью ключа «WebAppData».
  4. Создайте хэш проверки, закодировав dataCheckString из предыдущей основы с помощью секретного ключа.
  5. Сравните контрольный хэш с хэшем из initData.

С небольшой помощью дополнительного криптографического пакета node.js для кодирования мы закончим этот блок кода:

const crypto = require('crypto')

const WEB_APP_DATA_CONST = "WebAppData"
const TELEGRAM_BOT_TOKEN = "so secret token!!"

// encoding message with key
// we need two types of representation here: Buffer and Hex 
function encodeHmac(message, key, repr=undefined) {
  return crypto.createHmac('sha256', key).update(message).digest(repr);
}

function checkAuthorization(iniData){
  // parsing the iniData sting
  const authTelegramData = parseAuthString(iniData);

  // creating the secret key and keep it as a Buffer (important!)
  const secretKey = encodeHmac(
    TELEGRAM_BOT_TOKEN,
    WEB_APP_DATA_CONST,
  );

  // creating the validation key (and transform it to HEX)
  const validationKey = encodeHmac(
    authTelegramData.dataCheckString,
    secretKey,
    'hex',
  );

  // the final step - comparing and returning
  if (validationKey === authTelegramData.hash) {
    return authTelegramData.metaData.user;
  }

  return null;
}

Итак, все!
Вот полный код аутентификации:

const crypto = require('crypto')
const WEB_APP_DATA_CONST = "WebAppData"
const TELEGRAM_BOT_TOKEN = "so secret token!!"

export function telegramAuthMiddleware(req, res, next) {
  // take initData from headers
  const iniData = req.headers[
    'telegram-data'
    ];
  // use our helpers (see bellow) to validate string
  // and get user from it
  const user = checkAuthorization(iniData);

  // add uses to  the request "context" for the future
  if (user) {
    req.user = user;
    next();
    // or if the validation is failed response 401
  } else {
    res.writeHead(401, { 'content-type': 'application/json' });
    res.write('unauthorized');
    res.end();
  }
}

function parseAuthString(iniData) {
  // parse string to get params
  const searchParams = new URLSearchParams(iniData);

  // take the hash and remove it from params list
  const hash = searchParams.get('hash');
  searchParams.delete('hash');

  // sort params
  const restKeys = Array.from(searchParams.entries());
  restKeys.sort(([aKey, aValue], [bKey, bValue]) => aKey.localeCompare(bKey));

  // and join it with \n
  const dataCheckString = restKeys.map(([n, v]) => `${n}=${v}`).join('\n');


  return {
    dataCheckString,
    hash,
    // get metaData from params
    metaData: {
      user: JSON.parse(searchParams.get('user')),
      auth_date: searchParams.get('auth_date'),
      query_id: searchParams.get('query_id'),
    },
  };
}


// encoding message with key
// we need two types of representation here: Buffer and Hex
function encodeHmac(message, key, repr=undefined) {
  return crypto.createHmac('sha256', key).update(message).digest(repr);
}

function checkAuthorization(iniData){
  // parsing the iniData sting
  const authTelegramData = parseAuthString(iniData);

  // creating the secret key and keep it as a Buffer (important!)
  const secretKey = encodeHmac(
    TELEGRAM_BOT_TOKEN,
    WEB_APP_DATA_CONST,
  );

  // creating the validation key (and transform it to HEX)
  const validationKey = encodeHmac(
    authTelegramData.dataCheckString,
    secretKey,
    'hex',
  );

  // the final step - comparing and returning
  if (validationKey === authTelegramData.hash) {
    return authTelegramData.metaData.user;
  }

  return null;
}

Следующие шаги

  1. Вы можете добавить кеширование, потому что криптография — довольно сложная вещь для процессора, поэтому вы можете использовать Redis или даже кэш в памяти, чтобы сохранить строку initData, например ключ, и userData JSON в качестве значения, например.
  2. Вы можете сгенерировать свой собственный токен JWT один раз после проверки initData и установить его в файлы cookie. Я думаю, что это более мощный способ создания аутентификации.

Если у вас есть какие-либо вопросы, не стесняйтесь задавать их в комментариях!