# Итог: двухфакторная аутентификация TOTP по выбору пользователя (изменение 037) План: `PLAN.md`. Пользователь сам включает второй фактор: приложение-аутентификатор (TOTP), настройка по QR. Выдаются 10 одноразовых кодов восстановления; superadmin может сбросить 2FA другому пользователю. Без 2FA вход прежний. ## Что сделано | Где | Изменение | |---|---| | `app/models.py`, `alembic/versions/0015_user_totp.py` | `users.totp_secret_enc`, `totp_pending_enc`, `totp_enabled_at`, `totp_last_step`; таблица `recovery_codes` (sha256, `ON DELETE CASCADE`) | | `app/totp.py` (новый) | Fernet-шифрование секрета (`TOTP_ENC_KEY`), генерация секрета и `otpauth://`, QR в SVG на сервере (segno, data URI), проверка кода ±1 шаг со строгим ростом шага (защита от повторного использования), коды восстановления | | `app/config.py`, `docker-compose.yml`, `scripts/gen_env.py`, `.env.example` | `TOTP_ENC_KEY`: некорректный ключ — отказ старта; без ключа приложение стартует, 2FA недоступна (503) | | `app/security.py` | `mfa_token` (`typ="mfa"`, 5 мин); `current_user` отклоняет токены с `typ` | | `app/api/v1/auth.py` | Вход в два шага: `POST /auth/login` → `mfa_required` + `mfa_token`; `POST /auth/login/2fa` — TOTP или код восстановления под теми же advisory-lock и лимитами, что пароль. Общие хелперы `_lock_pair`, `_record_failure`, `_complete_login` | | `app/api/v1/users.py` | `POST /users/me/2fa/setup` (пароль), `/enable` (код → 10 кодов восстановления), `/disable` (пароль + код, с лимитами перебора), `POST /users/{id}/2fa/reset` (superadmin, не себе). Журнал: `user.2fa_enabled/disabled/reset`, `session.2fa_failed` | | `app/schemas.py` | `TokenOut` (`mfa_required`, `mfa_token`), `UserOut` (`totp_enabled`, `totp_enabled_at`), схемы 2FA | | `web/app.js` | Второй шаг входа; пункт «Двухфакторная аутентификация» в меню логина (включение с QR, коды восстановления с копированием, отключение); бейдж `2FA` и «Сбросить 2FA» в «Пользователях»; `submitDialog` не закрывает окно, открытое следующим шагом цепочки | | `requirements.txt`, `requirements.lock` | `pyotp`, `segno`, `cryptography` | | `tests/test_users.py` | `test_totp_flow`: setup/enable, вход с кодом, повтор кода → 401, код восстановления однократен, сброс superadmin | | `README.md` | Вход, конфигурация `TOTP_ENC_KEY`, API, миграции `0001`–`0015`, строка 037 | ## Найдено и исправлено в ходе цикла - **Ревью — повторное использование кода:** `login_2fa` читал пользователя до advisory-lock; два параллельных запроса с одним кодом проходили оба. Исправлено `db.refresh(user)` после блокировки; на стенде из двух одновременных запросов — 200 и 401. - **Ревью — перебор кода при отключении:** `/users/me/2fa/disable` не ограничивал попытки кода (угроза при украденных токене и пароле). Подключены лимиты входа; на стенде 4-я неверная попытка — 429. - **Ручная проверка UI — обрыв настройки:** `submitDialog` безусловно закрывал окно после шага, закрывая уже открытое следующее окно цепочки (после «Далее» диалог исчезал; на шаге подтверждения закрылось бы окно с кодами восстановления). Исправлено: окно закрывается, только если обработчик не подменил `S.dialog`. Урок: у UI нет автотестов, и ревью было сосредоточено на серверной части — цепочки диалогов проверяются только вручную. ## Проверки - `pytest -q` — 16 passed. Миграция 0015 применена, `alembic check` чисто, откат до 0014 и повторный upgrade — без ошибок. - На стенде: секрет в БД зашифрован (открытого base32 нет); `mfa_token` на `/auth/me` → 401; параллельный повтор кода отклонён; лимит перебора кода при отключении срабатывает; события 2FA в журнале. Без ключа — старт разрешён, с неверным ключом — отказ. - Ручная проверка UI — пользователь. ## Эксплуатация - `TOTP_ENC_KEY` стенда сгенерирован и записан в `.env`. Ключ нужно хранить в резервной копии: его потеря = сброс 2FA всем пользователям. - Пересборка стенда — с `--force-recreate` (см. изменение 036).