From d3495fb0774bedcf8bdbd4c6adeecdd30f62ba1f Mon Sep 17 00:00:00 2001 From: ayurishchev Date: Sun, 27 Sep 2026 16:57:14 +0300 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=B4=D0=B0=D1=87=D0=B0=20037:=20?= =?UTF-8?q?=D0=B4=D0=B2=D1=83=D1=85=D1=84=D0=B0=D0=BA=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=BD=D0=B0=D1=8F=20=D0=B0=D1=83=D1=82=D0=B5=D0=BD=D1=82=D0=B8?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D1=8F=20TOTP=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=D1=83=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пользователь сам включает 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 --- .env.example | 3 + README.md | 18 +++- alembic/versions/0015_user_totp.py | 38 +++++++ app/api/v1/auth.py | 122 ++++++++++++++++------ app/api/v1/users.py | 105 ++++++++++++++++++- app/config.py | 8 ++ app/models.py | 19 ++++ app/schemas.py | 36 ++++++- app/security.py | 25 +++++ app/totp.py | 110 ++++++++++++++++++++ docker-compose.yml | 1 + docs/changes/037-totp-2fa/PLAN.md | 126 +++++++++++++++++++++++ docs/changes/037-totp-2fa/SUMMARY.md | 39 +++++++ requirements.lock | 10 +- requirements.txt | 3 + scripts/gen_env.py | 4 + tests/test_users.py | 78 ++++++++++++++ web/app.js | 146 ++++++++++++++++++++++++--- 18 files changed, 835 insertions(+), 56 deletions(-) create mode 100644 alembic/versions/0015_user_totp.py create mode 100644 app/totp.py create mode 100644 docs/changes/037-totp-2fa/PLAN.md create mode 100644 docs/changes/037-totp-2fa/SUMMARY.md diff --git a/.env.example b/.env.example index 185f06f..cc86f65 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,6 @@ TRUSTED_PROXIES= # Публикация через общий Caddy на этом же хосте (изменение 034): Caddy ходит на опубликованный порт и приходит # с адреса шлюза Docker-моста — доверять диапазону мостов # TRUSTED_PROXIES=172.16.0.0/12 +# Ключ шифрования секретов TOTP (изменение 037), Fernet: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +# Не задан — приложение стартует, но 2FA недоступна (503). Потеря ключа означает сброс 2FA всем пользователям. +TOTP_ENC_KEY= diff --git a/README.md b/README.md index 92fff52..684f19d 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,13 @@ venv/bin/python scripts/seed_demo.py # по желанию: демо-да | `APP_BIND` | Адрес публикации порта: по умолчанию `0.0.0.0`; `127.0.0.1` — только за reverse-proxy | | `DB_HOST_PORT` | Порт PostgreSQL на хосте, публикуется только на `127.0.0.1` | | `TRUSTED_PROXIES` | CIDR доверенных прокси через запятую; только от них принимается `X-Forwarded-For` | +| `TOTP_ENC_KEY` | Ключ Fernet для шифрования секретов 2FA (изменение 037). Не задан — приложение стартует, но 2FA недоступна (503); некорректный ключ — отказ старта; потеря ключа означает сброс 2FA всем пользователям | ## Архитектура | Слой | Технологии | |---|---| | API | FastAPI, pydantic v2, JWT (срок 8 ч), пароли в argon2, роли `superadmin`, `admin`, `viewer` с привязкой к организации | -| БД | PostgreSQL 16, SQLAlchemy 2, Alembic (миграции `0001`–`0014`), типы `CIDR`/`INET` | +| БД | PostgreSQL 16, SQLAlchemy 2, Alembic (миграции `0001`–`0015`), типы `CIDR`/`INET` | | UI | Статический SPA (vanilla JS, ES-модуль) раздаётся приложением; шрифты IBM Plex хранятся локально, внешних зависимостей нет | ``` @@ -78,11 +79,11 @@ docs/changes/ планы и итоги доработок docs/reviews/ ## API (`/api/v1`) | Область | Эндпоинты | |---|---| -| Вход | `POST /auth/login`, `GET /auth/me` | +| Вход | `POST /auth/login`, `POST /auth/login/2fa` (изменение 037), `GET /auth/me` | | Справочники | `/organizations`, `/vrfs`, `/isps`, `/device-types`, `/devices` | | Префиксы | `/prefixes`, `GET\|POST /prefixes/{id}/subnets/next` (предпросмотр и автовыделение вложенного) | | Адреса | `GET\|POST /prefixes/{id}/addresses`, `POST /prefixes/{id}/addresses/next` (автоназначение из пула), `PATCH\|DELETE /addresses/{id}` | -| Пользователи | `/users`, `POST /users/me/password` | +| Пользователи | `/users`, `POST /users/me/password`, `POST /users/me/2fa/setup\|enable\|disable`, `POST /users/{id}/2fa/reset` (изменение 037) | | Журнал | `GET /audit`, `/audit/summary`, `/audit/facets`, `/audit/{uid}`, `GET\|PUT /journal/settings`, `POST /journal/clear` | | Сводка | `GET /overview` | @@ -112,6 +113,13 @@ docs/changes/ планы и итоги доработок docs/reviews/ - 20 — на IP; - 50 — на логин со всех IP, кроме тех, с которых пользователь успешно входил за 30 дней. - Сверх лимита — 429 с `Retry-After`. +- Двухфакторная аутентификация TOTP — по желанию пользователя (изменение 037), не обязательна. Включается в меню логина + в шапке: пароль → QR-код (Google Authenticator, Aegis, 1Password, Bitwarden и подобные) → код подтверждения → + 10 одноразовых кодов восстановления (показываются один раз). При включённой 2FA вход идёт в два шага: + `POST /auth/login` возвращает `mfa_token` вместо токена доступа, `POST /auth/login/2fa` принимает код из приложения + или код восстановления. Неверный код учитывается в тех же лимитах перебора, что и пароль. Секрет хранится в БД + зашифрованным ключом `TOTP_ENC_KEY`; коды восстановления — хэшем sha256. Суперадминистратор может сбросить 2FA + другому пользователю (`POST /users/{id}/2fa/reset`), не отключая свою. **Журнал** - Хранит IP клиента и метаданные запроса. @@ -141,7 +149,8 @@ docs/changes/ планы и итоги доработок docs/reviews/ ## Интерфейс - Экраны: «Обзор», «Префиксы» (дерево по VRF), «Адреса» подсети, «Организации», «Операторы», «Устройства», «Журнал», «Пользователи» (только `superadmin`). - В «Пользователях» видна дата и IP последнего входа (изменение 035); «—», если пользователь ещё не входил. -- Логин в шапке раскрывает меню «Сменить пароль» / «Выйти» (изменение 036). +- Логин в шапке раскрывает меню «Сменить пароль» / «Двухфакторная аутентификация» / «Выйти» (изменения 036, 037). +- В «Пользователях» у записей с включённой 2FA — бейдж «2FA»; в меню строки суперадминистратора для чужих записей — «Сбросить 2FA» (изменение 037). - Переключатель организации — только у `superadmin`; `admin`/`viewer` работают в своей организации. - Строка реестра кликабельна целиком. Действия над строкой — в меню «⋯». - Групповые операции через чекбоксы (кроме «Журнала»): удаление, смена типа устройств, статус префиксов и адресов, доступ пользователей. @@ -193,6 +202,7 @@ docker compose -p ipam_control_006 up -d --build && venv/bin/python -m pytest -q | 034 | Публикация через Caddy: rxipam.rxmsk.ru | [план](docs/changes/034-caddy-publication/PLAN.md) · [итог](docs/changes/034-caddy-publication/SUMMARY.md) | | 035 | Последний вход пользователя: дата и IP | [план](docs/changes/035-user-last-login/PLAN.md) · [итог](docs/changes/035-user-last-login/SUMMARY.md) | | 036 | Меню пользователя в шапке: логин с выпадающим списком | [план](docs/changes/036-user-menu/PLAN.md) · [итог](docs/changes/036-user-menu/SUMMARY.md) | +| 037 | Двухфакторная аутентификация TOTP, по выбору пользователя | [план](docs/changes/037-totp-2fa/PLAN.md) · [итог](docs/changes/037-totp-2fa/SUMMARY.md) | ## Отчёты ревью - [Ревью кодовой базы](docs/reviews/2026-09-26-codebase-review.md) (находки → изменения 011–023) diff --git a/alembic/versions/0015_user_totp.py b/alembic/versions/0015_user_totp.py new file mode 100644 index 0000000..f4f3691 --- /dev/null +++ b/alembic/versions/0015_user_totp.py @@ -0,0 +1,38 @@ +"""Двухфакторная аутентификация TOTP, по выбору пользователя (изменение 037) + +Revision ID: 0015 +Revises: 0014 +""" +from alembic import op +import sqlalchemy as sa + + +revision = "0015" +down_revision = "0014" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("totp_secret_enc", sa.Text(), nullable=True)) + op.add_column("users", sa.Column("totp_pending_enc", sa.Text(), nullable=True)) + op.add_column("users", sa.Column("totp_enabled_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("users", sa.Column("totp_last_step", sa.BigInteger(), nullable=True)) + + op.create_table( + "recovery_codes", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("code_hash", sa.String(64), nullable=False), + sa.Column("used_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index("ix_recovery_codes_user_id", "recovery_codes", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_recovery_codes_user_id", table_name="recovery_codes") + op.drop_table("recovery_codes") + op.drop_column("users", "totp_last_step") + op.drop_column("users", "totp_enabled_at") + op.drop_column("users", "totp_pending_enc") + op.drop_column("users", "totp_secret_enc") diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index 8d46c54..9a4fa69 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -9,11 +9,13 @@ from sqlalchemy import and_, delete, func, select from sqlalchemy.dialects.postgresql import insert from sqlalchemy.orm import Session +from app import totp +from app.config import settings from app.db import get_db from app.models import KnownLogin, LoginAttempt, User from app.request_context import request_meta -from app.schemas import LoginIn, TokenOut, UserOut -from app.security import create_token, current_user, hash_password, verify_password +from app.schemas import LoginIn, MfaLoginIn, TokenOut, UserOut +from app.security import create_mfa_token, create_token, current_user, decode_mfa_token, hash_password, verify_password from app.services import ANONYMOUS, KNOWN_IP_DAYS, LOGIN_WINDOW, MAX_PER_IP, MAX_PER_LOGIN, MAX_PER_LOGIN_IP, audit router = APIRouter(prefix="/auth", tags=["auth"]) @@ -80,19 +82,57 @@ def _locked(retry: int) -> HTTPException: headers={"Retry-After": str(retry)}) +def _lock_pair(db: Session, name: str, ip: str | None) -> None: + """Сериализация попыток одного логина (изменение 024, находка №1), затем — этого IP (изменение 027, находка №7): + порядок всегда «логин, затем IP» исключает взаимную блокировку; без второй блокировки параллельный перебор + разных логинов с одного IP проходит проверку лимита по IP одновременно, и лимит превышается на степень + параллелизма — ценой служит то, что попытки с одного IP (в том числе за NAT) обрабатываются по одной, + и при массовом входе время ответа растёт на время проверки argon2/TOTP.""" + db.execute(select(func.pg_advisory_xact_lock(LOGIN_LOCK_NS, func.hashtext(name)))) + if ip is not None: + db.execute(select(func.pg_advisory_xact_lock(IP_LOCK_NS, func.hashtext(ip)))) + + +def _record_failure(db: Session, name: str, ip: str | None, known: bool, label: str, action: str, message: str) -> int: + """Общий учёт неудачной попытки (пароль или второй фактор, изменение 037): та же запись в LoginAttempt и + тот же выбор события в журнал (`session.locked`, если попытка впервые пересекла лимит; иначе `session.`, + только первая неудача по этому логину в окне). Возвращает секунды до конца блокировки (0 — не заблокирован).""" + _, _, before = _retry_after(db, name, ip, known) + db.add(LoginAttempt(client_ip=ip, username=name)) + db.flush() + retry, why, after = _retry_after(db, name, ip, known) + if retry and before[why] < after[why]: # именно этот запрос впервые пересёк лимит — запись пишем один раз + attempts = after[why] + diff = {"scope": why, "attempts": attempts, "retry_after_seconds": retry} + if why == "ip": + diff["distinct_logins"] = _distinct_logins(db, ip) + scope_ru = {"login_ip": "по логину и IP", "ip": "с IP", "login": "по логину"}[why] + audit(db, ANONYMOUS, "session", None, "locked", label, diff, + message=f"Вход заблокирован на {max(1, -(-retry // 60))} мин.: {attempts} неудачных попыток ({scope_ru})") + elif before["login"] == 0: # в журнал — только первая неудача по этому логину в окне (не по IP: иначе перебор логинов с одного IP её не оставит) + audit(db, ANONYMOUS, "session", None, action, label, message=message) + db.commit() + return retry + + +def _complete_login(db: Session, user: User, ip: str | None, name: str, diff: dict | None = None) -> TokenOut: + """Общее завершение успешного входа — паролем (без 2FA) или вторым фактором (изменение 037): очистка попыток, + отметка известного IP, `last_login_*`, запись `session.login` в журнал, выдача токена доступа.""" + db.execute(delete(LoginAttempt).where(LoginAttempt.username == name)) + _remember_login(db, name, ip) + user.last_login_at = func.now() # изменение 035 + user.last_login_ip = ip # изменение 035 + audit(db, user, "session", None, "login", user.username, diff, message=f"Вход в UI: {user.username}") + db.commit() + return TokenOut(access_token=create_token(user)) + + @router.post("/login", response_model=TokenOut) def login(body: LoginIn, db: Session = Depends(get_db)): ctx = request_meta.get() ip = ctx["client_ip"] if ctx else None name = body.username.strip().lower() - # сериализация попыток одного логина (изменение 024, находка №1), затем — этого IP (изменение 027, находка №7): - # порядок всегда «логин, затем IP» исключает взаимную блокировку; без второй блокировки параллельный перебор - # разных логинов с одного IP проходит проверку лимита по IP одновременно, и лимит превышается на степень - # параллелизма — ценой служит то, что попытки с одного IP (в том числе за NAT) обрабатываются по одной, - # и при массовом входе время ответа растёт на время проверки argon2. - db.execute(select(func.pg_advisory_xact_lock(LOGIN_LOCK_NS, func.hashtext(name)))) - if ip is not None: - db.execute(select(func.pg_advisory_xact_lock(IP_LOCK_NS, func.hashtext(ip)))) + _lock_pair(db, name, ip) known = _is_known(db, name, ip) retry, _, _ = _retry_after(db, name, ip, known) if retry: # блокировка: пароль не проверяем, в журнал не пишем (запись о блокировке уже есть) @@ -101,31 +141,49 @@ def login(body: LoginIn, db: Session = Depends(get_db)): if user is None: verify_password(body.password, _DUMMY_HASH) if user is None or not verify_password(body.password, user.password_hash): - _, _, before = _retry_after(db, name, ip, known) - db.add(LoginAttempt(client_ip=ip, username=name)) - db.flush() - retry, why, after = _retry_after(db, name, ip, known) - if retry and before[why] < after[why]: # именно этот запрос впервые пересёк лимит — запись пишем один раз - attempts = after[why] - diff = {"scope": why, "attempts": attempts, "retry_after_seconds": retry} - if why == "ip": - diff["distinct_logins"] = _distinct_logins(db, ip) - scope_ru = {"login_ip": "по логину и IP", "ip": "с IP", "login": "по логину"}[why] - audit(db, ANONYMOUS, "session", None, "locked", body.username[:100], diff, - message=f"Вход заблокирован на {max(1, -(-retry // 60))} мин.: {attempts} неудачных попыток ({scope_ru})") - elif before["login"] == 0: # в журнал — только первая неудача по этому логину в окне (не по IP: иначе перебор логинов с одного IP её не оставит) - audit(db, ANONYMOUS, "session", None, "failed", body.username[:100], message="Неудачная попытка входа в UI") - db.commit() + retry = _record_failure(db, name, ip, known, body.username[:100], "failed", "Неудачная попытка входа в UI") if retry: raise _locked(retry) raise HTTPException(401, "Неверный логин или пароль") - db.execute(delete(LoginAttempt).where(LoginAttempt.username == name)) - _remember_login(db, name, ip) - user.last_login_at = func.now() # изменение 035 - user.last_login_ip = ip # изменение 035 - audit(db, user, "session", None, "login", user.username, message=f"Вход в UI: {user.username}") - db.commit() - return TokenOut(access_token=create_token(user)) + if user.totp_enabled: # изменение 037: второй фактор — токен доступа не выдаётся, попытки/last_login_* не трогаем + if not settings.totp_enc_key: + raise HTTPException(503, "2FA не настроена на сервере (TOTP_ENC_KEY)") + return TokenOut(mfa_required=True, mfa_token=create_mfa_token(user)) + return _complete_login(db, user, ip, name) + + +@router.post("/login/2fa", response_model=TokenOut) +def login_2fa(body: MfaLoginIn, db: Session = Depends(get_db)): + """Второй шаг входа (изменение 037): код TOTP либо код восстановления. Те же лимиты и advisory-lock, что у пароля — + неверный код тоже считается неудачной попыткой входа этого логина.""" + ctx = request_meta.get() + ip = ctx["client_ip"] if ctx else None + user = decode_mfa_token(body.mfa_token, db) + name = user.username.lower() + _lock_pair(db, name, ip) + # изменение 037: user был загружен ДО advisory-lock — под конкурентным запросом с тем же кодом это устаревший + # снимок (totp_last_step/totp_enabled ещё не видят чужой commit). После получения блокировки перечитываем + # пользователя из БД, иначе два параллельных запроса с одинаковым кодом оба могут пройти проверку (гонка). + db.refresh(user) + known = _is_known(db, name, ip) + retry, _, _ = _retry_after(db, name, ip, known) + if retry: + raise _locked(retry) + if not user.totp_enabled: # 2FA отключена между шагами входа — токен больше не действителен + raise HTTPException(401, "Недействительный токен") + if not settings.totp_enc_key: + raise HTTPException(503, "2FA не настроена на сервере (TOTP_ENC_KEY)") + step = totp.verify(user, body.code) + if step is not None: + user.totp_last_step = step + return _complete_login(db, user, ip, name) + remaining = totp.use_recovery_code(db, user, body.code) + if remaining is not None: + return _complete_login(db, user, ip, name, diff={"recovery_code": True, "recovery_codes_left": remaining}) + retry = _record_failure(db, name, ip, known, user.username, "2fa_failed", "Неверный код 2FA при входе в UI") + if retry: + raise _locked(retry) + raise HTTPException(401, "Неверный код") @router.get("/me", response_model=UserOut) diff --git a/app/api/v1/users.py b/app/api/v1/users.py index d22535a..fbd55f4 100644 --- a/app/api/v1/users.py +++ b/app/api/v1/users.py @@ -8,12 +8,19 @@ from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import func, select +from sqlalchemy import delete, func, select from sqlalchemy.orm import Session from app import schemas as s +from app import totp +# изменение 037: лимиты и advisory-lock входа переиспользуются для /users/me/2fa/disable (иначе код 2FA +# можно перебирать неограниченно, имея украденные пароль и токен доступа) — импорт из app.api.v1.auth, а не +# дублирование в app/services.py: это специфика входа (LoginAttempt/KnownLogin), auth.py не зависит от users.py +from app.api.v1.auth import _is_known, _lock_pair, _locked, _record_failure, _retry_after +from app.config import settings from app.db import get_db -from app.models import Organization, Role, User +from app.models import Organization, RecoveryCode, Role, User +from app.request_context import request_meta from app.security import admin_user, create_token, current_user, hash_password, superadmin_user, verify_password from app.services import MAX_OFFSET, apply_update, audit, commit, contains, count, flush, get_or_404, refuse_delete @@ -122,3 +129,97 @@ def change_own_password(body: s.PasswordChange, db: Session = Depends(get_db), u audit(db, user, "user", user, "password_reset", user.username, message=f"{user.username}: пароль изменён пользователем") commit(db) return {"ok": True, "access_token": create_token(user)} # прежние токены недействительны — текущая сессия получает новый + + +def _clear_totp(user: User) -> None: + """Полный сброс состояния 2FA (изменение 037): используется и при своём отключении, и при сбросе суперадминистратором.""" + user.totp_secret_enc = None + user.totp_pending_enc = None + user.totp_enabled_at = None + user.totp_last_step = None + + +@router.post("/users/me/2fa/setup", response_model=s.TotpSetupOut) +def setup_totp(body: s.TotpSetupIn, db: Session = Depends(get_db), user: User = Depends(current_user)): + """Начало включения 2FA (изменение 037): доступно любой роли, требует текущий пароль (как /users/me/password). + Секрет кладётся в totp_pending_enc — действует до подтверждения кодом в /users/me/2fa/enable.""" + if not settings.totp_enc_key: + raise HTTPException(503, "2FA не настроена на сервере (TOTP_ENC_KEY)") + if user.totp_enabled: + raise HTTPException(409, "Двухфакторная аутентификация уже включена") + if not verify_password(body.password, user.password_hash): + raise HTTPException(403, "Неверный пароль") + secret = totp.new_secret() + user.totp_pending_enc = totp.encrypt(secret) + commit(db) + uri = totp.provisioning_uri(secret, user.username) + return s.TotpSetupOut(secret=secret, otpauth_uri=uri, qr=totp.qr_svg_data_uri(uri)) + + +@router.post("/users/me/2fa/enable", response_model=s.RecoveryCodesOut) +def enable_totp(body: s.TotpCodeIn, db: Session = Depends(get_db), user: User = Depends(current_user)): + """Подтверждение включения 2FA кодом по pending-секрету: перенос в totp_secret_enc, выпуск кодов восстановления + (изменение 037). Коды показываются только в этом ответе — сервер их больше не хранит открытым текстом.""" + if not settings.totp_enc_key: + raise HTTPException(503, "2FA не настроена на сервере (TOTP_ENC_KEY)") + if user.totp_enabled: + raise HTTPException(409, "Двухфакторная аутентификация уже включена") + if not user.totp_pending_enc: + raise HTTPException(409, "Настройка 2FA не начата: сначала POST /users/me/2fa/setup") + step = totp.check_code(totp.decrypt(user.totp_pending_enc), body.code, None) + if step is None: + raise HTTPException(422, "Неверный код") + user.totp_secret_enc = user.totp_pending_enc + user.totp_pending_enc = None + user.totp_enabled_at = datetime.now(timezone.utc) + user.totp_last_step = step + db.execute(delete(RecoveryCode).where(RecoveryCode.user_id == user.id)) # коды прошлой неудавшейся настройки не остаются + codes = totp.new_recovery_codes() + db.add_all(RecoveryCode(user_id=user.id, code_hash=h) for _, h in codes) + audit(db, user, "user", user, "2fa_enabled", user.username, message=f"{user.username}: включена двухфакторная аутентификация") + commit(db) + return s.RecoveryCodesOut(recovery_codes=[c for c, _ in codes]) + + +@router.post("/users/me/2fa/disable") +def disable_totp(body: s.TotpDisableIn, db: Session = Depends(get_db), user: User = Depends(current_user)): + """Отключение 2FA: пароль и код (TOTP либо восстановления). Очищает секреты и все коды восстановления (изменение 037). + Код ограничен теми же лимитами перебора, что и вход (`_retry_after`/`LoginAttempt`, изменение 037): без этого + имеющий пароль и (действующий) токен доступа мог бы перебирать 6-значный TOTP-код неограниченно.""" + if not user.totp_enabled: + raise HTTPException(409, "Двухфакторная аутентификация не включена") + ctx = request_meta.get() + ip = ctx["client_ip"] if ctx else None + name = user.username.lower() + _lock_pair(db, name, ip) + known = _is_known(db, name, ip) + retry, _, _ = _retry_after(db, name, ip, known) + if retry: # блокировка: пароль и код не проверяем, как в /auth/login + raise _locked(retry) + if not verify_password(body.password, user.password_hash): + raise HTTPException(403, "Неверный пароль") # неверный пароль — как в /users/me/password, без учёта попыток + if not settings.totp_enc_key: + raise HTTPException(503, "2FA не настроена на сервере (TOTP_ENC_KEY)") + if totp.verify(user, body.code) is None and totp.use_recovery_code(db, user, body.code) is None: + retry = _record_failure(db, name, ip, known, user.username, "2fa_failed", "Неверный код 2FA при отключении") + if retry: + raise _locked(retry) + raise HTTPException(422, "Неверный код") + _clear_totp(user) + db.execute(delete(RecoveryCode).where(RecoveryCode.user_id == user.id)) + audit(db, user, "user", user, "2fa_disabled", user.username, message=f"{user.username}: отключена двухфакторная аутентификация") + commit(db) + return {"ok": True} + + +@router.post("/users/{id}/2fa/reset") +def reset_totp(id: int, db: Session = Depends(get_db), user: User = Depends(superadmin_user)): + """Сброс 2FA другому пользователю — только superadmin; свою 2FA отключают через /users/me/2fa/disable (изменение 037).""" + if id == user.id: + raise HTTPException(409, "Свою двухфакторную аутентификацию отключают через /users/me/2fa/disable") + u = get_or_404(db, User, id, "Пользователь") + _clear_totp(u) + db.execute(delete(RecoveryCode).where(RecoveryCode.user_id == u.id)) + audit(db, user, "user", u, "2fa_reset", u.username, message=f"Пользователю {u.username}: двухфакторная аутентификация сброшена суперадминистратором {user.username}") + commit(db) + return {"ok": True} diff --git a/app/config.py b/app/config.py index 30c4fa7..28ca23d 100644 --- a/app/config.py +++ b/app/config.py @@ -10,6 +10,7 @@ class Settings(BaseSettings): admin_username: str = "admin" admin_password: str = "" trusted_proxies: str = "" # CIDR через запятую: от них принимается X-Forwarded-For + totp_enc_key: str = "" # изменение 037: ключ Fernet для шифрования секретов TOTP; пусто — 2FA недоступна (503), потеря ключа = сброс 2FA всем settings = Settings() @@ -23,3 +24,10 @@ def validate_secrets(s: Settings = settings) -> None: raise RuntimeError("JWT_SECRET не задан, короче 32 символов или является заглушкой: сгенерируйте значение (python scripts/gen_env.py)") if s.admin_password and len(s.admin_password) < 8: raise RuntimeError("ADMIN_PASSWORD короче 8 символов: пароль слабее, чем допускает UI") + if s.totp_enc_key: # изменение 037: не задан — приложение стартует, 2FA недоступна; задан некорректно — отказ старта + from cryptography.fernet import Fernet + + try: + Fernet(s.totp_enc_key.encode()) + except (ValueError, TypeError) as e: + raise RuntimeError("TOTP_ENC_KEY некорректен: нужен ключ Fernet (python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\")") from e diff --git a/app/models.py b/app/models.py index 70cb288..443d6e8 100644 --- a/app/models.py +++ b/app/models.py @@ -151,6 +151,15 @@ class User(Base): password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # токены с iat раньше — недействительны last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # изменение 035: время последнего успешного входа last_login_ip: Mapped[str | None] = mapped_column(INET) # изменение 035: IP последнего успешного входа + # изменение 037: двухфакторная аутентификация TOTP, по выбору пользователя + totp_secret_enc: Mapped[str | None] = mapped_column(Text) # зашифрованный (Fernet, TOTP_ENC_KEY) подтверждённый секрет + totp_pending_enc: Mapped[str | None] = mapped_column(Text) # секрет на время настройки, до подтверждения кодом + totp_enabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # NULL — 2FA выключена + totp_last_step: Mapped[int | None] = mapped_column(BigInteger) # защита от повторного использования кода: шаг должен строго возрастать + + @property + def totp_enabled(self) -> bool: # изменение 037: вычисляемое поле для UserOut (from_attributes читает его как обычный атрибут) + return self.totp_enabled_at is not None Index("uq_users_lower_username", func.lower(User.username), unique=True) # логин уникален без учёта регистра @@ -207,3 +216,13 @@ class ClearAttempt(Base): id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class RecoveryCode(Base): + """Коды восстановления 2FA (изменение 037): выдаются 10 штук при включении, каждый гасится один раз. + Хэш — sha256 (не argon2): код высокоэнтропийный (10 символов base32), медленный хэш не нужен.""" + __tablename__ = "recovery_codes" + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + code_hash: Mapped[str] = mapped_column(String(64)) + used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) diff --git a/app/schemas.py b/app/schemas.py index 7934f73..411d7fa 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -62,8 +62,11 @@ class LoginIn(BaseModel): class TokenOut(BaseModel): - access_token: str + # изменение 037: при включённой 2FA access_token не выдаётся сразу — вместо него mfa_token на второй шаг (/auth/login/2fa) + access_token: str | None = None token_type: str = "bearer" + mfa_required: bool = False + mfa_token: str | None = None LOGIN_RE = re.compile(r"^[A-Za-z0-9._-]{3,100}$") @@ -90,6 +93,8 @@ class UserOut(ORM): is_active: bool = True last_login_at: datetime | None = None # изменение 035 last_login_ip: str | None = None # изменение 035 + totp_enabled: bool = False # изменение 037: вычисляемое — User.totp_enabled (property), totp_enabled_at is not None + totp_enabled_at: datetime | None = None # изменение 037: для статуса «включена с <дата>» в UI @field_validator("last_login_ip", mode="before") @classmethod @@ -123,6 +128,35 @@ class UserUpdate(BaseModel): # инвариант не проверить — реконсиляция по итоговому состоянию в update_user (находка №3) +# --- 2FA (изменение 037) +class MfaLoginIn(BaseModel): + mfa_token: str + code: str = Field(max_length=20) # 6 цифр TOTP либо код восстановления вида XXXXX-XXXXX + + +class TotpSetupIn(BaseModel): + password: str = Field(max_length=128) + + +class TotpSetupOut(BaseModel): + secret: str + otpauth_uri: str + qr: str # data:image/svg+xml;base64,… + + +class TotpCodeIn(BaseModel): + code: str = Field(max_length=20) + + +class TotpDisableIn(BaseModel): + password: str = Field(max_length=128) + code: str = Field(max_length=20) + + +class RecoveryCodesOut(BaseModel): + recovery_codes: list[str] + + class PasswordChange(BaseModel): current_password: str new_password: str = Field(min_length=8, max_length=128) diff --git a/app/security.py b/app/security.py index 5bb96e8..ba526d1 100644 --- a/app/security.py +++ b/app/security.py @@ -44,6 +44,13 @@ def create_token(user: User) -> str: return jwt.encode({"sub": user.username, "iat": now, "exp": exp, "pv": _password_version(user)}, settings.jwt_secret, algorithm="HS256") +def create_mfa_token(user: User) -> str: + """Промежуточный токен второго шага входа (изменение 037): typ="mfa", короткий срок жизни, не работает как токен доступа.""" + now = datetime.now(timezone.utc) + exp = now + timedelta(minutes=5) + return jwt.encode({"sub": user.username, "iat": now, "exp": exp, "pv": _password_version(user), "typ": "mfa"}, settings.jwt_secret, algorithm="HS256") + + def current_user( cred: HTTPAuthorizationCredentials | None = Depends(_bearer), db: Session = Depends(get_db) ) -> User: @@ -54,6 +61,8 @@ def current_user( username = payload["sub"] except (jwt.PyJWTError, KeyError): raise HTTPException(401, "Недействительный токен") + if payload.get("typ"): # изменение 037: mfa_token (typ="mfa") не работает как токен доступа; токены доступа typ не имеют + raise HTTPException(401, "Недействительный токен") user = db.scalar(select(User).where(User.username == username, User.is_active)) if user is None: raise HTTPException(401, "Пользователь не найден") @@ -62,6 +71,22 @@ def current_user( return user +def decode_mfa_token(token: str, db: Session) -> User: + """Разбор и проверка mfa_token второго шага входа (изменение 037): typ == "mfa", пользователь активен, + версия пароля совпадает (иначе токен выдан до смены пароля — второй шаг не должен переживать смену пароля).""" + try: + payload = jwt.decode(token, settings.jwt_secret, algorithms=["HS256"]) + username = payload["sub"] + except (jwt.PyJWTError, KeyError): + raise HTTPException(401, "Недействительный или истёкший токен, войдите заново") + if payload.get("typ") != "mfa": + raise HTTPException(401, "Недействительный токен") + user = db.scalar(select(User).where(User.username == username, User.is_active)) + if user is None or payload.get("pv", 0) != _password_version(user): + raise HTTPException(401, "Недействительный или истёкший токен, войдите заново") + return user + + def admin_user(user: User = Depends(current_user)) -> User: # изменение 032: admin_user проверяет роль != viewer (пропускает admin И superadmin) if user.role == Role.viewer: diff --git a/app/totp.py b/app/totp.py new file mode 100644 index 0000000..734dad3 --- /dev/null +++ b/app/totp.py @@ -0,0 +1,110 @@ +"""Двухфакторная аутентификация TOTP, по выбору пользователя (изменение 037). + +Секрет хранится в БД зашифрованным (Fernet, ключ `TOTP_ENC_KEY`) — читаемый дамп БД не даёт готового секрета +для генератора кодов. Коды восстановления хранятся хэшем sha256 (не argon2: код высокоэнтропийный, +10 символов base32 — медленный хэш не даёт защиты, которая была бы нужна для короткого пользовательского пароля). +""" +import base64 +import hashlib +import io +import secrets +import time + +import pyotp +import segno +from cryptography.fernet import Fernet, InvalidToken +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.config import settings +from app.models import RecoveryCode, User + +ISSUER = "IPAM Manager" +STEP_SECONDS = 30 # шаг TOTP по умолчанию (RFC 6238), используется и pyotp +RECOVERY_CODES_COUNT = 10 +_RECOVERY_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" # base32-алфавит без спутываемых символов (0/O, 1/I и т.п. base32 не использует) + + +def _fernet() -> Fernet: + if not settings.totp_enc_key: + raise RuntimeError("TOTP_ENC_KEY не задан") + return Fernet(settings.totp_enc_key.encode()) + + +def encrypt(secret: str) -> str: + return _fernet().encrypt(secret.encode()).decode() + + +def decrypt(token: str) -> str: + try: + return _fernet().decrypt(token.encode()).decode() + except InvalidToken as e: # смена/потеря TOTP_ENC_KEY после включения 2FA — секрет не читается + raise RuntimeError("TOTP_ENC_KEY не подходит к сохранённому секрету") from e + + +def new_secret() -> str: + return pyotp.random_base32() + + +def provisioning_uri(secret: str, username: str) -> str: + return pyotp.TOTP(secret).provisioning_uri(name=username, issuer_name=ISSUER) + + +def qr_svg_data_uri(uri: str) -> str: + """QR в SVG, отрисованный на сервере (без внешних вызовов); отдаётся как data: URI — CSP уже разрешает img-src data:.""" + buf = io.BytesIO() + segno.make(uri, error="m").save(buf, kind="svg", scale=4, border=2, dark="#111111", light="#ffffff", xmldecl=False) + return "data:image/svg+xml;base64," + base64.b64encode(buf.getvalue()).decode() + + +def check_code(secret: str, code: str, last_step: int | None) -> int | None: + """Проверка TOTP-кода по конкретному секрету с окном ±1 шаг (изменение 037). Шаг принятого кода должен быть + строго больше `last_step` (повторное использование в том же/более раннем шаге отклоняется). Возвращает + принятый шаг (для сохранения в totp_last_step) или None, если код не подошёл.""" + code = code.strip().replace(" ", "") + if not code.isdigit(): + return None + totp = pyotp.TOTP(secret, interval=STEP_SECONDS) + now_step = int(time.time()) // STEP_SECONDS + for step in (now_step, now_step - 1, now_step + 1): + if last_step is not None and step <= last_step: + continue + if secrets.compare_digest(totp.generate_otp(step), code): + return step + return None + + +def verify(user: User, code: str) -> int | None: + """Проверка кода по подтверждённому (включённому) секрету пользователя.""" + if not user.totp_secret_enc: + return None + return check_code(decrypt(user.totp_secret_enc), code, user.totp_last_step) + + +def new_recovery_codes() -> list[tuple[str, str]]: + """10 кодов восстановления вида XXXXX-XXXXX и их хэши sha256 (hex) — для сохранения в БД.""" + out = [] + for _ in range(RECOVERY_CODES_COUNT): + raw = "".join(secrets.choice(_RECOVERY_ALPHABET) for _ in range(10)) + code = f"{raw[:5]}-{raw[5:]}" + out.append((code, hashlib.sha256(raw.encode()).hexdigest())) + return out + + +def _normalize_recovery(code: str) -> str: + return code.strip().upper().replace("-", "").replace(" ", "") + + +def use_recovery_code(db: Session, user: User, code: str) -> int | None: + """Погашает код восстановления, если он подходит и ещё не использован. Возвращает остаток неиспользованных + кодов (может быть 0) или None, если код неверен/уже использован.""" + norm = _normalize_recovery(code) + if not norm: + return None + h = hashlib.sha256(norm.encode()).hexdigest() + row = db.scalar(select(RecoveryCode).where(RecoveryCode.user_id == user.id, RecoveryCode.code_hash == h, RecoveryCode.used_at.is_(None))) + if row is None: + return None + row.used_at = func.now() + db.flush() + return db.scalar(select(func.count()).select_from(RecoveryCode).where(RecoveryCode.user_id == user.id, RecoveryCode.used_at.is_(None))) or 0 diff --git a/docker-compose.yml b/docker-compose.yml index 769eece..82e14b7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,7 @@ services: ADMIN_USERNAME: ${ADMIN_USERNAME} ADMIN_PASSWORD: ${ADMIN_PASSWORD} TRUSTED_PROXIES: ${TRUSTED_PROXIES:-} + TOTP_ENC_KEY: ${TOTP_ENC_KEY:-} # APP_BIND: 0.0.0.0 — доступ из LAN (по умолчанию, как раньше); 127.0.0.1 — только через reverse-proxy с TLS (см. README «Публикация») ports: ["${APP_BIND:-0.0.0.0}:${APP_PORT}:8000"] healthcheck: diff --git a/docs/changes/037-totp-2fa/PLAN.md b/docs/changes/037-totp-2fa/PLAN.md new file mode 100644 index 0000000..18ac186 --- /dev/null +++ b/docs/changes/037-totp-2fa/PLAN.md @@ -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 в приложении-аутентификаторе, вход с кодом, вход кодом восстановления, + отключение, сброс суперадминистратором. diff --git a/docs/changes/037-totp-2fa/SUMMARY.md b/docs/changes/037-totp-2fa/SUMMARY.md new file mode 100644 index 0000000..90b2aa1 --- /dev/null +++ b/docs/changes/037-totp-2fa/SUMMARY.md @@ -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). diff --git a/requirements.lock b/requirements.lock index 04e8fe1..0ca9a71 100644 --- a/requirements.lock +++ b/requirements.lock @@ -19,9 +19,13 @@ argon2-cffi==25.1.0 argon2-cffi-bindings==26.1.0 # via argon2-cffi cffi==2.1.1 - # via argon2-cffi-bindings + # via + # argon2-cffi-bindings + # cryptography click==8.5.0 # via uvicorn +cryptography==50.0.1 + # via -r requirements.txt dnspython==2.8.0 # via email-validator email-validator==2.3.0 @@ -58,12 +62,16 @@ pydantic-settings==2.15.0 # via -r requirements.txt pyjwt==2.15.0 # via -r requirements.txt +pyotp==2.10.0 + # via -r requirements.txt python-dotenv==1.2.3 # via # pydantic-settings # uvicorn pyyaml==6.0.3 # via uvicorn +segno==1.6.6 + # via -r requirements.txt sqlalchemy==2.0.54 # via # -r requirements.txt diff --git a/requirements.txt b/requirements.txt index fffbfb2..cf7283b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,6 @@ pydantic-settings>=2.4 email-validator>=2.2 pyjwt>=2.9 argon2-cffi>=23.1 +pyotp>=2.9 # изменение 037: TOTP-коды двухфакторной аутентификации +segno>=1.6 # изменение 037: QR-код в SVG на сервере (чистый Python, без внешних вызовов) +cryptography>=43 # изменение 037: шифрование секрета TOTP (Fernet) diff --git a/scripts/gen_env.py b/scripts/gen_env.py index e88efd9..b1a8cde 100644 --- a/scripts/gen_env.py +++ b/scripts/gen_env.py @@ -1,4 +1,6 @@ """Генерирует .env со случайными учётными данными (не перезаписывает существующий).""" +import base64 +import os import pathlib import secrets @@ -15,6 +17,8 @@ else: f"ADMIN_PASSWORD={secrets.token_urlsafe(18)}\n" "APP_PORT=8088\n" "DB_HOST_PORT=55432\n" + # изменение 037: ключ шифрования секретов TOTP (Fernet). Потеря ключа означает сброс 2FA всем пользователям. + f"TOTP_ENC_KEY={base64.urlsafe_b64encode(os.urandom(32)).decode()}\n" ) env.chmod(0o600) print(f"создан {env}") diff --git a/tests/test_users.py b/tests/test_users.py index 007463e..dd0156a 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -1,7 +1,9 @@ """Управление пользователями: один сквозной сценарий (создание → права → отключение → пароли → удаление).""" +import time import uuid import httpx +import pyotp from tests.conftest import BASE @@ -86,3 +88,79 @@ def test_users_management(client, org): other.close() if uid is not None: client.delete(f"/users/{uid}") + + +def test_totp_flow(client, org): + """Двухфакторная аутентификация TOTP (изменение 037): включение, вход в два шага, коды восстановления, сброс. + Лимит перебора — 5 неудач за 10 минут на пару логин+IP (изменение 026): сценарий укладывается в 2 неудачи + (повтор TOTP-кода, повтор кода восстановления).""" + name, uid, other = f"qa-totp-{uuid.uuid4().hex[:8]}", None, None + try: + created = client.post("/users", json={"username": name, "password": "start-pass-123", "role": "viewer", "organization_id": org["id"]}) + assert created.status_code == 201, created.text + uid = created.json()["id"] + + other = _client(name, "start-pass-123") + assert other is not None + assert other.get("/auth/me").json()["totp_enabled"] is False + + setup = other.post("/users/me/2fa/setup", json={"password": "start-pass-123"}) + assert setup.status_code == 200, setup.text + secret, qr = setup.json()["secret"], setup.json()["qr"] + assert qr.startswith("data:image/svg+xml;base64,") + + gen = pyotp.TOTP(secret) + enabled = other.post("/users/me/2fa/enable", json={"code": gen.now()}) + assert enabled.status_code == 200, enabled.text + codes = enabled.json()["recovery_codes"] + assert len(codes) == 10 + assert other.get("/auth/me").json()["totp_enabled"] is True + + # вход паролем теперь останавливается на первом факторе + anon = httpx.Client(base_url=BASE, timeout=30) + step1 = anon.post("/auth/login", json={"username": name, "password": "start-pass-123"}) + assert step1.status_code == 200 + body = step1.json() + assert body["mfa_required"] is True and body["access_token"] is None and body["mfa_token"] + mfa_token = body["mfa_token"] + + # mfa_token не работает как токен доступа + probe = httpx.Client(base_url=BASE, timeout=30, headers={"Authorization": "Bearer " + mfa_token}) + assert probe.get("/auth/me").status_code == 401 + + # код, использованный в enable(), уже погашен (totp_last_step) — дожидаемся следующего шага TOTP (30 с), + # чтобы не столкнуться с этим же шагом при первом входе + time.sleep(30 - (time.time() % 30) + 2) + code = gen.now() + ok = anon.post("/auth/login/2fa", json={"mfa_token": mfa_token, "code": code}) + assert ok.status_code == 200, ok.text + assert ok.json()["access_token"] + + # повторное использование того же кода отклоняется (mfa_token остаётся действителен 5 минут — используем его же) + replay = anon.post("/auth/login/2fa", json={"mfa_token": mfa_token, "code": code}) + assert replay.status_code == 401 + + # вход кодом восстановления + rc = codes[0] + rec = anon.post("/auth/login/2fa", json={"mfa_token": mfa_token, "code": rc}) + assert rec.status_code == 200, rec.text + assert rec.json()["access_token"] + + # повтор того же кода восстановления отклоняется + rec2 = anon.post("/auth/login/2fa", json={"mfa_token": mfa_token, "code": rc}) + assert rec2.status_code == 401 + anon.close() + probe.close() + + # сброс суперадминистратором — вход снова только паролем + reset = client.post(f"/users/{uid}/2fa/reset") + assert reset.status_code == 200, reset.text + other.close() + other = _client(name, "start-pass-123") + assert other is not None + assert other.get("/auth/me").json()["totp_enabled"] is False + finally: + if other is not None: + other.close() + if uid is not None: + client.delete(f"/users/{uid}") diff --git a/web/app.js b/web/app.js index 0f2637c..01b45ff 100644 --- a/web/app.js +++ b/web/app.js @@ -41,6 +41,7 @@ let user = null; let orgs = []; let S = {}; // локальное состояние текущего экрана let menu = null; // открытое всплывающее меню {id, items:[{label,value,cls}], on} +let pendingMfa = null; // изменение 037: {mfa_token} между первым и вторым шагом входа — только в памяти, не в sessionStorage /* --------------------------------------------------------------------- api */ class ApiError extends Error { @@ -60,7 +61,7 @@ async function api(path, { method = "GET", body, params } = {}) { }); if (res.status === 204) return null; const data = await res.json().catch(() => null); - if (res.status === 401 && path !== "/auth/login") { + if (res.status === 401 && path !== "/auth/login" && path !== "/auth/login/2fa") { // изменение 037: 401 здесь — истёкший/неверный mfa_token, обрабатывается на месте store.token = null; location.hash = "#/login"; throw new ApiError(401, { message: "Сессия истекла" }); @@ -133,7 +134,8 @@ const NAV = [["overview", "Обзор"], ["prefixes", "Префиксы"], ["org function shell(active, content) { const nav = NAV.filter(([, , role]) => role !== "superadmin" || isSuperadmin()); // изменение 032: раздел «Пользователи» виден только суперадминистратору // изменение 036: логин в шапке — триггер выпадающего меню («Сменить пароль» / «Выйти») вместо отдельных кнопок - const userMenu = `${popMenu("user", [{ label: "Сменить пароль", value: "pw" }, { label: "Выйти", value: "logout" }], "row-pop")}`; + // изменение 037: пункт «Двухфакторная аутентификация» между «Сменить пароль» и «Выйти» + const userMenu = `${popMenu("user", [{ label: "Сменить пароль", value: "pw" }, { label: "Двухфакторная аутентификация", value: "2fa" }, { label: "Выйти", value: "logout" }], "row-pop")}`; return `
ipam_manager
${userMenu}
@@ -184,7 +186,9 @@ async function submitDialog() { const h = S.dialog; try { await h(readForm()); - closeDialog(); + // изменение 037: обработчик мог сам открыть следующее окно диалоговой цепочки (сменив S.dialog) — тогда + // закрывать нечего, иначе только что открытое окно закрылось бы сразу вслед за собой + if (S.dialog === h) closeDialog(); } catch (e) { if (e instanceof ApiError) showFormError(e); else throw e; } @@ -707,6 +711,8 @@ screens.users = async () => { const items2 = [ { label: "Редактировать", value: "edit:" + u.id }, { label: u.is_active ? "Отключить доступ" : "Разрешить доступ", value: "toggle:" + u.id, cls: u.is_active ? "danger" : "" }, + // изменение 037: сброс 2FA — только у чужих записей с включённой 2FA + ...(!self && u.totp_enabled ? [{ label: "Сбросить 2FA", value: "totp-reset:" + u.id }] : []), { label: "Удалить", value: "del:" + u.id, cls: "danger" }, ]; const orgName = orgs.find((o) => o.id === u.organization_id)?.name || "—"; // изменение 032 @@ -714,7 +720,7 @@ screens.users = async () => { ${esc(u.username)}${self ? ` это вы` : ""} ${badge(u.role === "superadmin" ? "purple" : u.role === "admin" ? "blue" : "", ROLE_RU[u.role] || u.role)} ${u.role === "superadmin" ? "—" : esc(orgName)} -${u.is_active ? badge("green", "Доступ разрешён") : badge("red", "Отключён")} +${u.is_active ? badge("green", "Доступ разрешён") : badge("red", "Отключён")}${u.totp_enabled ? " " + badge("blue", "2FA") : ""} ${u.last_login_at ? `${fmtDate(u.last_login_at)}${esc(u.last_login_ip || "—")}` : '—'} ${self ? "свои роль и доступ менять нельзя" : ""} ${iconBtn(I.dots(), "menu", "Действия", `data-menu="user-row-${u.id}"`)}${popMenu("user-row-" + u.id, items2, "row-pop")}`; @@ -772,8 +778,82 @@ function passwordDialog() { }); } +// ---- 2FA (изменение 037): управление своей двухфакторной аутентификацией из меню логина в шапке +function totpDialog() { + return user.totp_enabled ? totpStatusDialog() : totpSetupPasswordDialog(); +} +function totpStatusDialog() { + openDialog({ + title: "Двухфакторная аутентификация", + body: `
Включена с ${esc(fmtDate(user.totp_enabled_at))}
`, + foot: `
${btn("Закрыть", "close-dialog", { cls: "ghost" })}${btn("Отключить", "totp-disable-start", { cls: "danger" })}
`, + }); +} +function totpSetupPasswordDialog() { + S.dialog = async (v) => { + const r = await api("/users/me/2fa/setup", { method: "POST", body: { password: v.password } }); + closeDialog(); + totpConfirmDialog(r); + }; + openDialog({ + title: "Включить двухфакторную аутентификацию", + note: "Понадобится приложение-аутентификатор (Google Authenticator, Aegis, 1Password, Bitwarden и подобные).", + body: formBody(`${fInput("password", "Текущий пароль", { type: "password" })}`), + foot: dlgFoot("Далее"), + }); +} +function totpConfirmDialog(setup) { + S.dialog = async (v) => { + const r = await api("/users/me/2fa/enable", { method: "POST", body: { code: v.code } }); + closeDialog(); + user = await api("/auth/me"); + totpRecoveryCodesDialog(r.recovery_codes); + }; + openDialog({ + title: "Сканируйте QR-код", + width: 460, + note: "Отсканируйте код в приложении-аутентификаторе или введите секрет вручную, затем введите код из приложения.", + body: formBody(`
QR-код
+ +${fInput("code", "Код из приложения", { ph: "000000" })}`), + foot: dlgFoot("Подтвердить"), + }); +} +function totpRecoveryCodesDialog(codes) { + S.dialog = null; + const text = codes.join("\n"); + openDialog({ + title: "Коды восстановления", width: 460, + note: "Сохраните эти коды в надёжном месте — сервер больше не покажет их. Каждый код можно использовать один раз, вместо кода из приложения.", + body: `
${esc(text)}
`, + foot: `
${btn("Скопировать", "copy", { data: `data-text="${esc(text)}"` })}${btn("Готово", "close-dialog", { cls: "primary" })}
`, + }); +} +function totpDisableStartDialog() { + closeDialog(); + S.dialog = async (v) => { + await api("/users/me/2fa/disable", { method: "POST", body: { password: v.password, code: v.code } }); + closeDialog(); + user = await api("/auth/me"); + toast("Двухфакторная аутентификация отключена"); + }; + openDialog({ + title: "Отключить двухфакторную аутентификацию", + body: formBody(`${fInput("password", "Текущий пароль", { type: "password" })} +${fInput("code", "Код из приложения или код восстановления", { ph: "000000" })}`), + foot: dlgFoot("Отключить"), + }); +} + // ---- login function loginScreen(err = "") { + if (pendingMfa) { + return ``; + } return `