Задача 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>
This commit is contained in:
1 parent
c852f47f09
commit
d3495fb077
18 files changed
+835
-56
No files matched your search
@@ -0,0 +1,126 @@
|
||||
# Двухфакторная аутентификация 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 в приложении-аутентификаторе, вход с кодом, вход кодом восстановления,
|
||||
отключение, сброс суперадминистратором.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Итог: двухфакторная аутентификация 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).
|
||||
Reference in new issue
Block a user