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