Сначала войдите через Телеграм
- четверг, 21 марта 2024 г. в 00:00:20
Как-то мне понадобилось ограничить доступ к статическому сайту. Я написал сервер, который просит пользователей войти через Телеграм и пропускает только людей из белого списка. Ничего сложного, но вдруг кому-то понадобится.
Задача была такая: на замену статическому сайту — набору HTML-страниц, раздающемуся с сервера — написать программу, которая обрабатывает авторизацию и отправляет HTML-ки, если доступ разрешён.
Я использовал фреймворк FastAPI. Код и инструкции по запуску есть на Гитхабе, а структура приложения выглядит так:
site/
здесь лежат статические файлы сайта
src/
templates/
login.html
__init__.py
auth.py
staticfiles.py
vars.py
whitelist.py
Сейчас подробно объясню.
Мой туториал подойдёт вам, даже если ваш сайт не статический, но тоже написан на FastAPI и не использует другую авторизацию.
Посетителям сайта, которые ещё не аутентифицировались, я показываю простую страницу с просьбой войти и кнопкой «Войти через Телеграм». Страница выглядит, как на картинке выше, и лежит в 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
Список людей, которым я разрешаю доступ к своему сайту, не меняется — поэтому проще всего было захардкодить их 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 с помощью бота: например, проверять, состоит ли пользователь в чате.
Перейдём к основной части проекта. В __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
Последний шаг — отдавать файлы сайта.
Нужно добавить пару строчек:
# 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
. Оказалось, что 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
Если вы знаете, как улучшить или упростить код, — буду рад увидеть ваши комментарии.