Книга: FastAPI: веб-разработка на Python
Назад: Глава 10. Уровень данных
Дальше: Глава 12. Тестирование

Глава 11. Аутентификация и авторизация

Уважай мою власть!

Эрик Картман (мультсериал «Южный парк»)

Обзор

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

Стоит ли оставить наш сайт о криптидах открытым для доступа любых пользователей к любой конечной точке? Нет! Практически любой крупный веб-сервис в конечном счете должен решать следующие задачи.

Аутентификация (authn). Кто вы?

Авторизация (authz). Что вам нужно?

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

Часто описания веб-безопасности кажутся более запутанными, чем нужно.

Злоумышленники могут быть очень, очень хитрыми, а меры противодействия — непростыми.

Как я уже не раз упоминал, официальная документация по FastAPI превосходна. Попробуйте изучить раздел Security section (https://oreil.ly/oYsKl), если информация данной главы покажется вам недостаточно подробной.

Итак, давайте разберемся с этим вопросом пошагово. Я начну с простых техник, предназначенных только для подключения auth-системы к конечной точке веб-сайта для тестирования, но на публичном сайте все это применяться не будет.

Немного отвлечемся. Нужна ли вам аутентификация?

Повторю: аутентификация связана с идентификацией — кто вы? Чтобы реализовать аутентификацию, необходимо сопоставить секретную информацию с уникальной идентификацией. Существуют разные способы реализовать это со множеством уровней сложности. Давайте начнем с малого и будем работать на усложнение.

Часто в книгах и статьях по веб-разработке сразу же переходят к деталям аутентификации и авторизации, иногда путая их. Порой в таких материалах пропущен первый вопрос: действительно ли вам нужно и то и другое?

Вы можете предоставить полностью анонимный доступ ко всем страницам своего сайта. Но это оставит открытую возможность для таких уязвимостей, как атаки типа «отказ в обслуживании» (DoS). Хотя некоторые меры защиты, например ограничение количества запросов, можно реализовать вне веб-сервера (см. главу 13), почти все поставщики публичных API требуют наличия хотя бы какой-то аутентификации. Помимо безопасности, необходимо знать, насколько эффективны веб-сайты.

• Сколько уникальных посетителей?

• Какие страницы пользуются наибольшей популярностью?

• Увеличивают ли определенные изменения количество просмотров?

• Какие последовательности посещения страниц часто встречаются?

Ответы на эти вопросы требуют аутентификации конкретных посетителей. В противном случае вы можете получить только общее количество значений по всем показателям.

Если ваш сайт требует аутентификации или авторизации, доступ к нему должен быть зашифрован (с использованием HTTPS вместо HTTP), чтобы злоумышленники не смогли извлечь секретные данные из обычного текста. Подробные сведения о настройке HTTPS см. в главе 13.

Методы аутентификации

Существует множество методов и инструментов веб-аутентификации:

имя пользователя/электронная почта и пароль — применение классических HTTP Basic и дайджест-аутентификации;

• ключ API — неясная длинная строка с сопутствующим секретом;

• OAuth2 — набор стандартов для аутентификации и авторизации;

веб-токены JavaScript (JavaScript Web Tokens, JWT) — формат кодирования, содержащий криптографически подписанную информацию о пользователе.

В этом разделе я рассмотрю первые два метода и покажу их традиционную реализацию. Но остановлюсь перед тем, как заполнить код API и базы данных. Вместо этого мы полностью реализуем более современную схему с OAuth2 и JWT.

Глобальная аутентификация — секретный ключ или общий секрет (Shared Secret)

Самый простой метод аутентификации заключается в передаче секрета, который обычно известен только веб-серверу. Если он совпадает, доступ открывается. Это небезопасно, если ваш сайт API открыт для общего доступа по протоколу HTTP, а не HTTPS. Если он скрыт за открытым фронтенд-сайтом, фронтенд- и бэкенд-части системы могут взаимодействовать, используя общий постоянный секрет. Но если фронтенд-сайт взломают, всем конец. Давайте посмотрим, как FastAPI обрабатывает простую аутентификацию.

Создайте новый файл верхнего уровня под названием auth.py. Убедитесь, что у вас нет другого сервера FastAPI, запущенного из одного из постоянно меняющихся файлов main.py из предыдущих глав. В примере 11.1 реализован сервер, просто возвращающий все записи username и password, отправленные ему с помощью HTTP Basic Authentication — метода из первых дней существования Интернета.

Пример 11.1. Применение HTTP Basic Auth для получения информации о пользователе: auth.py

import uvicorn

from fastapi import Depends, FastAPI

from fastapi.security import HTTPBasic, HTTPBasicCredentials

app = FastAPI()

basic = HTTPBasic()

@app.get("/who")

def get_user(

    creds: HTTPBasicCredentials = Depends(basic)):

    return {"username": creds.username, "password": creds.password}

if __name__ == "__main__":

    uvicorn.run("auth:app", reload=True)

В примере 11.2 укажите HTTPie выполнить этот запрос Basic Auth (для этого требуются аргументы -a name:password). Здесь мы используем название me и пароль secret.

Пример 11.2. Проверка с помощью HTTPie

$ http -q -a me:secret localhost:8000/who

{

    "password": "secret",

    "username": "me"

}

Тестирование с помощью пакета Requests в примере 11.3 аналогично, используется параметр auth.

Пример 11.3. Проверка с помощью Requests

>>> import requests

>>> r = requests.get("http://localhost:8000/who",

    auth=("me", "secret"))

>>> r.json()

{'username': 'me', 'password': 'secret'}

Вы также можете протестировать пример 11.1 с помощью автоматической страницы документации (http://localhost:8000/docs), показанной на рис. 11.1.

Рис. 11.1. Страница документации по простой аутентификации

Нажмите стрелку вниз, расположенную справа, затем кнопку Try It Out (Пробовать) и кнопку Execute (Выполнить). Вы увидите форму, запрашивающую имя пользователя и пароль. Введите что угодно. Форма документации обратится к этой конечной точке сервера и покажет эти значения в ответе.

Эти тесты показывают, что вы можете получить имя пользователя и пароль к серверу и обратно (хотя ни один из них на самом деле ничего не проверял). Что-то на сервере должно проверить, что имя и пароль соответствуют утвер­жденным значениям. Так, в примере 11.4 я включу в веб-сервер одно секретное имя пользователя и пароль. Вводимые имя пользователя и пароль должны совпадать (каждый из них представляет собой секретный ключ), иначе будет выброшено исключение. Код состояния HTTP 401 официально называется Unauthorized (Не авторизован), но на самом деле он означает «неаутентифицированный».

Вместо того чтобы запоминать все коды статуса HTTP, можно импортировать модуль статуса FastAPI, который сам импортируется непосредственно из Starlette. Поэтому вы можете использовать более понятное status_code=HTTP_401_UNAUTHORIZED в примере 11.4 вместо простой строки status_code=401.

Пример 11.4. Добавление секретного имени пользователя и пароля в auth.py

import uvicorn

from fastapi import Depends, FastAPI, HTTPException

from fastapi.security import HTTPBasic, HTTPBasicCredentials

app = FastAPI()

secret_user: str = "newphone"

secret_password: str = "whodis?"

basic: HTTPBasicCredentials = HTTPBasic()

@app.get("/who")

def get_user(

    creds: HTTPBasicCredentials = Depends(basic)) -> dict:

    if (creds.username == secret_user and

        creds.password == secret_password):

        return {"username": creds.username,

            "password": creds.password}

    raise HTTPException(status_code=401, detail="Hey!")

if __name__ == "__main__":

    uvicorn.run("auth:app", reload=True)

Неправильное введение имени пользователя и пароля приведет к мягкому упреку 401, как показано в примере 11.5.

Пример 11.5. Тест с помощью HTTPie и с несовпадающими именем пользователя/паролем

$ http -a me:secret localhost:8000/who

HTTP/1.1 401 Unauthorized

content-length: 17

content-type: application/json

date: Fri, 03 Mar 2023 03:25:09 GMT

server: uvicorn

{

    "detail": "Hey!"

}

Применение магической комбинации возвращает имя пользователя и пароль, как показано в примере 11.6.

Пример 11.6. Тестирование с помощью HTTPie и с правильными именем пользователя/паролем

$ http -q -a newphone:whodis? localhost:8000/who

{

    "password": "whodis?",

    "username": "newphone"

}

Простая индивидуальная аутентификация

В предыдущем разделе было показано, как можно использовать секретный ключ для контроля доступа. Это широко применяемый, но не очень надежный подход. И это ничего не говорит вам о конкретном посетителе, только то, что он (или разумный ИИ) знает секретный ключ. Многим сайтам требуется обес­печить следующее:

• определение каким-либо образом отдельных посетителей;

• идентификацию конкретных посетителей при получении ими доступа к определенным конечным точкам (аутентификация);

• возможность назначения различных прав доступа для некоторых посетителей и конечных точек (авторизация);

• возможность сохранения определенной информации о каждом посетителе (интересы, покупки и т.д.).

Если ваши посетители — люди, можете попросить их указать имя пользователя или электронную почту и пароль. Если это внешние программы, можно попросить их предоставить ключ и секрет API.

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

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

• Передать значения пользователя (имя и пароль) конечным точкам сервера API в виде HTTP-заголовков.

• Использовать HTTPS вместо HTTP, чтобы никто не смог подсмотреть текст этих заголовков.

• Хешировать пароль в отдельную строку. Результат не является «дехешируе­мым» — из его хеша нельзя извлечь оригинальный пароль.

• Реальная база данных должна хранить таблицу User, содержащую имя пользователя и хешированный пароль (ни в коем случае не оригинальный пароль в виде простого текста).

• Хешировать только что введенный пароль и сравнить результат с хешированным паролем в базе данных.

• Если имя пользователя и хешированный пароль совпадают, передать соответствующий объект User вверх по стеку. Если они не совпадают, вернуть значение None или вызвать исключение.

• На уровне сервисов запустить все метрики/ведение журналов/что угодно, относящиеся к аутентификации отдельных пользователей.

• На веб-уровне отправить информацию об аутентифицированном пользователе всем функциям, которым она требуется.

В следующих разделах я покажу вам, как сделать все эти вещи, применяя такие современные инструменты, как OAuth2 и JWT.

Более сложная индивидуальная аутентификация

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

Официальные документы по безопасности FastAPI (вводные (https://oreil.ly/kkTUB) и продвинутые (https://oreil.ly/biKwy)) содержат полные описания того, как настроить аутентификацию для нескольких пользователей, задействуя локальную базу данных. Но пример веб-функции подделывает фактический доступ к базе данных.

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

OAuth2

OAuth 2.0, что расшифровывается как Open Autho­rization («открытая авторизация»), — это стандарт, позволяющий веб-сайту или приложению получать доступ к ресурсам, размещенным другими веб-приложениями, от имени пользователя.

Auth0

В ранние времена полного доверия к Интернету вы могли предоставить логин и пароль от веб-сайта (назовем его Б) другому сайту (A, конечно же), и он получал доступ к материалам, размещенным на Б, для вас. В результате A получит полный доступ к Б, хотя ему будет позволено иметь доступ только к тому, что ему положено. Примерами Б и ресурсов могут служить подписчики в Twitter, друзья в Facebook, контакты по электронной почте и т.д. Конечно, это не могло продолжаться долго, поэтому различные компании и группы объединились, чтобы определить стандарт OAuth. Изначально он был разработан только для того, чтобы позволить сайту A получить доступ к определенным (не всем) ресурсам сайта Б.

OAuth2 (https://oauth.net/2) — это популярный, но сложный стандарт авторизации, его применение выходит за рамки примера A/Б. Для него существует множество объяснений, от простых (https://oreil.ly/ehmuf) до сложных (https://oreil.ly/qAUaM).

Раньше существовал стандарт OAuth1 (https://oauth.net/1), но он больше не используется. Некоторые из первоначальных рекомендаций OAuth2 уже устарели (другими словами — не применяйте их). На горизонте уже виден стандарт OAuth2.1 (https://oauth.net/2.1) и где-то дальше в тумане — txauth (https://oreil.ly/5PW2T).

OAuth предлагает различные потоки (flows) (https://oreil.ly/kRiWh) для разных обстоятельств. Здесь я буду использовать Authorization Code Flow (поток кода авторизации). В этом разделе мы рассмотрим реализацию, по одному среднему этапу за раз.

Сначала вам нужно установить сторонние пакеты Python:

JWT handlingpip install python-jose[cryptography];

• Secure password handlingpip install passlib;

Form handlingpip install python-multipart.

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

Модель пользователя

Начнем с самых минимальных определений пользовательской модели в примере 11.7. Они будут применяться во всех слоях.

Пример 11.7. Определение пользователя: model/user.py

from pydantic import BaseModel

class User(BaseModel):

    name: str

    hash: str

Объект User содержит произвольное поле name и строку hash — хешированный пароль, а не оригинальный в виде простого текста, и именно он сохраняется в базе данных. Для аутентификации посетителя нам понадобятся оба варианта.

Уровень пользовательских данных

Пример 11.8 содержит код базы данных пользователя.

Код содержит таблицы user (активные пользователи) и xuser (удаленные пользователи). Часто разработчики добавляют булево поле deleted в таблицу пользователей, чтобы указать, что пользователь больше не активен, не удаляя запись из таблицы. Я предпочитаю перемещать данные удаленного пользователя в другую таблицу. Это позволяет избежать повторной проверки поля deleted во всех пользовательских запросах. Также это может помочь ускорить запросы — создание индекса для поля с низкой мощностью, например булева, не принесет пользы.

Пример 11.8. Уровень данных: data/user.py

from model.user import User

from .init import (conn, curs, get_db, IntegrityError)

from error import Missing, Duplicate

curs.execute("""create table if not exists

                user(

                  name text primary key,

                  hash text)""")

curs.execute("""create table if not exists

                xuser(

                  name text primary key,

                  hash text)""")

def row_to_model(row: tuple) -> User:

    name, hash = row

    return User(name=name, hash=hash)

def model_to_dict(user: User) -> dict:

    return user.dict()

def get_one(name: str) -> User:

    qry = "select * from user where name=:name"

    params = {"name": name}

    curs.execute(qry, params)

    row = curs.fetchone()

    if row:

        return row_to_model(row)

    else:

        raise Missing(msg=f"User {name} not found")

def get_all() -> list[User]:

    qry = "select * from user"

    curs.execute(qry)

    return [row_to_model(row) for row in curs.fetchall()]

def create(user: User, table:str = "user"):

    """Добавление <пользователя> в таблицу user или xuser"""

    qry = f"""insert into {table}

        (name, hash)

        values

        (:name, :hash)"""

    params = model_to_dict(user)

    try:

        curs.execute(qry, params)

    except IntegrityError:

        raise Duplicate(msg=

            f"{table}: user {user.name} already exists")

def modify(name: str, user: User) -> User:

    qry = """update user set

             name=:name, hash=:hash

             where name=:name0"""

    params = {

        "name": user.name,

        "hash": user.hash,

        "name0": name}

    curs.execute(qry, params)

    if curs.rowcount == 1:

        return get_one(user.name)

    else:

        raise Missing(msg=f"User {name} not found")

def delete(name: str) -> None:

    """Отбрасывание пользователя с именем <name> из таблицы пользователей,

    добавление его в таблицу xuser"""

    user = get_one(name)

    qry = "delete from user where name = :name"

    params = {"name": name}

    curs.execute(qry, params)

    if curs.rowcount != 1:

        raise Missing(msg=f"User {name} not found")

    create(user, table="xuser")

Уровень фиктивных данных пользователя

Модуль из примера 11.9 применяется в тестах, исключающих базу данных, но нуждается в пользовательских данных.

Пример 11.9. Уровень фиктивных данных: fake/user.py

from model.user import User

from error import Missing, Duplicate

# (в этом модуле нет проверки хешированного пароля)

fakes = [

    User(name="kwijobo",

         hash="abc"),

    User(name="ermagerd",

         hash="xyz"),

    ]

def find(name: str) -> User | None:

    for e in fakes:

        if e.name == name:

            return e

    return None

def check_missing(name: str):

    if not find(name):

        raise Missing(msg=f"Missing user {name}")

def check_duplicate(name: str):

    if find(name):

        raise Duplicate(msg=f"Duplicate user {name}")

def get_all() -> list[User]:

    """Возврат всех пользователей"""

    return fakes

def get_one(name: str) -> User:

    """Возврат одного пользователя"""

    check_missing(name)

    return find(name)

def create(user: User) -> User:

    """Добавление пользователя"""

    check_duplicate(user.name)

    return user

def modify(name: str, user: User) -> User:

    """Частичное изменение пользователя"""

    check_missing(name)

    return user

def delete(name: str) -> None:

    """Удаление пользователя"""

    check_missing(name)

    return None

Сервисный уровень пользователя

Пример 11.10 определяет сервисный уровень для пользователей. Отличием от других модулей сервисного уровня является добавление функций OAuth2 и JWT. Я думаю, что лучше оставить их здесь, чем в веб-слое, хотя несколько функций веб-слоя OAuth2 уже есть в готовящемся проекте web/user.py.

Функции CRUD пока что остаются проходными, но в будущем их можно будет разнообразить метриками по своему вкусу. Обратите внимание на то, что, как и сервисы существ и исследователей, эта конструкция поддерживает применение во время выполнения либо фиктивных, либо настоящих уровней данных для доступа к пользовательским данным.

Пример 11.10. Сервисный уровень: service/user.py

from datetime import timedelta, datetime

import os

from jose import jwt

from model.user import User

if os.getenv("CRYPTID_UNIT_TEST"):

    from fake import user as data

else:

    from data import user as data

# --- Новые данные auth

from passlib.context import CryptContext

# Измените SECRET_KEY для среды эксплуатации!

SECRET_KEY = "keep-it-secret-keep-it-safe"

ALGORITHM = "HS256"

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

def verify_password(plain: str, hash: str) -> bool:

    """Хеширование строки <plain> и сравнение с записью <hash> из базы данных"""     

    return pwd_context.verify(plain, hash)

def get_hash(plain: str) -> str:

    """Возврат хеша строки <plain>"""

    return pwd_context.hash(plain)

def get_jwt_username(token:str) -> str | None:

    """Возврат имени пользователя из JWT-доступа <token>"""

    try:

        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])

        if not (username := payload.get("sub")):

            return None

    except jwt.JWTError:

        return None

    return username

def get_current_user(token: str) -> User | None:

    """Декодирование токена <token> доступа OAuth и возврат объекта User"""

    if not (username := get_jwt_username(token)):

        return None

    if (user := lookup_user(username)):

        return user

    return None

def lookup_user(username: str) -> User | None:

    """Возврат совпадающего пользователя из базы данных для строки <name>"""

    if (user := data.get(username)):

        return user

    return None

def auth_user(name: str, plain: str) -> User | None:

    """Аутентификация пользователя <name> и <plain> пароль"""

    if not (user := lookup_user(name)):

        return None

    if not verify_password(plain, user.hash):

        return None

    return user

def create_access_token(data: dict,

    expires: timedelta | None = None

):

    """Возвращение токена доступа JWT"""

    src = data.copy()

    now = datetime.utcnow()

    if not expires:

        expires = timedelta(minutes=15)

    src.update({"exp": now + expires})

    encoded_jwt = jwt.encode(src, SECRET_KEY, algorithm=ALGORITHM)

    return encoded_jwt

# --- CRUD-пассивный материал

def get_all() -> list[User]:

    return data.get_all()

def get_one(name) -> User:

    return data.get_one(name)

def create(user: User) -> User:

    return data.create(user)

def modify(name: str, user: User) -> User:

    return data.modify(name, user)

def delete(name: str) -> None:

    return data.delete(name)

Веб-уровень пользователей

Пример 11.11 определяет базовый пользовательский модуль на веб-уровне. Он применяет новый код авторизации из модуля service/user.py из примера 11.10.

Пример 11.11. Веб-уровень: web/user.py

import os

from fastapi import APIRouter, HTTPException

from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm

from model.user import User

if os.getenv("CRYPTID_UNIT_TEST"):

    from fake import user as service

else:

    from service import user as service

from error import Missing, Duplicate

ACCESS_TOKEN_EXPIRE_MINUTES = 30

router = APIRouter(prefix = "/user")

# --- Новые данные auth

# Эта зависимость создает сообщение в каталоге

# "/user/token" (из формы с именем пользователя и паролем)

# и возвращает токен доступа.

oauth2_dep = OAuth2PasswordBearer(tokenUrl="token")

def unauthed():

    raise HTTPException(

        status_code=401,

        detail="Incorrect username or password",

        headers={"WWW-Authenticate": "Bearer"},

        )

# К этой конечной точке направляется любой вызов,

# содержащий зависимость oauth2_dep():

@router.post("/token")

async

def create_access_token(

    form_data: OAuth2PasswordRequestForm = Depends()

):

    """Получение имени пользователя и пароля

       из формы OAuth, возврат токена доступа"""

    user = service.auth_user(form_data.username, form_data.password)

    if not user:

        unauthed()

    expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)

    access_token = service.create_access_token(

        data={"sub": user.username}, expires=expires

    )

    return {"access_token": access_token, "token_type": "bearer"}

@app.get("/token")

def get_access_token(token: str = Depends(oauth2_dep)) -> dict:

    """Возврат текущего токена доступа"""

    return {"token": token}

# --- предыдущий материал CRUD

@router.get("/")

def get_all() -> list[User]:

    return service.get_all()

@router.get("/{name}")

def get_one(name) -> User:

    try:

        return service.get_one(name)

    except Missing as exc:

        raise HTTPException(status_code=404, detail=exc.msg)

@router.post("/", status_code=201)

def create(user: User) -> User:

    try:

        return service.create(user)

    except Duplicate as exc:

        raise HTTPException(status_code=409, detail=exc.msg)

@router.patch("/")

def modify(name: str, user: User) -> User:

    try:

        return service.modify(name, user)

    except Missing as exc:

        raise HTTPException(status_code=404, detail=exc.msg)

@router.delete("/{name}")

def delete(name: str) -> None:

    try:

        return service.delete(name)

    except Missing as exc:

        raise HTTPException(status_code=404, detail=exc.msg)

Тестируем!

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

Верхний уровень

В предыдущем разделе была определена новая переменная router для URL, начинающихся с пути /user, поэтому в примере 11.12 добавляется этот субмаршрут.

Пример 11.12. Верхний уровень: main.py

from fastapi import FastAPI

from web import explorer, creature, user

app = FastAPI()

app.include_router(explorer.router)

app.include_router(creature.router)

app.include_router(user.router)

При автозагрузке Uvicorn конечные точки папки /user/... теперь должны быть доступны.

Итак, мы создали пользовательский код, а теперь давайте дадим ему повод для работы.

Этапы аутентификации

Вот обзор массы кода из предыдущих разделов.

• Если у конечной точки есть зависимость oauth2_dep() (в файле web/user.py), то форма, содержащая поля имени пользователя и пароля, генерируется и отправляется клиенту.

• После того как клиент заполнит и отправит эту форму, имя пользователя и пароль (хешированные тем же алгоритмом, что и те, которые уже хранятся в локальной базе данных) будут сопоставлены с находящимися в локальной базе данных.

• При их совпадении токен доступа (в формате JWT) генерируется и возвращается.

• Этот токен доступа передается обратно веб-серверу в виде HTTP-заголовка Authorization при последующих запросах. JWT-токен декодируется на локальном сервере в имя пользователя и другие данные. Это имя не нужно снова искать в базе данных.

• Имя пользователя аутентифицировано, и сервер может делать с ним все, что захочет.

Что может сделать сервер с полученной с таким трудом информацией об аутентификации? Он может:

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

• сохранять информацию о пользователе.

JWT

Этот раздел содержит некоторые подробности о JWT. На самом деле эти токены не нужны, чтобы применять весь предыдущий код из этой главы, но если вам любопытно…

JWT (https://jwt.io) — это схема кодирования, а не метод аутентификации. Низкоуровневые детали определены в стандарте RFC 7519 (https://oreil.ly/_op1j). Его можно использовать для передачи информации об аутентификации для OAuth2 и других методов, я покажу пример такой реализации.

JWT — это читаемая строка, состоящая из трех разделов, разделенных точками:

заголовок — используемый алгоритм шифрования и тип токена;

• полезная нагрузка — …

подпись — …

Каждый раздел состоит из строки JSON, закодированной в формате Base 64 URL (https://www.base64url.com). Вот пример (он был разбит на позициях точек, чтобы поместиться на ширине этой страницы):

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.

eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.

SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

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

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

Сторонняя аутентификация: OIDC

Часто можно встретить сайты, позволяющие войти в систему с помощью идентификатора и пароля или войти через свой аккаунт на другом сайте, например Google, Facebook/Meta, LinkedIn и многих других. В таком случае часто используется стандарт OpenID Connect (OIDC) (https://openid.net/connect), созданный поверх OAuth2. Когда вы подключаетесь к внешнему сайту с поддержкой OIDC, вы получаете в ответ маркер доступа OAuth2 (как в примерах в этой главе), а также ID token.

Официальная документация FastAPI не содержит примера кода для интеграции с OIDC. Если вы хотите попробовать, то сэкономить время на создание собственной реализации позволят некоторые сторонние пакеты (как специфичные для FastAPI, так и более общие):

• FastAPI OIDC (https://oreil.ly/TDABr);

• fastapi-third-party-auth (https://oreil.ly/yGaO6);

• FastAPI Resource Server (https://oreil.ly/THByF);

• oauthlib (https://oreil.ly/J-pDB);

• oic (https://oreil.ly/AgYKZ);

• OIDC Client (https://oreil.ly/e9QGb);

• oidc-op (https://oreil.ly/cJCF4);

• OpenID Connect (https://oreil.ly/WH49I).

Страница репозитория с проблемами FastAPI (https://oreil.ly/ztR3r) содержит множество примеров кода, а также комментарий от пользователя tiangelo (Себастьян Рамирес) о том, что в будущем примеры FastAPI OIDC будут включены в официальную документацию и учебники.

Авторизация

Аутентификация отвечает за то, кто (личность), а авторизация — за то, что: к каким ресурсам (конечным точкам веб-страниц) вам разрешен доступ и каким образом? Количество комбинаций ответов на вопросы «кто?» и «что?» может быть огромным.

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

Если доступ ко всем конечным точкам полностью открыт, авторизация не требуется, можете пропустить этот раздел. Простейшая авторизация может быть простой булевой функцией (является этот пользователь администратором или нет?). Для примеров в этой книге вам может потребоваться авторизация уровня администратора для добавления, удаления или изменения исследователя или существа. Если в вашей базе данных много записей, возможно, вы захотите ограничить функции get_all() с дополнительными правами для неадминистраторов. По мере усложнения сайта разрешения могут становиться все более детализированными.

Рассмотрим несколько вариантов авторизации. Берем таблицу User, в которой поле name может быть электронной почтой, именем пользователя или ключом API. Парные таблицы — это способ реляционной базы данных сопоставить записи из двух отдельных таблиц:

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

• Если все посетители должны проходить аутентификацию, но вам нужно авторизовать администраторов только для некоторых конечных точек, то аутентифицируйте всех, как в предыдущих примерах (из таблицы User), а затем проверьте таблицу Admin, чтобы узнать, является ли этот пользователь также администратором.

• Для более чем одного типа разрешения (например, только чтение, чтение, запись):

• задействуйте таблицу определения уровней допуска Permission;

• возьмите таблицу UserPermission, в которой сопоставляются пользователи и уровни допуска. Иногда ее называют списком управления доступом.

• Если комбинации уровней управления доступом сложны, добавьте уровень и определите роли (независимые наборы разрешений):

• создайте таблицу ролей Role;

• создайте таблицу UserRole, сопоставляющую пары сущностей из таблиц пользователей и ролей — User и Role соответственно. Иногда ее называют управлением доступом на основе ролей (Role-Based Access Control, RBAC).

Промежуточное программное обеспечение

FastAPI позволяет вставлять на веб-уровень код, выполняющий:

• перехват запроса;

• операции с запросом;

• передачу запроса функции пути;

• перехват ответа, возвращаемого исполняющей функцией;

• операции с ответом;

• возврат ответа вызывающей стороне.

Это похоже на то, что декоратор в Python делает с «оборачиваемой» функцией.

В некоторых случаях можно использовать либо промежуточное ПО, либо внедрение зависимостей с помощью функции Depends(). Промежуточное ПО удобнее для решения более глобальных вопросов безопасности, таких как CORS, что приводит к…

CORS

Совместное использование ресурсов разными источниками (Cross-Origin Resource Sharing, CORS) предполагает связь между другими доверенными серверами и вашим сайтом. Если на сайте весь код фронтенда и бэкенда находится в одном месте, то проблем не возникнет. Но в наши дни часто встречается ситуация, когда фронтенд на JavaScript общается с бэкендом, написанным на чем-то вроде FastAPI. Эти серверы не будут иметь одинакового происхождения:

протокол — HTTP или HTTPS;

• домен — интернет-домен, например google.com или localhost;

порт — числовой TCP/IP-порт в этом домене, например 80, 443 или 8000.

Как бэкенд может отличить надежный фронтенд от коробки с заплесневелой редиской или злоумышленника, крутящего усы? Это работа для CORS — технологии, определяющей, чему доверяет бэкенд. Наиболее известными способами являются следующие:

• заголовки запросов Origin;

• HTTP-методы;

• HTTP-заголовки;

• тайм-аут кэша CORS.

Вы подключаетесь к CORS на веб-уровне. В примере 11.13 показано, как разрешить только один фронтенд-сервер (с доменом https://ui.cryptids.com), а также любые HTTP-заголовки и методы.

Пример 11.13. Активация промежуточного ПО CORS

from fastapi import FastAPI, Request

from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

app.add_middleware(

    CORSMiddleware,

    allow_origins=["https://ui.cryptids.com",],

    allow_credentials=True,

    allow_methods=["*"],

    allow_headers=["*"],

    )

@app.get("/test_cors")

def test_cors(request: Request):

    print(request)

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

Пакеты сторонних разработчиков

Вы ознакомились с примерами того, как создавать решения для аутентификации и авторизации с помощью FastAPI. Но, возможно, не нужно делать все самостоятельно. Экосистема FastAPI быстро развивается, и могут появиться пакеты, выполняющие бо́льшую часть работы за вас.

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

• FastAPI Users (https://oreil.ly/ueVfq);

• FastAPI JWT Auth (https://oreil.ly/ooGSK);

• FastAPI-Login (https://oreil.ly/oWA3p);

• fastapi-auth0 (https://oreil.ly/fHfkU);

• AuthX (https://authx.yezz.me);

• FastAPI-User-Auth (https://oreil.ly/J57xu);

• fastapi-authz (https://oreil.ly/aAGzW);

• fastapi-opa (https://oreil.ly/Bvzv3);

• FastAPI-key-auth (https://oreil.ly/s-Ui5);

• FastAPI Auth Middleware (https://oreil.ly/jnR-s);

• fastapi-jwt (https://oreil.ly/RrxUZ);

• fastapi_auth2 (https://oreil.ly/5DXkB);

• fastapi-sso (https://oreil.ly/GLTdt);

• Fief (https://www.fief.dev).

Заключение

Эта глава была труднее остальных. В ней были показаны способы аутентификации посетителей и их авторизации для выполнения определенных действий. Это два аспекта веб-безопасности. Здесь также обсуждается CORS — еще одна важная тема веб-безопасности.


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

Назад: Глава 10. Уровень данных
Дальше: Глава 12. Тестирование