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

Задача была такая: на замену статическому сайту — набору 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
Если вы знаете, как улучшить или упростить код, — буду рад увидеть ваши комментарии.
savostin
…и сайт больше не статический…
nin-jin
А ведь можно же было зашифровать данные секретным ключом, секретный ключ зашифровать общим ключом, получаемым из своего приватного ключа и публичного ключа юзера, которому надо предоставить доступ, а приватный ключ зашифровать паролем и.. выложить всё это дело на статический хостинг.
tmat Автор
И заставить пользователей в ужасе разбежаться
nin-jin
Ужас-то в чём?
Goron_Dekar
У вас в описании этого схематоза слово "ключ" употребляется 6 раз в 4 значениях. Этого достаточно.
nin-jin
Это в каких таких 4 значениях?
Newbilius
Секретный, общий, публичный, приватный
Для "простого пользователя" (tm) уже ничего непонятно и сложно
pennanth
Интересный подход. А что он даёт по сравнению с генерацией ключа из пароля и обычным симметричным шифрованием через AES?
nin-jin
У каждого пользователя свой пароль и соответственно свой ключ из него. Чтобы не копировать все данные для каждого пользователя имеет смысл шифровать их отдельным одним ключом.
nin-jin
Во, запилил демку с упрощённой схемой без приватных ключей:
@Myateznikприватный ключ автора, разумеется, ни на какие хостинги выкладывать не нужно, только публичный.
inkelyad
Или вместо этого выдать каждому пользователя клиентский X.509 сертификат - и пускай с ним в статический сайт ходят.
Myateznik
Так ок, у нас есть DEK (симметричный), есть KEK (симметричный) по KDF, мы зашифровали клиентский приватный ключ (асимметричный) с помощью KEK (симметричный) по PBKDF2 (пароль знает только пользователь) допустим... Но что с нашим приватным ключом (асимметричный)? Он так же в открытую лежит на статическом хостинге? Или он тоже зашифрован, но данные расшифровки лежат рядом?
В этой схеме просто теряется смысл, по сути любой клиент имеет у себя в наличии сразу два приватных ключа... И вектор атаки очевидно - кража зашифрованного клиентского ключа + пароля для его расшифровки (соц. инженерия, фишинг, зловредные расширения), а серверный приватный ключ уже есть.
Тут без динамической части ну никак не обойтись в принципе, кто-то должен выступать второй стороной ответственной за ключ и хранящей его в тайне.
qw1
Любой пользователь, раз авторизовавшись, получив секретный ключ из всей этой матрёшки, может на форумах-трекерах растрезвонить: секретный ключ такой-то, скачивайте, расшифровывайте. И даже нельзя будет узнать, кто слил, раз секретный ключ для всех общий.
nin-jin
Сенсация! Пользователь, получивший доступ к контенту, может его скопировать и выложить куда угодно не раскрывая свою анонимность! Срочно на главную! Страна опасносте!
PanDubls
С традиционными методами авторизации пользователь может поделиться с неограниченным кругом лиц только контентом, а в случае секретного ключа -- ещё и доступом к функциональности. Это немного хуже.
vikarti
Вообще у Pinata была штука которая вроде бы так работала (в сочетании с IPFS и как то (по их словам) обеспечивали безопасность, в условиях когда вообще хостингом непонятно что теперь нет - https://knowledge.pinata.cloud/en/articles/8313206-sunsetting-submarine-private-files-and-private-api-faqs и сами предлагают как замену - https://www.litprotocol.com/
Там смарт-контракты по полной программе, клиенту нужен кошелек и так далее. И мне вот не вполне понятно из описания LitProtocol на https://developer.litprotocol.com/v3/resources/how-it-works - насколько оно корректно работает то.
SergioT4
Если сайт реально статический и у вас нету контроля на стороне сервера, то большинство подпрыгиваний не рабочий вариант. Как бы не заморочился, будет кусок кода который говорит авторизован или нет этот код можно отдебажить и даже поменять у себя в браузере.
Более менее то что реально работает (от честных людей), это например доступ к ресурсам базируясь на значении полученном от пользователя.
т.е. при загрузке сайт, у нас например есть общедоступный список пользователей и некое значение.
Пользователь вводит имя/пароль. По имени пользователя получаем значение из списка и манипулируем его значение с значением пароля, пусть самое простое XOR, например у vasya пароль "123456", тогда XOR c "qpoipErqw23nl23kn" будет QEJcXUVzQ0NEBgZYXQAAX1s.
Полученный результат, используем как часть URL который будет использован при загрузке данных и/или кода, что-то типа:
Таким образом если ты не знаешь правильного пароля, то и не загрузишь ресурс.
Но конечно в случаях когда пароль компроментирован, придётся менять названия директорий и т.п.
Если есть хоть какой-то контроль на стороне сервера и у вас небольшое количество пользователей, можно просто делать символические ссылки на директорию специфичную для каждого пользователя, чтобы не усложнять управление приложением.
SergioT4
Кстати в статье не было указано зачем используется авторизация. Это очень важный вопрос и от этого ответа многое зависит.
В некоторых случаях может оказаться что авторизация не так и нужна и для достижения цели можно обойтись альтернативными решениями, особенно для публичных сервисов.
Например:
- Сохранение настроек между несколькими устройствами. Функция востребованная очень небольшим количеством пользователей. Храните настойки в локальном сторадже.
- Трекинг пользователей и их поведения. При первом заходе создайте guid и сохраните в локальном сторадже.
- Обновления контента/комменты и т.п. - для статического сайта без динамической части не вариант, так что само собой не рассматриваем.
- Ограничение доступа/доступ по оплате, тут решение которое привёл в предыдущем комментарии, но надо понимать что работает для очень небольшого количества пользователей и без нормального server-side геморойно.
vikarti
Вот!
При этом если надо просто закрыть доступ - самую тупую HTTP-авторизацию в которую умеет любой вебсервер видимо не надо. Если мы все равно ставим свой сервер то сайт уже не статический и можно извращаться по полной программе.
Вот если б способ с реально статическим, в ситуации когда ну нельзя сделать сервер, совсем нельзя, но хочется чтобы как то работало (желательно через telegram :)) - ну например сайт - лежит в IPFS.