Пользователь сам включает TOTP (QR, приложение-аутентификатор); 10 одноразовых кодов восстановления; superadmin сбрасывает 2FA другому пользователю. Вход в два шага: /auth/login → mfa_token, /auth/login/2fa — под теми же лимитами и advisory-lock, что пароль. Секрет шифруется Fernet-ключом TOTP_ENC_KEY, защита от повторного использования кода (totp_last_step), лимиты перебора кода при отключении. Миграция 0015, зависимости pyotp, segno, cryptography. UI: второй шаг входа, диалоги 2FA в меню логина, бейдж и сброс в «Пользователях»; submitDialog не закрывает окно, открытое следующим шагом цепочки. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
12 KiB
12 KiB
Двухфакторная аутентификация TOTP, по выбору пользователя (изменение 037)
Context
Стенд опубликован в интернет (изменение 034), и вход защищён только паролем и лимитами перебора. Нужен второй фактор, который пользователь включает сам.
Решения пользователя:
- Метод — TOTP: Google Authenticator, Aegis, 1Password, Bitwarden; настройка по QR-коду.
- Восстановление: 10 одноразовых кодов, выдаются при включении; superadmin может сбросить 2FA другому пользователю.
- Хранение: секрет в БД шифруется отдельным ключом
TOTP_ENC_KEY(Fernet).
Обязательность 2FA не вводится: у кого 2FA выключена, вход прежний.
Модель и миграция 0015_user_totp.py
- Таблица
users, новые колонки (nullable):totp_secret_enc(Text) — зашифрованный подтверждённый секрет;totp_pending_enc(Text) — секрет на время настройки, до подтверждения кодом;totp_enabled_at(timestamptz) — признак включения;totp_last_step(BigInteger) — защита от повторного использования кода: шаг TOTP должен быть строго больше последнего принятого.
- Новая таблица
recovery_codes:id,user_id(FK →users.id,ON DELETE CASCADE, индекс),code_hash(sha256 hex),used_at(timestamptz null). sha256, а не argon2: коды высокоэнтропийные (10 символов base32), медленный хэш не нужен. UserOut:totp_enabled: bool(вычисляемое,totp_enabled_at is not None).
Конфигурация и зависимости
app/config.py:totp_enc_key: str = "".validate_secrets(): если задан — проверить, что это корректный ключ Fernet (fail-fast). Если не задан — приложение стартует, 2FA недоступна:setup→ 503 «2FA не настроена на сервере (TOTP_ENC_KEY)»;- вход пользователя с включённой 2FA → 503 (не пропускать без второго фактора).
docker-compose.yml:TOTP_ENC_KEY: ${TOTP_ENC_KEY:-}в окруженииapp.scripts/gen_env.py: генерироватьTOTP_ENC_KEY(Fernet.generate_key()илиbase64.urlsafe_b64encode(os.urandom(32)))..env.example: строка с пояснением — потеря ключа означает сброс 2FA всем.requirements.txt:pyotp,segno(QR в SVG на сервере, чистый Python),cryptography. Обновитьrequirements.lock:venv/bin/pip-compile --strip-extras -o requirements.lock requirements.txt, команда из README.- Новый модуль
app/totp.py:encrypt/decrypt(Fernet);new_secret();provisioning_uri(username)с issuerIPAM Manager;qr_svg_data_uri(uri)через segno:data:image/svg+xml;base64,…, CSP уже разрешаетimg-src data:;verify(user, code)—pyotp.TOTP.verifyс окном ±1 шаг и проверкойtotp_last_step, возвращает принятый шаг;new_recovery_codes()— 10 кодов видаXXXXX-XXXXXи их хэши;use_recovery_code(db, user, code)— нормализация (без дефиса, верхний регистр) и однократное погашение.
Вход — app/api/v1/auth.py
POST /auth/login: после успешной проверки пароля, еслиuser.totp_enabled_at:- вместо токена вернуть
{"mfa_required": true, "mfa_token": …}; mfa_token— JWT сtyp="mfa",sub,pv, сроком 5 минут;session.loginв журнал пока не пишется,last_login_*не обновляются, попытки входа не сбрасываются.TokenOutрасширить:access_token: str | None,mfa_required: bool = False,mfa_token: str | None.
- вместо токена вернуть
POST /auth/login/2fa{mfa_token, code}:- токен разбирается и проверяется:
typ == "mfa", пользователь активен,pvсовпадает; - те же advisory-lock и лимиты, что у пароля (
_retry_after,LoginAttempt); неверный код считается неудачной попыткой; code— 6 цифр TOTP либо код восстановления;- успех: общая с паролем завершающая часть — вынести её из
login()в хелпер_complete_login(очистка попыток,_remember_login,last_login_*,session.login,create_token). При входе кодом восстановления вdiff—{"recovery_code": true}и остаток кодов; - неудача:
session.2fa_failedв журнал (только первая в окне — какsession.failed), 401 «Неверный код».
- токен разбирается и проверяется:
app/security.py::current_user: отклонять токены сtyp(401) —mfa_tokenне должен работать как токен доступа.
Управление 2FA — app/api/v1/users.py
Свои действия — любой роли, в стиле POST /users/me/password:
POST /users/me/2fa/setup{password}— только если 2FA выключена. Новый секрет кладётся вtotp_pending_enc, ответ:{secret, otpauth_uri, qr}(qr — data URI SVG).POST /users/me/2fa/enable{code}— проверка кода по pending-секрету; перенос вtotp_secret_enc,totp_enabled_at = now(),totp_last_step; выпуск 10 кодов. Ответ:{recovery_codes: [...]}— показываются один раз. Журналuser.2fa_enabled.POST /users/me/2fa/disable{password, code}—codeможет быть TOTP или кодом восстановления. Очистить поля и удалить коды. Журналuser.2fa_disabled.POST /users/{id}/2fa/reset— толькоsuperadmin, не для своей записи (409 — свою отключают через/me/2fa/disable). Очистить поля и коды. Журналuser.2fa_reset.- Журнал: все события без
organization_id(системные, видитsuperadmin), тексты — явнымmessage. - Схемы в
app/schemas.py:MfaLoginIn,TotpSetupIn,TotpSetupOut,TotpCodeIn,TotpDisableIn,RecoveryCodesOut. - Ошибки: неверный пароль → 403, как в
/users/me/password; неверный код → 422; 2FA уже включена или выключена → 409.
UI — web/app.js
- Вход. Если ответ
mfa_required, показать второй шаг: поле «Код из приложения или код восстановления» (autocomplete="one-time-code",inputmodeне ограничивать — у кода восстановления есть буквы).mfa_tokenхранить в памяти. «Назад» возвращает к паролю. Ошибка истёкшего токена → возврат к паролю с сообщением. - Меню логина (изменение 036): пункт «Двухфакторная аутентификация» между «Сменить пароль» и «Выйти» → диалог.
- 2FA выключена: «Включить» → пароль → QR, секрет текстом для ручного ввода и поле кода → «Подтвердить» → экран с 10 кодами восстановления, моноширинно, кнопка «Скопировать» и предупреждение «сохраните — больше не покажем».
- 2FA включена: статус «включена с <дата>» и «Отключить» (пароль + код).
- Диалоги — через существующие
openDialog/formBody/fInput/dlgFoot, по образцуpasswordDialog.
- Раздел «Пользователи»: бейдж
2FAв колонке статуса у пользователей с включённой 2FA. В меню строки уsuperadmin— «Сбросить 2FA» (с подтверждением) для чужих записей с включённой 2FA. auth/meотдаётtotp_enabled— диалог знает текущее состояние.
Тесты (минимально)
Один сквозной тест tests/test_users.py::test_totp_flow, pyotp вычисляет коды:
- создать пользователя и войти;
setup→enable(получить 10 кодов);- вход паролем →
mfa_required;mfa_tokenна/auth/me→ 401; /auth/login/2faс кодом → токен; повтор того же кода → 401 (повторное использование);- вход кодом восстановления → успех, повтор того же кода → 401;
resetсуперадминистратором → вход снова только паролем;- в
finallyудалить пользователя.
pyotp добавить в requirements-dev.txt, если он не подтягивает requirements.txt.
Документация
docs/changes/037-totp-2fa/PLAN.md— этот план; по завершении —SUMMARY.md.README.md:- «Безопасность → Вход»: 2FA по желанию, коды восстановления, сброс суперадминистратором;
- конфигурация:
TOTP_ENC_KEY; - API:
/auth/login/2fa,/users/me/2fa/*,/users/{id}/2fa/reset; - миграции
0001–0015; - строка 037 в истории.
Исполнение
По принятой схеме: код, миграцию, UI, зависимости и тест пишет агент на Sonnet (без запуска тестов и стенда). Моя часть:
- ревью;
TOTP_ENC_KEYв.envстенда;- пересборка с
--force-recreate; - проверки и SUMMARY.
Проверка
- Пересборка:
docker compose -p ipam_control_006 up -d --build --force-recreate app.alembic current=0015,alembic checkчисто, откат до 0014 и повторный upgrade — без ошибок; стенд отдаёт новыйapp.js. - Старт без
TOTP_ENC_KEY→ приложение работает,setup→ 503. С некорректным ключом → отказ старта. pytest -q— все зелёные, включаяtest_totp_flow.- API вручную:
mfa_tokenне работает как токен доступа;- лимит перебора кода срабатывает (429 после порогов);
- в БД
totp_secret_encне содержит base32-секрет открытым текстом; - события
user.2fa_*иsession.2fa_failedесть в журнале.
- Ручная проверка в браузере (пользователь): включение по QR в приложении-аутентификаторе, вход с кодом, вход кодом восстановления, отключение, сброс суперадминистратором.