Аутентификация и безопасность
Argon2id, сессии и JWT, блокировка с нарастающей задержкой, CSRF-токен в каждой форме и CSP, начинающийся с default-src 'none'.
Доступно с версии v0.1.0
Модель пользователя
Любая модель. Базового класса для наследования нет — именно поэтому существующая схема может получить админку без миграций.
fort.set_user_model(User, identity_field="email")
Пять атрибутов определяются по стандартным именам — идентификатор (identity), пароль, активность (active), сотрудник (staff), суперпользователь (superuser). Если они называются иначе, передайте их явно:
fort.set_user_model(
User,
identity_field="login",
password_field="pwd",
active_field="enabled",
staff_field="can_admin",
superuser_field="is_root",
)
Определение имён атрибутов происходит через бэкенд, а не через класс.
SQLAlchemy создаёт дескриптор на классе для каждой колонки, а Tortoise — нет:
его поля существуют только в Model._meta, поэтому hasattr(User, "email")
вернёт False для модели Tortoise, у которой поле email при этом действительно
есть.
Пароли
Argon2id через pwdlib. Колонка пароля определяется по типу или по имени,
поэтому обычный String с именем hashed_password без каких-либо
дополнительных указаний получает нужный элемент управления: поле нового
пароля плюс подтверждение, хеширование при сохранении и отсутствие
изменений, если оба поля пусты.
FastFortSettings(
auth={"password_min_length": 10, "password_reject_common": True},
)
Конфиденциальные поля никогда не возвращаются обратно в форму и маскируются в записях аудита.
Блокировка
auth={
"lockout_threshold": 5,
"lockout_seconds": 60,
"lockout_window_seconds": 900,
}
Пять неудачных попыток за пятнадцать минут блокируют идентификатор и адрес на минуту, и с каждой следующей неудачей задержка растёт.
Сессии и токены
Два времени жизни, потому что это две разные сущности:
auth={
"session_ttl": 1_209_600, # an admin browser session
"access_token_ttl": 900, # a JWT for an API client
"refresh_token_ttl": 1_209_600,
}
Рабочий день человека — это не окно обновления токена API-клиента.
Сама админка использует cookie сессии и ничего больше, и это осознанное
решение. Токен в localStorage доступен любому скрипту, оказавшемуся на
странице; токен в cookie — это, по сути, та же сессионная cookie с
дополнительными шагами. JWT предназначены для API, который проект строит
рядом со своей админкой — мобильный клиент, интеграция, сервисный аккаунт —
там, где нет ни хранилища cookie, ни истории с CSRF.
API токенов
Четыре маршрута, подключаемые по адресу auth_url, если вы их запросили:
FastFortSettings(
secret_key=...,
auth={"api_enabled": True},
)
POST /auth/token identity + password → access + refresh
POST /auth/refresh refresh → access + refresh
POST /auth/logout refresh → 204
GET /auth/me Bearer access → who that is
По умолчанию отключены. Любой другой маршрут, который добавляет FastFort, находится за шлюзом админки; эти четыре по необходимости публичны, а добавлять публичные эндпоинты в чужое приложение без явного запроса — не дело библиотеки решать за вас.
Формы запроса и ответа — это формы OAuth 2, поэтому любой сгенерированный
клиент и любой пример curl из другого API работают без переделки:
curl -X POST http://127.0.0.1:8000/auth/token \
-d identity=admin@example.com -d password=...
{
"access_token": "eyJhbGciOi...",
"refresh_token": "eyJhbGciOi...",
"token_type": "Bearer",
"expires_in": 900
}
POST /auth/token выполняет аутентификацию через тот же самый вызов, который
делает собственная форма входа в админку, поэтому применяются счётчики
блокировки, сравнение с защитой от атак по времени и перехеширование при
входе — с одним отличием: он не требует учётной записи сотрудника (staff).
Пользователи API — это клиенты, и отказ в аутентификации только потому, что
человек не администратор, отказал бы в обычном случае. Установите
auth={"api_requires_staff": True} для API, который обслуживает только тех
же людей, что и админка. is_active требуется в любом случае.
Защита собственных маршрутов
Та часть, которая пригодится, даже если четыре маршрута выше вам не нужны:
from typing import Annotated, Any
from fastapi import Depends
from fastfort.auth import bearer_user
current_user = bearer_user(fort)
@app.get("/orders")
async def orders(user: Annotated[Any, Depends(current_user)]) -> list[Order]:
return await orders_for(user)
Маршрут получает вашу собственную строку пользователя, а не словарь claims — маршруту, которому пришлось бы самому искать эту строку, выдали лишь половину ответа. По пути туда проверяется подпись, проверяется, что токен — это именно access-токен, а не refresh, и отклоняется пользователь, который с тех пор был деактивирован. Эта последняя проверка — разница между токеном, истекающим через пятнадцать минут, и закрытой учётной записью, которая продолжает работать все эти пятнадцать минут.
Персональные токены доступа
Описанный выше поток JWT подходит клиенту, за которым стоит человек. Он не той формы для cron-задачи, скрипта развёртывания или партнёрской интеграции: у них нет пароля, который можно ввести, нет способа обновить токен в три часа ночи и нет никого, кто заметил бы неудачное обновление. Им нужна одна долгоживущая строка, отзываемая сама по себе.
Таблица ваша — так работает каждая возможность FastFort, которой нужна таблица:
from fastfort.orm.sqlalchemy import ApiTokenMixin
class ApiToken(ApiTokenMixin, Base):
__tablename__ = "admin_api_token"
fort.enable_api_tokens(ApiToken)
Столбцы проверяются тут же, поэтому опечатка в схеме — ошибка на старте, а не 500 на первом же аутентифицированном запросе.
Как выпустить
Из админки, зарегистрировав модель:
from fastfort.admin import ApiTokenAdmin
fort.register(ApiToken, ApiTokenAdmin)
Кнопка Добавить превращается в страницу выпуска — секрет нужно сгенерировать, а не набрать. Страница показывает токен ровно один раз и не может показать его снова: она отрисовывается, а не перенаправляет, поэтому у показа нет URL, к которому можно вернуться, а обновление страницы возвращает форму выпуска.
Или кодом, что и нужно скрипту начального заполнения:
issued = await fort.api_tokens.issue(
user=user,
name="nightly-export",
scopes="orders:read", # определяете вы; FastFort не судит
expires_at=None, # None — токен не истекает
)
print(issued.secret) # единственный момент, когда он читаем
Как использовать
from fastfort.auth import token_user
machine = token_user(fort)
@app.get("/orders")
async def orders(user: Annotated[Any, Depends(machine)]) -> list[Order]:
return await orders_for(user)
Отдельная зависимость от bearer_user, а не одна, принимающая оба варианта.
JWT — это сессия, за которой стоит человек, а токен — постоянное разрешение для
машины; маршрут, принимающий и то и другое, должен сказать это, завися от
обоих, а не унаследовать это от помощника, который тихо расширился.
Что хранится
Не секрет. В строке остаются дайджест SHA-256 и восьмисимвольный префикс, и префикс существует ровно для того, чтобы вы могли сопоставить строку в админке со строкой в своём конфигурационном файле — при этом самой строки недостаточно, чтобы аутентифицироваться.
Дайджест — SHA-256, а не Argon2, и это осознанно. Фактор сложности существует,
чтобы сделать подбор дорогим, а подбор угрожает только угадываемому секрету —
человеческому паролю из небольшого перекошенного распределения. Здесь же 32
байта из secrets.token_urlsafe: 256 бит равномерной энтропии, и никакого
словаря. Фактору сложности нечего замедлять, а его наличие стоило бы каждому
аутентифицированному запросу тех самых ~100 мс, на которые настроен Argon2.
Отказы
Неизвестный, отозванный, истёкший или принадлежащий удалённой либо деактивированной учётной записи — все четыре дают один и тот же ответ. Различать их означало бы сообщать держателю строки, какой из четырёх случаев перед ним.
В отличие от записи о входе, токен не должен переживать свою учётную запись, поэтому при каждом запросе владелец разыскивается заново. Отзыв сохраняет строку, потому что строка всё ещё отвечает, чем это было и когда перестало работать; удаление говорит, что этого не должно было существовать вовсе.
Реакция на то, что делает админка
fort.hooks — это способ отреагировать на запись, ничего не наследуя:
from fastfort.core.hooks import Hook
@fort.hooks.on(Hook.AFTER_CREATE)
async def announce(*, request, model_key, obj, **_):
await queue.publish("created", model_key, obj.id)
Событие испускает каждый путь записи, которым владеет админка: создание и изменение через форму, одиночное и массовое удаление, восстановление, редактирование ячеек на месте, массовое редактирование и импорт.
Момент срабатывания — это то, подо что и пишется слушатель.
BEFORE_* выполняется внутри транзакции, непосредственно перед записью,
поэтому слушатель, бросивший исключение, прерывает её — именно это и делает
возможным вето:
@fort.hooks.on(Hook.BEFORE_DELETE)
async def protect(*, obj, **_):
if obj.is_locked:
raise ValidationError("Locked records cannot be deleted.")
AFTER_* выполняется после фиксации транзакции, поэтому AFTER_CREATE
означает, что строка долговечна, а не просто сброшена во flush. Слушатель,
отправляющий письмо или событие в очередь, никогда не сработает для изменения,
которое затем откатилось, — но это же означает, что исключение в AFTER_* уже
не отменит запись и всплывёт как ошибка запроса, который базу изменил. Ошибки
слушателей распространяются намеренно: хук, падающий молча, хуже отсутствия
хука, потому что вызывающая сторона считает работу выполненной.
Массовый путь пишет много строк в одной транзакции, поэтому в пакете каждый
BEFORE_* предшествует каждому AFTER_*, а не чередуется с ним. Иначе одна
строка объявлялась бы долговечной, пока более поздняя строка той же транзакции
всё ещё могла бы её забрать.
Две детали, которые стоит знать: мягкое удаление испускает пару delete, а не
update — по механике это запись в один столбец, а по намерению удаление. И
BEFORE_CREATE несёт очищенные значения, а не экземпляр, потому что экземпляра
ещё нет.
Ротация и что означает повторное воспроизведение (replay)
auth={
"rotate_refresh_tokens": True,
"revoke_family_on_reuse": True,
"algorithm": "HS256", # or HS384/512, RS256, EdDSA
}
Каждое обновление выпускает новый токен и аннулирует предъявленный, поэтому украденный токен обновления полезен ровно до тех пор, пока настоящий клиент не выполнит обновление сам — и тогда один из двух предъявит токен, который уже был использован.
Именно это повторное использование и есть детектирование. Отличить его от атакующего, использующего украденный токен параллельно с настоящим клиентом, невозможно, поэтому безопасная трактовка — считать, что это был именно он, и отозвать всё семейство целиком: каждый токен, произошедший от одного и того же входа. Легитимный клиент выходит из системы и должен пройти аутентификацию заново. Это реальная цена, и она намного меньше, чем атакующий, удерживающий действующую сессию.
Именно поэтому стороне обновления нужно хранилище, а стороне доступа — нет. Токен доступа проверяется исключительно по подписи — без обращения куда-либо, без разделяемого состояния между воркерами — и он короткоживущий именно для того, чтобы невозможность его отозвать не имела значения дольше короткого времени.
RS256 и EdDSA существуют для развёртываний, где токены проверяются в
другом сервисе, которому для этого нужен только публичный ключ:
fort.configure_tokens(
signing_key=PRIVATE_KEY_PEM,
verifying_key=PUBLIC_KEY_PEM,
)
Секретный ключ подписывает cookie и не является асимметричным ключом,
поэтому запрос RS256 без пары ключей вызывает исключение уже при
конструировании, а не при выпуске первого токена.
Ограничение частоты запросов
Включено по умолчанию и ограничено собственными путями админки — фреймворк, который начинает отказывать в запросах приложению, в которое он был подключён, вышел за рамки своей задачи.
rate_limit={
"read_per_minute": 600, # GET: lists, forms, the dashboard
"write_per_minute": 120, # everything that writes
"login_per_minute": 10, # POST to sign-in, right password or wrong
}
Три бюджета вместо одного, потому что три вещи, которые обслуживает админка,
стоят совершенно разных ресурсов. Страница списка — это запрос; сохранение —
это транзакция; вход — это хеш Argon2, который медленный намеренно, и
именно это делает форму входа самым дешёвым для атаки местом на любом сайте.
Хеш, настроенный на 100 мс, означает, что одиннадцать неаутентифицированных
запросов в секунду насыщают одно ядро, а отправитель при этом не тратит
ничего. Поэтому бюджет входа самый жёсткий, и он списывается в middleware
до того, как выполнится обработчик: отклонённый запрос вообще никогда не
доходит до хеширования. Когда подключён API токенов, POST /auth/token
списывается с того же бюджета — это тот же самый хеш за другой дверью.
Значения по умолчанию намеренно мягкие. Бюджет считается на адрес, а адрес — это не человек: целый офис за одним NAT — это один адрес, и лимит, рассчитанный на действия одного человека, ограничил бы уже пятого коллегу, открывающего список. Десять чтений в секунду устойчиво — это намного больше, чем делает целая комната кликающих людей, и намного меньше, чем скрейпер, который не ждёт собственных ответов.
Отклонённый запрос — это 429 с заголовками Retry-After,
X-RateLimit-Limit и Cache-Control: no-store. Статические ресурсы никогда
не учитываются — одна страница подгружает сразу несколько таких файлов, и их
учёт превратил бы один просмотр страницы в шесть.
Та же история, что и с хранилищем токенов обновления при нескольких воркерах — четыре процесса, каждый из которых держит четверть счётчиков, в сумме дают вчетверо больший лимит:
fort.set_rate_limit_store(RedisRateLimitStore(...))
За прокси
Бюджет списывается с адреса клиента, а за обратным прокси сокет принадлежит прокси — поэтому без явного указания лимитер спишет весь ваш трафик в одну корзину.
security={"forwarded_depth": 1} # one Caddy/nginx in front; 2 with a CDN too
Заголовок X-Forwarded-For пишет тот, кто отправил запрос, поэтому читать
его нужно справа: слева стоит то, что выбрал отправитель, а каждый прокси
дописывает значение после него. Отсчёт справа — единственный способ чтения,
который атакующий не может удлинить. Ноль — значение по умолчанию — полностью
игнорирует заголовок и использует адрес сокета, который всегда честен и
неверен лишь в том, что принадлежит прокси, а не клиенту.
Опасное направление ошибки — установить значение слишком высоким: тогда читается адрес, который написал сам клиент, и каждый запрос может выдавать себя за нового клиента со свежим бюджетом и свежим счётчиком блокировки. Именно поэтому блокировка и лимитер используют одну и ту же функцию.
Шлюз
Каждый маршрут админки находится за одной зависимостью уровня роутера, а не за проверкой внутри каждого отдельного представления. Нет ни одного представления, которое могло бы её забыть.
public пропускает ровно три вещи: статические ресурсы, страницу входа и
переключатель языка.
Отсутствующая запись — это всегда 404, никогда не 403. Подтверждение того, что запись существует, тому, кому она, возможно, не разрешена к просмотру, — это утечка, поэтому оба ответа сделаны неотличимыми друг от друга.
CSRF
security={
"csrf_enabled": True,
"csrf_header_name": "X-CSRF-Token",
"csrf_field_name": "_csrf",
}
В каждой форме. Подписывается тем же secret_key, поэтому его ротация
одновременно делает недействительными сессии, CSRF-токены и JWT.
Cookie
security={
"cookie_name": "fastfort_session",
"cookie_secure": True,
"cookie_httponly": True,
"cookie_samesite": "lax",
}
Значения по умолчанию — самые строгие. Ослабление любого из них требует
явного изменения, которое будет видно в код-ревью, — и в любом случае об этом
сообщит check --deploy.
cookie_samesite="none" требует cookie_secure=True; валидатор настроек
прямо отклоняет такое сочетание, а не пропускает его в продакшн.
CSP
Начинается с default-src 'none' и script-src 'self', без nonce.
Это единственное решение определяет большую часть формы фронтенда:
- Нет ни одного встроенного
<script>— нигде. - Нет встроенных обработчиков событий.
- Нет CDN. Данные попадают в браузер через атрибуты
data-, которые считывает скрипт.
Ровно две директивы проект может расширить, и каждую — ровно на один источник:
| Директива | Расширяется через |
|---|---|
img-src | ui.map_tile_url |
script-src | ui.richtext_url |
Обе они реализованы как настройки, а не как точки расширения в шаблонах, именно для того, чтобы расширение политики было изменением, которое кто-то проверяет.
Рядом с ней: X-Frame-Options, X-Content-Type-Options, Referrer-Policy, а
также HSTS, если задан security.hsts_seconds.
security={"security_headers": True, "hsts_seconds": 31_536_000}
Сжатие и что намеренно не сжимается
Для статических ресурсов согласуется Brotli → gzip → identity, каждое кодирование кешируется в рамках процесса. HTML намеренно исключён.
Страница содержит одновременно CSRF-токен и текст, выбираемый запросом, а
это ровно паттерн атаки BREACH: сжатие их вместе позволяет атакующему,
который может влиять на текст, вычислить токен по размеру ответа. Не
пытайтесь «исправить» это добавлением GZipMiddleware.
Загруженные файлы
Отдаются через админку, за тем же шлюзом, что и любое другое представление, — а не через отдельный неаутентифицированный статический маршрут. Загруженный файл — это такая же запись, как и любая другая строка, а не публичный ресурс.
media={"root": "media", "upload_limit": 10_000_000}
Запрос считывает не более одного байта сверх лимита, прежде чем решить, что загрузка окончена, поэтому слишком большой файл становится ошибкой валидации поля, а не оказывается сначала целиком в памяти — какими бы гигабайтами он ни был на самом деле.
Что должна пройти загрузка
Загруженные файлы отдаются обратно с /admin/media/… — это собственный
origin админки, тот самый origin, на который ограничена cookie сессии.
Файл, который браузер удастся убедить выполнить как документ, — это поэтому
не курьёз, а скрипт, исполняющийся от имени того, кто его открыл, с его
сессией. Четыре защиты, потому что любая из них по отдельности оставляет
дыру открытой.
Расширение проверяется по разрешающему списку (allow-list), а также отдельно сверяется с запрещающим списком (deny-list) всего, что сервер или браузер могут выполнить:
media={
"allowed_extensions": {"pdf", "png", "jpg", "csv", "xlsx"},
"allowed_image_extensions": {"png", "jpg", "jpeg", "webp"},
}
Можно указывать как с ведущей точкой, так и без неё, в любом регистре. Поле
изображения получает более узкий список, потому что photo в значении
«картинка» и photo в значении «zip-архив» — это разные обещания для того,
кто смотрит на колонку с миниатюрами.
Опасное расширение в середине имени перестаёт быть опасным. hack.exe.png
— это имя, которое некоторые серверы читают слева направо и передают
интерпретатору — классическая ошибка AddHandler, — поэтому оно сохраняется
как hack_exe.png, и .exe в нём больше нет. Файл нейтрализуется, а не
отклоняется: отклонение любого имени со «спрятанным» внутри расширением
отклонило бы и example.com.pdf, а правило, выдающее необъяснимую ошибку на
обычном файле, — это правило, которое люди начинают обходить. archive.tar.gz
остаётся нетронутым, потому что tar — это не то, что кто-либо исполняет.
Байты сверяются с именем. Расширение — это лишь заявление того, кто
загрузил файл. Считываются ведущие magic-числа, и .png, содержимое которого
на деле — ELF-бинарник, shell-скрипт или HTML-документ, отклоняется с явным
указанием несовпадения — ведь интересна как раз та атака, где заявленное и
фактическое расходятся.
Ничто не отдаётся на веру. Content-Type ответа определяется по
хранящимся байтам, а не по хранящемуся имени, и всё, что не опознано
однозначно как растровое изображение, отдаётся как text/plain и как
скачивание. Это покрывает и файлы, записанные до появления всех описанных
выше механизмов, и файлы, помещённые в медиа-каталог чем-то помимо FastFort.
Записи о входе
Кто вошёл, откуда и с какого устройства. Один вызов, работающий с таблицей, которой владеет ваш проект:
from fastfort.orm.sqlalchemy import SignInRecordMixin
class SignInRecord(SignInRecordMixin, Base):
__tablename__ = "admin_sign_in"
fort.record_sign_ins(SignInRecord)
Каждая попытка становится строкой: когда, какой идентификатор был введён, успешно ли, адрес, браузер, платформа, тип устройства и исходная строка user-agent.
FastFort не поставляет миграций и не создаёт таблиц, поэтому модель — ваша:
миксин лишь предоставляет колонки, а проект, которому нужно меньше, объявляет
меньше. Обязательны только at, successful и address, и колонки
проверяются при вызове record_sign_ins, поэтому ошибка в схеме — это ошибка
при старте, а не сюрприз в три часа ночи.
Стоит знать о четырёх решениях, потому что каждое из них — противоположность очевидной реализации.
Сначала client hints, user-agent — во вторую очередь. Браузеры на
Chromium без всяких запросов отправляют Sec-CH-UA, Sec-CH-UA-Platform и
Sec-CH-UA-Mobile, и это структурированные поля; user-agent же — это фраза,
обраставшая подробностями с 1994 года. Исходная строка всё равно сохраняется
рядом с распознанным значением, потому что в ней лжёт каждый браузер — Chrome
называет себя Safari, который называет себя Gecko, — и распознанное значение
может быть неверным. Ничто в FastFort не принимает решений на основании этих
данных: user-agent пишет тот, кто отправил запрос, поэтому это свидетельство
для чтения человеком, а не вход для принятия решения.
Неудачи тоже записываются, если вы не передали failures=False. Журнал
одних лишь успешных входов ничего не расскажет о ночи, когда кто-то перебрал
четыреста паролей.
Внешнего ключа на таблицу пользователей нет. Учётная запись хранится как текст — идентификатор в том виде, в каком он был введён, и первичный ключ в виде строки. Запись аудита, которая каскадно удаляется вместе с учётной записью, — это не запись аудита, а «кто удалил эту учётную запись и откуда» — это как раз то, для ответа на что эти записи и существуют.
Запись строки не может привести к отказу входа. Она выполняется в
собственной транзакции, а ошибка логируется через fastfort.auth, а не
выбрасывается: отсутствующая или переполненная таблица аудита не должна
получить возможность заблокировать доступ тем, кто мог бы это исправить.
Где определяется адрес
fort.record_sign_ins(SignInRecord, locate=my_geoip_lookup)
FastFort не поставляется с базой GeoIP и не обращается ни к какому сервису.
Первое означало бы сотню мегабайт, устаревающих внутри wheel-пакета; второе
означало бы передачу адреса каждого администратора третьей стороне прямо
изнутри обработчика входа. Если у вас есть своя база, locate — это
вызываемый объект, принимающий адрес и возвращающий строку, а callback,
выбросивший исключение, стоит вам лишь местоположения и ничего больше.
Чтение записей
SignInRecord — это обычная модель, поэтому зарегистрируйте её как любую
другую и получите фильтруемую, доступную для поиска и экспорта страницу
бесплатно. kind — это фиксированный словарь значений: desktop, mobile,
tablet, bot, unknown — так что это может быть и фильтром, и
Breakdown, а не колонкой, по которой никто не может
сгруппировать данные.
Что администратор может сделать с учётной записью
Четыре настройки — для тех развёртываний, где «администратор может всё» является неверным значением по умолчанию: публичное демо, общая песочница, staging-копия продакшен-данных:
FastFortSettings(
auth={
"allow_password_change": False,
"allow_superuser_password_change": False,
"allow_user_delete": False,
"allow_superuser_delete": False,
},
)
| Настройка | По умолчанию | Что она блокирует |
|---|---|---|
allow_password_change | True | Изменение пароля любой учётной записи через админку |
allow_superuser_password_change | True | Изменение пароля суперпользователя кем-то другим |
allow_user_delete | True | Удаление любой учётной записи |
allow_superuser_delete | True | Удаление суперпользователя |
Они применяются только к модели пользователя и ни к чему больше, и одинаково ко всем администраторам — включая того, кто их написал. Ничто из этого не является системой прав доступа. Правила, различающиеся для разных людей, — это авторизация, а она ваша собственная и подключается к шлюзу.
Защищённое поле пароля отображается только для чтения и исключается из записи, потому что элемент управления «только для чтения», который тем не менее принимает отправленное значение, — это ярлык, а не защита. Защищённой учётной записи отказывают и на странице подтверждения, и повторно в момент нажатия, поскольку та страница могла быть открыта уже час назад. Массовое удаление сохраняет защищённые строки, удаляет остальные и сообщает, сколько было сохранено.
Суперпользователь всегда может сменить собственный пароль: блокировка людей от их собственных учётных данных — это уже другая функциональность.
В публичном демо используйте allow_password_change, а не вариант для
суперпользователя. Демо, которое входит под одной и той же учётной записью
для каждого посетителя, делает каждого из них этой учётной записью, а более
узкая настройка намеренно продолжает разрешать смену собственного пароля.
Перед развёртыванием
uv run fastfort check --app main:fort --deploy
Либо сделайте так, чтобы старт приложения отказывал:
settings.require_production_ready()