Files
ipam_control/docs/changes/037-totp-2fa/PLAN.md
T
ayurishchevandClaude Opus 5.5 d3495fb077 Задача 037: двухфакторная аутентификация TOTP по выбору пользователя
Пользователь сам включает 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>
2026-09-27 16:57:14 +03:00

12 KiB
Raw Blame History

Двухфакторная аутентификация 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) с issuer IPAM 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 в приложении-аутентификаторе, вход с кодом, вход кодом восстановления, отключение, сброс суперадминистратором.