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

Задача была такая: на замену статическому сайту — набору 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

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

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

Комментарии (50)


  1. savostin
    19.03.2024 18:28
    +42

    …и сайт больше не статический…


    1. nin-jin
      19.03.2024 18:28
      +7

      А ведь можно же было зашифровать данные секретным ключом, секретный ключ зашифровать общим ключом, получаемым из своего приватного ключа и публичного ключа юзера, которому надо предоставить доступ, а приватный ключ зашифровать паролем и.. выложить всё это дело на статический хостинг.


      1. tmat Автор
        19.03.2024 18:28
        +9

        И заставить пользователей в ужасе разбежаться


        1. nin-jin
          19.03.2024 18:28

          Ужас-то в чём?


          1. Goron_Dekar
            19.03.2024 18:28
            +6

            У вас в описании этого схематоза слово "ключ" употребляется 6 раз в 4 значениях. Этого достаточно.


            1. nin-jin
              19.03.2024 18:28

              Это в каких таких 4 значениях?


              1. Newbilius
                19.03.2024 18:28
                +2

                Секретный, общий, публичный, приватный

                Для "простого пользователя" (tm) уже ничего непонятно и сложно


      1. pennanth
        19.03.2024 18:28
        +1

        Интересный подход. А что он даёт по сравнению с генерацией ключа из пароля и обычным симметричным шифрованием через AES?


        1. nin-jin
          19.03.2024 18:28
          +2

          У каждого пользователя свой пароль и соответственно свой ключ из него. Чтобы не копировать все данные для каждого пользователя имеет смысл шифровать их отдельным одним ключом.


        1. nin-jin
          19.03.2024 18:28

          Во, запилил демку с упрощённой схемой без приватных ключей:

          @Myateznikприватный ключ автора, разумеется, ни на какие хостинги выкладывать не нужно, только публичный.


      1. inkelyad
        19.03.2024 18:28
        +17

        Или вместо этого выдать каждому пользователя клиентский X.509 сертификат - и пускай с ним в статический сайт ходят.


      1. Myateznik
        19.03.2024 18:28
        +2

        Так ок, у нас есть DEK (симметричный), есть KEK (симметричный) по KDF, мы зашифровали клиентский приватный ключ (асимметричный) с помощью KEK (симметричный) по PBKDF2 (пароль знает только пользователь) допустим... Но что с нашим приватным ключом (асимметричный)? Он так же в открытую лежит на статическом хостинге? Или он тоже зашифрован, но данные расшифровки лежат рядом?

        В этой схеме просто теряется смысл, по сути любой клиент имеет у себя в наличии сразу два приватных ключа... И вектор атаки очевидно - кража зашифрованного клиентского ключа + пароля для его расшифровки (соц. инженерия, фишинг, зловредные расширения), а серверный приватный ключ уже есть.

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


      1. qw1
        19.03.2024 18:28
        +2

        и.. выложить всё это дело на статический хостинг

        Любой пользователь, раз авторизовавшись, получив секретный ключ из всей этой матрёшки, может на форумах-трекерах растрезвонить: секретный ключ такой-то, скачивайте, расшифровывайте. И даже нельзя будет узнать, кто слил, раз секретный ключ для всех общий.


        1. nin-jin
          19.03.2024 18:28

          Сенсация! Пользователь, получивший доступ к контенту, может его скопировать и выложить куда угодно не раскрывая свою анонимность! Срочно на главную! Страна опасносте!


          1. PanDubls
            19.03.2024 18:28
            +2

            С традиционными методами авторизации пользователь может поделиться с неограниченным кругом лиц только контентом, а в случае секретного ключа -- ещё и доступом к функциональности. Это немного хуже.


        1. vikarti
          19.03.2024 18:28
          +1

          Вообще у 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 - насколько оно корректно работает то.


        1. SergioT4
          19.03.2024 18:28
          +2

          Если сайт реально статический и у вас нету контроля на стороне сервера, то большинство подпрыгиваний не рабочий вариант. Как бы не заморочился, будет кусок кода который говорит авторизован или нет этот код можно отдебажить и даже поменять у себя в браузере.

          Более менее то что реально работает (от честных людей), это например доступ к ресурсам базируясь на значении полученном от пользователя.

          т.е. при загрузке сайт, у нас например есть общедоступный список пользователей и некое значение.

          [{"name": "vasya", "value": "qpoipErqw23nl23kn"},
           {"name": "petya", "value": "nelkFGqrjcin32oi"},
           {"name": "vova", "value": "JDDOee4134weqwed"}]

          Пользователь вводит имя/пароль. По имени пользователя получаем значение из списка и манипулируем его значение с значением пароля, пусть самое простое XOR, например у vasya пароль "123456", тогда XOR c "qpoipErqw23nl23kn" будет QEJcXUVzQ0NEBgZYXQAAX1s.

          Полученный результат, используем как часть URL который будет использован при загрузке данных и/или кода, что-то типа:

          var script = document.createElement('script'); 
          script.src = "https://mysite.com/js/" + encodeURIComponent(xoredValue) + "/myapp.js";

          Таким образом если ты не знаешь правильного пароля, то и не загрузишь ресурс.

          Но конечно в случаях когда пароль компроментирован, придётся менять названия директорий и т.п.

          Если есть хоть какой-то контроль на стороне сервера и у вас небольшое количество пользователей, можно просто делать символические ссылки на директорию специфичную для каждого пользователя, чтобы не усложнять управление приложением.


          1. SergioT4
            19.03.2024 18:28

            Кстати в статье не было указано зачем используется авторизация. Это очень важный вопрос и от этого ответа многое зависит.

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

            Например:

            - Сохранение настроек между несколькими устройствами. Функция востребованная очень небольшим количеством пользователей. Храните настойки в локальном сторадже.

            - Трекинг пользователей и их поведения. При первом заходе создайте guid и сохраните в локальном сторадже.

            - Обновления контента/комменты и т.п. - для статического сайта без динамической части не вариант, так что само собой не рассматриваем.

            - Ограничение доступа/доступ по оплате, тут решение которое привёл в предыдущем комментарии, но надо понимать что работает для очень небольшого количества пользователей и без нормального server-side геморойно.


    1. vikarti
      19.03.2024 18:28

      Вот!

      При этом если надо просто закрыть доступ - самую тупую HTTP-авторизацию в которую умеет любой вебсервер видимо не надо. Если мы все равно ставим свой сервер то сайт уже не статический и можно извращаться по полной программе.

      Вот если б способ с реально статическим, в ситуации когда ну нельзя сделать сервер, совсем нельзя, но хочется чтобы как то работало (желательно через telegram :)) - ну например сайт - лежит в IPFS.


  1. rockket
    19.03.2024 18:28
    +3

    Чувуак, спасибо тебе, пригодилось)