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.
Продолжить на практике
- FastAPI для начинающих: API с базой данных и тестами ↗
Соберёте свой первый полноценный backend на FastAPI: с базой данных, регистрацией, JWT-авторизацией и автотестами. 34 коротких урока и 140 заданий с проверкой — бесплатно.
