HTTP Basic в FastAPI: проверяем обе части учётных данных

Небольшой служебный endpoint защищён HTTP Basic. Разбор заголовка уже подключён, но внутри сравниваются обычные строки, а при отказе возвращается произвольный JSON. В результате браузер не понимает, что требуется повторная аутентификация, и проверка учётных данных зависит от особенностей сравнения строк. Исправление касается двух отдельных частей: проверки значений и HTTP-ответа.

Что делает зависимость HTTPBasic

HTTPBasic извлекает имя и пароль из Authorization. Кодирование Base64 не шифрует их; реальный запрос должен идти через HTTPS. Для небольшого служебного сценария можно явно задать проверку пары. Для пользовательского продукта, где нужны выход, отзыв сессии и восстановление доступа, сначала выбирают подходящую систему учётных записей.

Сравнение secrets.compare_digest предназначено для сравнения значений без обычного раннего прекращения на первом отличии. Передадим байты UTF-8 и вычислим оба результата до общего условия. Иначе короткое замыкание and может пропустить проверку пароля, когда имя не подошло. В примере тестовые значения заданы в файле для воспроизводимости; конфигурацию настоящего сервиса следует получать из выбранного хранилища секретов.

Проверим доступ через HTTP

Блок запускается как обычный Python-файл с пакетами fastapi и httpx. TestClient умеет самостоятельно формировать Basic-заголовок через auth. Проверим отсутствие заголовка, неправильное имя, неправильный пароль и успешную пару. Эти случаи не взаимозаменяемы: только успешный запрос не доказывает работу отказа.

from typing import Annotated
import secrets
from fastapi import FastAPI, Depends, HTTPException
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.testclient import TestClient

app = FastAPI()
basic = HTTPBasic()

def current_operator(c: Annotated[HTTPBasicCredentials, Depends(basic)]):
    valid_name = secrets.compare_digest(c.username.encode("utf-8"), b"operator")
    valid_password = secrets.compare_digest(c.password.encode("utf-8"), b"demo-pass")
    if not (valid_name and valid_password):
        raise HTTPException(401, "invalid credentials",
                            headers={"WWW-Authenticate": "Basic"})
    return c.username

@app.get("/maintenance")
def maintenance(user: Annotated[str, Depends(current_operator)]):
    return {"operator": user}

with TestClient(app) as client:
    missing = client.get("/maintenance")
    assert missing.status_code == 401
    assert missing.headers["www-authenticate"] == "Basic"
    for pair in [("unknown", "demo-pass"), ("operator", "wrong")]:
        denied = client.get("/maintenance", auth=pair)
        assert denied.status_code == 401
        assert denied.headers["www-authenticate"] == "Basic"
    result = client.get("/maintenance", auth=("operator", "demo-pass"))
    assert result.status_code == 200
    assert result.json() == {"operator": "operator"}
    print({"missing": missing.status_code, "allowed": result.json()})

Почему одинаковый отказ удобнее

Для неверного имени и неверного пароля ответ совпадает. Клиенту не требуется знать, какая часть пары подошла, а сервер не раскрывает наличие конкретной учётной записи. WWW-Authenticate описывает схему, которую клиент может использовать для следующей попытки. Потеряв этот заголовок в общем обработчике ошибок, легко сломать привычное поведение браузера.

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

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

Подключение за обратным прокси

Клиент должен сразу обращаться к HTTPS-адресу: перенаправление с HTTP приходит уже после отправки первого запроса и не защищает переданный в нём Authorization. Проверьте также, что прокси передаёт Authorization приложению. Запрос напрямую к TestClient не проверяет эту конфигурацию. После развёртывания выполните один разрешённый и один запрещённый запрос через тот же внешний адрес, которым пользуется клиент. Секрет подавайте способом, который не сохраняет его в истории команд.

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

Продолжить на практике

Разберите цепочку зависимостей до текущего пользователя, затем подключите её к нескольким маршрутам. Бесплатный курс FastAPI даёт связанную практику зависимостей и JWT-авторизации: она поможет перейти от отдельного служебного endpoint к учебному API с пользователями. Откройте бесплатную программу FastAPI.

Продолжить на практике