Как стать автором
Обновить

Сначала войдите через Телеграм

Время на прочтение5 мин
Количество просмотров40K

Как-то мне понадобилось ограничить доступ к статическому сайту. Я написал сервер, который просит пользователей войти через Телеграм и пропускает только людей из белого списка. Ничего сложного, но вдруг кому-то понадобится.

Задача была такая: на замену статическому сайту — набору HTML-страниц, раздающемуся с сервера — написать программу, которая обрабатывает авторизацию и отправляет HTML-ки, если доступ разрешён.

Я использовал фреймворк FastAPI. Код и инструкции по запуску есть на Гитхабе, а структура приложения выглядит так:

site/
    здесь лежат статические файлы сайта

src/
    templates/
        login.html
    __init__.py
    auth.py
    staticfiles.py
    vars.py
    whitelist.py

Сейчас подробно объясню.

Мой туториал подойдёт вам, даже если ваш сайт не статический, но тоже написан на FastAPI и не использует другую авторизацию.

Шаг 1. Вход через Телеграм

Посетителям сайта, которые ещё не аутентифицировались, я показываю простую страницу с просьбой войти и кнопкой «Войти через Телеграм». Страница выглядит, как на картинке выше, и лежит в src/templates/login.html.

Сгенерировать кнопку «Войти через Телеграм» можно на странице Telegram Widget. Телеграм любит изобретать велосипеды — ни OAuth, ни другие стандарты авторизации он не использует.

Вам понадобится зарегистрировать Телеграм-бота. Во время авторизации пользователи будут видеть: «Вы входите на сайт example.com через бота ExampleBot». Да, требование иметь бота довольно бессмысленное, зато можно показывать входящим пользователям чекбокс: «Разрешаю боту писать мне в личку».

Как настроить бота и кнопку

Зарегистрируйте бота. В настройках бота в BotFather укажите домен сайта: для локального тестирования подойдёт 127.0.0.1 или 0.0.0.0.

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

Если вы генерируете кнопку и следуете моему туториалу дальше, в настройках redirect to url следует указать /auth/telegram-callback?next={{ next_path }}. Шаблонизатор будет заменять {{ next_path }} в итоговой HTML-странице на урл, на который нужно перенаправить пользователя.

После аутентификации Телеграм будет перенаправлять пользователя на callback url; сервер должен проверять правильность данных и ставить куки. В куки я храню id пользователя в Телеграме, закодированное в JWT. Сервер не хранит информацию о вошедших пользователях: вся логика авторизации — проверить, что id в белом списке.

Определяю нужные константы:

# src/vars.py

import hashlib
import os

JWT_SECRET_KEY = os.environ['JWT_SECRET_KEY']
BOT_TOKEN_HASH = hashlib.sha256(os.environ['BOT_TOKEN'].encode())
COOKIE_NAME = 'auth-token'

И создаю модуль приложения FastAPI:

# src/auth.py

import hmac
from typing import Annotated

from fastapi import APIRouter, Query
from fastapi.requests import Request
from fastapi.responses import PlainTextResponse, RedirectResponse
from joserfc import jwt

from src.vars import BOT_TOKEN_HASH, JWT_SECRET_KEY, COOKIE_NAME

auth_router = APIRouter()


@auth_router.get('/telegram-callback')
async def telegram_callback(
        request: Request,
        user_id: Annotated[int, Query(alias='id')],
        query_hash: Annotated[str, Query(alias='hash')],
        next_url: Annotated[str, Query(alias='next')] = '/',
):
    params = request.query_params.items()
    data_check_string = '\n'.join(sorted(f'{x}={y}' for x, y in params if x not in ('hash', 'next')))
    computed_hash = hmac.new(BOT_TOKEN_HASH.digest(), data_check_string.encode(), 'sha256').hexdigest()
    is_correct = hmac.compare_digest(computed_hash, query_hash)
    if not is_correct:
        return PlainTextResponse('Authorization failed. Please try again', status_code=401)

    token = jwt.encode({'alg': 'HS256'}, {'k': user_id}, JWT_SECRET_KEY)
    response = RedirectResponse(next_url)
    response.set_cookie(key=COOKIE_NAME, value=token)
    return response


@auth_router.get('/logout')
async def logout():
    response = RedirectResponse('/')
    response.delete_cookie(key=COOKIE_NAME)
    return response

Шаг 2. Белый список

Список людей, которым я разрешаю доступ к своему сайту, не меняется — поэтому проще всего было захардкодить их id. Все нужные люди состоят в одном чате, так что спарсить их было несложно.

# src/whitelist.py

WHITELIST_IDS = [
    254210206,
    36265675,
    1937983145,
]
Скрипт, собирающий пользователей чата

Код под TGPy:

ids = []
async for user in client.iter_participants("your chat title"):
  ids.append(user.id)
ids

TGPy — это разрабатываемый нами опенсорсный инструмент для написания одноразовых скриптов в Телеграме.

Возможно, вам не подойдёт константный список. Вместо него можете написать функцию, которая будет проверять id с помощью бота: например, проверять, состоит ли пользователь в чате.

Шаг 3. Авторизация

Перейдём к основной части проекта. В __init__.py я создаю само приложение FastAPI. Урлы, начинающиеся на auth/, обрабатываются функциями, которые я показал выше.

Функция middleware проверяет, отдавать ли пользователю страницу. Она выполняется каждый раз, когда делается запрос к серверу:

  • Если урл начинается на /auth/, используем обработчики авторизации.

  • Если нет куки или в куки неправильные данные — отдаём login.html.

  • Если всё ок, отдаём страничку, которую должны отдать.

# app/__init__.py

import urllib.parse

from fastapi import FastAPI
from fastapi.requests import Request
from fastapi.templating import Jinja2Templates
from joserfc import jwt
from joserfc.errors import JoseError

from src.auth import auth_router
from src.vars import COOKIE_NAME, JWT_SECRET_KEY
from src.whitelist import WHITELIST_IDS

app = FastAPI()
templates = Jinja2Templates('src/templates')

app.mount('/auth', auth_router)


@app.middleware('http')
async def middleware(request: Request, call_next):
    response = await call_next(request)
    if request.url.path.startswith('/auth/'):
        return response

    url_safe_path = urllib.parse.quote(request.url.path, safe='')
    template_context = {'request': request, 'next_path': url_safe_path}
    login_wall = templates.TemplateResponse('login.html', template_context)

    token = request.cookies.get(COOKIE_NAME)
    if not token:
        return login_wall

    try:
        token_parts = jwt.decode(token, JWT_SECRET_KEY)
    except JoseError:
        return login_wall

    user_id = token_parts.claims['k']
    if user_id not in WHITELIST_IDS:
        return login_wall

    return response

Шаг 4. Статические файлы

Последний шаг — отдавать файлы сайта.

Простой способ

Нужно добавить пару строчек:

# app/__init__.py

from fastapi.staticfiles import StaticFiles

...

app = FastAPI()
templates = Jinja2Templates('src/templates')
static_files = StaticFiles(directory='site/')

app.mount('/auth', auth_router)
app.mount('/', static_files, name='static')

...

Способ с удалением .html из урлов

Хостинг моего первоначального статического сайта удалял из адресов страниц окончания .html. Оказалось, что FastAPI из коробки так делать не умеет, так что пришлось разбираться.

FastAPI основан на фреймворке Starlette, и класс StaticFiles именно оттуда. У него есть опция html=True: она превращает файловые пути вида /path/to/dir/index.html в урлы вида /path/to/dir/. У остальных файлов расширение в урлах остаётся (/path/to/dir/file.html), так что пришлось дополнить этот класс.

# src/staticfiles.py

import os
import typing

from fastapi.staticfiles import StaticFiles


class HTMLStaticFiles(StaticFiles):
    def __init__(self, **kwargs):
        super().__init__(**kwargs, html=True)

    def lookup_path(
            self, path: str
    ) -> typing.Tuple[str, typing.Optional[os.stat_result]]:
        if not path.endswith('.html'):
            full_path, stat_result = super().lookup_path(path + '.html')
            if stat_result:
                return full_path, stat_result

        return super().lookup_path(path)
# app/__init__.py

from src.staticfiles import HTMLStaticFiles

...

app = FastAPI()
templates = Jinja2Templates('src/templates')
static_files = HTMLStaticFiles(directory='static/')

app.mount('/auth', auth_router)
app.mount('/', static_files, name='static')

...

Как запустить

Проект можно запустить или развернуть на сервере как обычное FastAPI-приложение.

Чтобы быстро потыкать приложение и заставить Телеграм-авторизацию работать локально, можно запустить на 80 порте:

sudo BOT_TOKEN=your_token_here JWT_SECRET_KEY=your_random_string uvicorn src:app --reload --port 80

Если вы знаете, как улучшить или упростить код, — буду рад увидеть ваши комментарии.

Исходники на Гитхабе.

Теги:
Хабы:
Всего голосов 56: ↑53 и ↓3+61
Комментарии51

Публикации