Задача 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:
ayurishchevandClaude Opus 5.5 committed 2026-09-27 16:57:14 +03:00
1 parent c852f47f09
commit d3495fb077
18 files changed
+821 -42

No files matched your search

+3
View File
@@ -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=
+14 -4
View File
@@ -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)
+38
View File
@@ -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")
+84 -26
View File
@@ -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,27 +82,21 @@ def _locked(retry: int) -> HTTPException:
headers={"Retry-After": str(retry)})
@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.
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))))
known = _is_known(db, name, ip)
retry, _, _ = _retry_after(db, name, ip, known)
if retry: # блокировка: пароль не проверяем, в журнал не пишем (запись о блокировке уже есть)
raise _locked(retry)
user = db.scalar(select(User).where(User.username == body.username, User.is_active))
if user is None:
verify_password(body.password, _DUMMY_HASH)
if user is None or not verify_password(body.password, user.password_hash):
def _record_failure(db: Session, name: str, ip: str | None, known: bool, label: str, action: str, message: str) -> int:
"""Общий учёт неудачной попытки (пароль или второй фактор, изменение 037): та же запись в LoginAttempt и
тот же выбор события в журнал (`session.locked`, если попытка впервые пересекла лимит; иначе `session.<action>`,
только первая неудача по этому логину в окне). Возвращает секунды до конца блокировки (0 — не заблокирован)."""
_, _, before = _retry_after(db, name, ip, known)
db.add(LoginAttempt(client_ip=ip, username=name))
db.flush()
@@ -111,23 +107,85 @@ def login(body: LoginIn, db: Session = Depends(get_db)):
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,
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, "failed", body.username[:100], message="Неудачная попытка входа в UI")
audit(db, ANONYMOUS, "session", None, action, label, message=message)
db.commit()
if retry:
raise _locked(retry)
raise HTTPException(401, "Неверный логин или пароль")
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, message=f"Вход в UI: {user.username}")
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()
_lock_pair(db, name, ip)
known = _is_known(db, name, ip)
retry, _, _ = _retry_after(db, name, ip, known)
if retry: # блокировка: пароль не проверяем, в журнал не пишем (запись о блокировке уже есть)
raise _locked(retry)
user = db.scalar(select(User).where(User.username == body.username, User.is_active))
if user is None:
verify_password(body.password, _DUMMY_HASH)
if user is None or not verify_password(body.password, user.password_hash):
retry = _record_failure(db, name, ip, known, body.username[:100], "failed", "Неудачная попытка входа в UI")
if retry:
raise _locked(retry)
raise HTTPException(401, "Неверный логин или пароль")
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)
def me(user: User = Depends(current_user)):
return user
+103 -2
View File
@@ -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}
+8
View File
@@ -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
+19
View File
@@ -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))
+35 -1
View File
@@ -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)
+25
View File
@@ -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:
+110
View File
@@ -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
+1
View File
@@ -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:
+126
View File
@@ -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 в приложении-аутентификаторе, вход с кодом, вход кодом восстановления,
отключение, сброс суперадминистратором.
+39
View File
@@ -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).
+9 -1
View File
@@ -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
+3
View File
@@ -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)
+4
View File
@@ -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}")
+78
View File
@@ -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}")
+122 -8
View File
@@ -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 = `<span class="rel user-menu"><button type="button" class="btn ghost" data-action="menu" data-menu="user" title="${esc(ROLE_RU[user?.role] ?? "")}">${esc(user?.username ?? "")}${I.chevD(14)}</button>${popMenu("user", [{ label: "Сменить пароль", value: "pw" }, { label: "Выйти", value: "logout" }], "row-pop")}</span>`;
// изменение 037: пункт «Двухфакторная аутентификация» между «Сменить пароль» и «Выйти»
const userMenu = `<span class="rel user-menu"><button type="button" class="btn ghost" data-action="menu" data-menu="user" title="${esc(ROLE_RU[user?.role] ?? "")}">${esc(user?.username ?? "")}${I.chevD(14)}</button>${popMenu("user", [{ label: "Сменить пароль", value: "pw" }, { label: "Двухфакторная аутентификация", value: "2fa" }, { label: "Выйти", value: "logout" }], "row-pop")}</span>`;
return `<div class="appbar"><div class="brand"><span class="brand-logo">${I.globe()}</span>ipam_manager</div>
<nav class="nav">${nav.map(([k, l]) => `<a href="#/${k}" class="${k === active ? "active" : ""}">${l}</a>`).join("")}</nav>
<div class="grow"></div>${userMenu}</div>
@@ -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 () => {
<span class="m13" style="font-weight:600">${esc(u.username)}${self ? ` <span class="muted" style="font:400 12px var(--sans)">это вы</span>` : ""}</span>
<span>${badge(u.role === "superadmin" ? "purple" : u.role === "admin" ? "blue" : "", ROLE_RU[u.role] || u.role)}</span>
<span class="muted" style="font-size:13px">${u.role === "superadmin" ? "—" : esc(orgName)}</span>
<span>${u.is_active ? badge("green", "Доступ разрешён") : badge("red", "Отключён")}</span>
<span>${u.is_active ? badge("green", "Доступ разрешён") : badge("red", "Отключён")}${u.totp_enabled ? " " + badge("blue", "2FA") : ""}</span>
<span style="font-size:13px">${u.last_login_at ? `<span style="display:flex;flex-direction:column;gap:2px"><span>${fmtDate(u.last_login_at)}</span><span class="mono muted" style="font-size:12px">${esc(u.last_login_ip || "—")}</span></span>` : '<span class="muted">—</span>'}</span>
<span class="muted" style="font-size:13px">${self ? "свои роль и доступ менять нельзя" : ""}</span>
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="user-row-${u.id}"`)}${popMenu("user-row-" + u.id, items2, "row-pop")}</span></div>`;
@@ -772,8 +778,82 @@ function passwordDialog() {
});
}
// ---- 2FA (изменение 037): управление своей двухфакторной аутентификацией из меню логина в шапке
function totpDialog() {
return user.totp_enabled ? totpStatusDialog() : totpSetupPasswordDialog();
}
function totpStatusDialog() {
openDialog({
title: "Двухфакторная аутентификация",
body: `<div class="dialog-body"><div class="info">Включена с ${esc(fmtDate(user.totp_enabled_at))}</div></div>`,
foot: `<div class="dialog-foot">${btn("Закрыть", "close-dialog", { cls: "ghost" })}${btn("Отключить", "totp-disable-start", { cls: "danger" })}</div>`,
});
}
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(`<div style="display:flex;justify-content:center;padding:8px 0"><img src="${setup.qr}" alt="QR-код" width="220" height="220"></div>
<label class="field"><span>Секрет для ручного ввода</span><input class="input mono" value="${esc(setup.secret)}" readonly onclick="this.select()"></label>
${fInput("code", "Код из приложения", { ph: "000000" })}`),
foot: dlgFoot("Подтвердить"),
});
}
function totpRecoveryCodesDialog(codes) {
S.dialog = null;
const text = codes.join("\n");
openDialog({
title: "Коды восстановления", width: 460,
note: "Сохраните эти коды в надёжном месте — сервер больше не покажет их. Каждый код можно использовать один раз, вместо кода из приложения.",
body: `<div class="dialog-body"><pre class="json" style="text-align:center;line-height:1.8">${esc(text)}</pre></div>`,
foot: `<div class="dialog-foot">${btn("Скопировать", "copy", { data: `data-text="${esc(text)}"` })}${btn("Готово", "close-dialog", { cls: "primary" })}</div>`,
});
}
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 `<div class="card login"><div class="brand"><span class="brand-logo">${I.globe()}</span>ipam_manager</div><h1>Вход</h1>
<form id="mfa-form"><div class="err-banner" style="margin-top:12px" ${err ? "" : "hidden"}>${esc(err)}</div>
<label class="field"><span>Код из приложения или код восстановления</span><input class="input mono" name="code" autocomplete="one-time-code" autofocus></label>
<button class="btn primary" type="submit">Подтвердить</button>
<button class="btn ghost" type="button" data-action="mfa-back" style="margin-top:8px;width:100%">Назад</button></form></div>`;
}
return `<div class="card login"><div class="brand"><span class="brand-logo">${I.globe()}</span>ipam_manager</div><h1>Вход</h1>
<form id="login-form"><div class="err-banner" style="margin-top:12px" ${err ? "" : "hidden"}>${esc(err)}</div>
<label class="field"><span>Логин</span><input class="input" name="username" autocomplete="username" autofocus></label>
@@ -907,6 +987,9 @@ const rowActions = {
await api(`/users/${id}`, { method: "PATCH", body: { is_active: !u.is_active } });
toast(u.is_active ? "Доступ отключён" : "Доступ разрешён"); await draw();
});
else if (act === "totp-reset" && confirm(`Сбросить двухфакторную аутентификацию пользователю ${u.username}?`)) await guarded(async () => {
await api(`/users/${id}/2fa/reset`, { method: "POST" }); toast("Двухфакторная аутентификация сброшена"); await draw();
}); // изменение 037
else if (act === "del" && confirmDel(`пользователя ${u.username}`)) await guarded(async () => { await api(`/users/${id}`, { method: "DELETE" }); toast("Пользователь удалён"); await draw(); });
},
};
@@ -928,7 +1011,8 @@ const actions = {
else if (id === "bulk-status" || id === "bulk-type") return bulkPick(id, value);
else if (id === "user") { // изменение 036: меню логина в шапке
document.querySelectorAll(".pop").forEach((el) => el.remove());
return value === "logout" ? actions.logout() : passwordDialog();
if (value === "logout") return actions.logout();
return value === "2fa" ? totpDialog() : passwordDialog(); // изменение 037
}
else {
const key = Object.keys(rowActions).find((k) => id.startsWith(k + "-"));
@@ -988,6 +1072,8 @@ const actions = {
"org-new": () => orgDialog(null),
"users-new": () => userDialog(null),
"pw-change": () => passwordDialog(),
"totp-disable-start": () => totpDisableStartDialog(), // изменение 037
"mfa-back": () => { pendingMfa = null; $("#app").innerHTML = loginScreen(); }, // изменение 037
"isp-new": () => ispDialog(null),
"dev-new": () => deviceDialog(null),
"pfx-new": () => prefixDialog(null),
@@ -1072,18 +1158,46 @@ document.addEventListener("keydown", (e) => {
if (e.key === "Enter" && e.target.closest("#dlg-form") && e.target.tagName !== "TEXTAREA") { e.preventDefault(); submitDialog(); }
});
document.addEventListener("submit", async (e) => {
if (e.target.id !== "login-form") return;
if (e.target.id === "login-form") {
e.preventDefault();
const f = new FormData(e.target);
try {
const { access_token } = await api("/auth/login", { method: "POST", body: { username: f.get("username"), password: f.get("password") } });
store.token = access_token;
const r = await api("/auth/login", { method: "POST", body: { username: f.get("username"), password: f.get("password") } });
if (r.mfa_required) { // изменение 037: второй шаг — код из приложения или код восстановления
pendingMfa = { mfa_token: r.mfa_token };
$("#app").innerHTML = loginScreen();
return;
}
store.token = r.access_token;
user = null; current = "";
location.hash = "#/overview";
draw();
} catch (err) {
$("#app").innerHTML = loginScreen(err.message);
}
return;
}
if (e.target.id === "mfa-form") { // изменение 037
e.preventDefault();
const code = new FormData(e.target).get("code");
try {
const { access_token } = await api("/auth/login/2fa", { method: "POST", body: { mfa_token: pendingMfa.mfa_token, code } });
pendingMfa = null;
store.token = access_token;
user = null; current = "";
location.hash = "#/overview";
draw();
} catch (err) {
// 401 «Неверный код» — остаёмся на втором шаге (можно повторить); другой 401 — истёкший/недействительный
// mfa_token или отключённая 2FA — возврат к паролю (изменение 037)
if (err instanceof ApiError && err.status === 401 && err.message !== "Неверный код") {
pendingMfa = null;
$("#app").innerHTML = loginScreen("Время на ввод кода истекло, войдите заново");
return;
}
$("#app").innerHTML = loginScreen(err.message);
}
}
});
draw();