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

127 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Двухфакторная аутентификация 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 в приложении-аутентификаторе, вход с кодом, вход кодом восстановления,
отключение, сброс суперадминистратором.