Как-то мне понадобилось ограничить доступ к статическому сайту. Я написал сервер, который просит пользователей войти через Телеграм и пропускает только людей из белого списка. Ничего сложного, но вдруг кому-то понадобится.
Задача была такая: на замену статическому сайту — набору 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.