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