Реализация аутентификации в telegram-боте через OAuth2 на примере Box API
Возникла идея простого бота, который будет отправлять переданные ему файлы в облако. Однако для аутентификации сейчас практически везде используется OAuth2. Если в web-приложениях пользоваться OAuth2 все уже научились, то с telegram у меня возникли вопросы.
В этой заметке будет минимальный пример telegram-бота, который работает с box.com через API с аутентификацией через OAuth2.
disclaimer #1: В статье не будет подробного описания работы OAuth2 — только необходимый минимум информации для понимания процесса.
disclaimer #2: Для примера использую облачное хранилище box.com, так как именно в нём лежит моя библиотека электронных книг. При реализации аналогичной функциональности для других облаков проблем возникнуть не должно.
Какой сценарий с точки зрения пользователя получим в конце текущей статьи:
- Пользователь добавляет бота в telegram
- По команде /loginпользователь получает ссылку на аутентификацию
- Пользователь переходит по ссылке в браузере на box.com
- Пользователь аутентифицируется на box.com
- Открывается страница бота https://t.me/{BOT_NAME}?start={code}с предложением отправить ему сообщение по кнопке Send message
- Пользователь нажимает на кнопку Send message
- В telegram открывается диалог с ботом с активной кнопкой Start
- Пользователь нажимает на кнопку Start
- Бот отправляет пользователю его login в box.com
Добавить остальную функциональность — дело техники.
Необходимый минимум об OAuth2
Для реализация аутентификации через OAuth2 необходимо выполнить несколько пунктов:
- Получить client_idиclient_secretв консоли разработчика box.com. Подробно процесс описан в приложении в конце статьи
- Сформировать ссылку для аутентификации пользователя https://account.box.com/api/oauth2/authorize?client_id={client_id}&redirect_uri={redirect_url}&response_type=code.client_idиredirect_urlнеобходимо скопировать из настроек созданного ранее приложения в консоли box.com
- На адресе redirect_url(по-умолчанию в этой статье — этоlocalhost:8000) поднять веб-сервер, который сможет получить код для аутентификации в box.com
- С полученным в п.3 кодом и параметрами client_idиclient_secretиз п.1 обратиться к endpoint box.com API для полученияaccess_tokenиrefresh_token.access_tokenв дальнейшем необходимо добавлять во все запросы к API.
Реализация
Шаг 1. Подготовка
Для работы понадобятся три внешних библиотеки:
- python-telegram-bot— для работы с Telegram Bot API
- requests— для выполнения запросов к API
- boxsdk— для работы с API box.com
Библиотеки и их версии прописаны в requirements.txt. Установить всё через pip install -r requirements.txt — ничего необычного. Но лучше для этого создать отдельное виртуальное окружение.
Шаг 2. Продолжаем подготовку
Теперь надо получить токен для бота. Я уже писал об этом ранее.
Кратко повторю. Получить токен для нового бота возможно через служебный аккаунт @BotFatherBot. Для этого отправить ему команду /newbot и пройти небольшой визард. В процессе надо сохранить токен, который потребуется при создании бота, и имя бота.
Шаг 3. Веб-сервис для получения OAuth2-кода
Нужен простой веб-сервис. На URI этого веб-сервиса будет происходить перенаправление со страницы аутентификации box.com. Адрес перенаправления будет содержать параметр code в строке запроса. Этот код используется для получения access_token и refresh_token дальше.
Веб-сервис будет без использования внешних зависимостей. Реализован на встроенном simple http server. Код позаимоствован из gist, но немного модифицирован:
#!/usr/bin/env python3
"""
Very simple HTTP server in python for logging requests
Usage::
    ./server.py [<port>]
"""
from http.server import BaseHTTPRequestHandler, HTTPServer
import logging
# Имя созданного ранее в @BotFather бота
bot_name = 'BOT_NAME'  
class S(BaseHTTPRequestHandler):
    def do_GET(self):
        logging.info("GET request,\nPath: %s\nHeaders:\n%s\n", str(self.path), str(self.headers))
        # Необходимо перенаправить запрос на адрес твоего бота.
        # Полученный из box.com API код передаётся созданному боту через механизм deep linking (https://core.telegram.org/bots#deep-linking).
        self.send_response(301)
        self.send_header('Location',f'https://t.me/{bot_name}?start={self.path.split("=")[-1]}')
        self.end_headers()
def run(server_class=HTTPServer, handler_class=S, port=8000):
    logging.basicConfig(level=logging.INFO)
    server_address = ('', port)
    httpd = server_class(server_address, handler_class)
    logging.info('Starting httpd...\n')
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        pass
    httpd.server_close()
    logging.info('Stopping httpd...\n')
if __name__ == '__main__':
    from sys import argv
    if len(argv) == 2:
        run(port=int(argv[1]))
    else:
        run()
Использовать код выше в настоящем боевом production я бы, конечно, не стал, но для демонстрации идеи подойдёт.
Шаг 4. Минимально работающий бот
Простейший бот с использованием библиотеки python-telegram-bot выглядит так:
from telegram.ext import Updater
from telegram.update import Update
from telegram.ext.callbackcontext import CallbackContext
from telegram.ext import CommandHandler
token = 'TG_BOT_TOKEN'
updater = Updater(token=token, use_context=True)
dispatcher = updater.dispatcher
def start(update: Update, context: CallbackContext):
    context.bot.send_message(chat_id=update.effective_chat.id, text="I'm a bot, please talk to me!")
start_handler = CommandHandler('start', start)
dispatcher.add_handler(start_handler)
updater.start_polling()
где TG_BOT_TOKEN — токен, который выдал @BotFather.
Этот бот не делает ничего полезного — только отправляет сообщение "I'm a bot, please talk to me!" по команде /start.
Теперь надо добавить самое важное — аутентификацию через OAuth2.
Шаг 4.1. OAuth2-аутентификация
Для аутентификации необходимо перейти по сформированной особым образом ссылке: https://account.box.com/api/oauth2/authorize?client_id={client_id}&redirect_uri={redirect_url}&response_type=code, где client_id и redirect_url параметры из настроек созданного ранее приложения в консоли разработчика box.com.
На этой странице будет стандартная форма логина в box.com  .
.
После ввода логина и пароля произойдёт перенаправление на redirect_url. На этом адресе сервис, получив GET-запрос, вычленит из строки запроса code. После чего перенаправит запрос на страницу старта работы с telegram-ботом. В адрес будет добавлен полученный код. Далее должен открыться диалог с ботом с активной кнопкой Start. По клику на кнопку в обработчике команды /start можно будет получить переданный код.
Простейший код как proof of concept:
from telegram.ext import Updater
from telegram.update import Update
from telegram.ext.callbackcontext import CallbackContext
from telegram.ext import CommandHandler
import requests
import json
import boxsdk
client_secret: str = 'CLIENT_SECRET'
client_id: str = 'CLIENT_ID'
redirect_url: str = 'REDIRECT_URL'
token = 'TG_BOT_TOKEN'
updater = Updater(token=token, use_context=True)
dispatcher = updater.dispatcher
# Обработчик команды /start
def start(update: Update, context: CallbackContext):
    message_text: str = update.message.text
    # В deep linking параметры из url передаются в сообщении в виде строки "/start code", 
    # где code - строка из url https://t.me/{BOT_NAME}?start=code.
    message_parts: list[str] = message_text.split(' ')
    if len(message_parts) == 2:
        # Во втором элементе будет содержаться переданный код, 
        # который необходим для получения access_token и refresh_token
        code: str = message_parts[1]
        # Формируем запрос к API box.com для получения access_token и refresh_token
        access_token_url = 'https://api.box.com/oauth2/token'
        headers = {'Content-Type': 'application/x-www-form-urlencoded'}
        params = {
            'client_id': client_id,
            'client_secret': client_secret,
            'code': code,
            'grant_type': 'authorization_code'
        }
        req = requests.post(access_token_url, data=params, headers=headers)
        data = json.loads(req.text)
        # Полученный JSON содержит необходимые данные.
        # Используя их можно инициализировать клиент boxsdk
        # и начать обращаться к его API.
        oauth: boxsdk.OAuth2 = boxsdk.OAuth2(
            client_id,
            client_secret,
            access_token=data['access_token'],
            refresh_token=data['refresh_token'])
        box_client = boxsdk.Client(oauth)
        # Например, можно получить информацию об аутентифицированном пользователе.
        user = box_client.user().get()
        context.bot.send_message(chat_id=update.effective_chat.id, text=user.login)
    else:
        show_welcome_message(update, context)
# Обработчик команды /login
def login(update: Update, context: CallbackContext):
    context.bot.send_message(chat_id=update.effective_chat.id, text=f'<a href="https://account.box.com/api/oauth2/authorize?client_id={client_id}&redirect_uri={redirect_url}&response_type=code">Connect to Box.com</a>', parse_mode='HTML')
def show_welcome_message(update: Update, context: CallbackContext):
    context.bot.send_message(chat_id=update.effective_chat.id, text="I'm a bot, please talk to me!")
start_handler = CommandHandler('start', start)
login_handler = CommandHandler('login', login)
dispatcher.add_handler(start_handler)
dispatcher.add_handler(login_handler)
updater.start_polling()
Что дальше
- Привести код бота в порядок: выделить классы, методы,
- Сделать конфигурирование бота более удобным,
- Реализовать загрузку файлов в облако.
О чём-то из этого постараюсь написать позже.
Исходники для этой заметки можно найти на github.
Приложение
Получить client_id и client_secret необходимо в консоли разработчика box.com:
- Cоздать новое приложение с типом Custom app  
- В настройках приложения выбрать User Authentication (OAuth2) и указывать App Name  
- В разделе Configuration созданного приложения:
- в секции OAuth 2.0 Credentials забрать client_idиclient_secret,
- в секции OAuth 2.0 Redirect URI указать localhost:8000(или другой адрес, если есть понимание, что это и зачем)
- поставить галочку в чекбоксе Write all files and folders stored in Box  (в этой статье про это говорить не буду) (в этой статье про это говорить не буду)
 
- в секции OAuth 2.0 Credentials забрать 
