Задача 037: двухфакторная аутентификация TOTP по выбору пользователя
Пользователь сам включает TOTP (QR, приложение-аутентификатор); 10 одноразовых кодов восстановления; superadmin сбрасывает 2FA другому пользователю. Вход в два шага: /auth/login → mfa_token, /auth/login/2fa — под теми же лимитами и advisory-lock, что пароль. Секрет шифруется Fernet-ключом TOTP_ENC_KEY, защита от повторного использования кода (totp_last_step), лимиты перебора кода при отключении. Миграция 0015, зависимости pyotp, segno, cryptography. UI: второй шаг входа, диалоги 2FA в меню логина, бейдж и сброс в «Пользователях»; submitDialog не закрывает окно, открытое следующим шагом цепочки. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c852f47f09
commit
d3495fb077
18 files changed
+821
-42
No files matched your search
@@ -14,3 +14,6 @@ TRUSTED_PROXIES=
|
|||||||
# Публикация через общий Caddy на этом же хосте (изменение 034): Caddy ходит на опубликованный порт и приходит
|
# Публикация через общий Caddy на этом же хосте (изменение 034): Caddy ходит на опубликованный порт и приходит
|
||||||
# с адреса шлюза Docker-моста — доверять диапазону мостов
|
# с адреса шлюза Docker-моста — доверять диапазону мостов
|
||||||
# TRUSTED_PROXIES=172.16.0.0/12
|
# 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=
|
||||||
@@ -23,12 +23,13 @@ venv/bin/python scripts/seed_demo.py # по желанию: демо-да
|
|||||||
| `APP_BIND` | Адрес публикации порта: по умолчанию `0.0.0.0`; `127.0.0.1` — только за reverse-proxy |
|
| `APP_BIND` | Адрес публикации порта: по умолчанию `0.0.0.0`; `127.0.0.1` — только за reverse-proxy |
|
||||||
| `DB_HOST_PORT` | Порт PostgreSQL на хосте, публикуется только на `127.0.0.1` |
|
| `DB_HOST_PORT` | Порт PostgreSQL на хосте, публикуется только на `127.0.0.1` |
|
||||||
| `TRUSTED_PROXIES` | CIDR доверенных прокси через запятую; только от них принимается `X-Forwarded-For` |
|
| `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` с привязкой к организации |
|
| 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 хранятся локально, внешних зависимостей нет |
|
| UI | Статический SPA (vanilla JS, ES-модуль) раздаётся приложением; шрифты IBM Plex хранятся локально, внешних зависимостей нет |
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -78,11 +79,11 @@ docs/changes/ планы и итоги доработок docs/reviews/
|
|||||||
## API (`/api/v1`)
|
## 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` |
|
| Справочники | `/organizations`, `/vrfs`, `/isps`, `/device-types`, `/devices` |
|
||||||
| Префиксы | `/prefixes`, `GET\|POST /prefixes/{id}/subnets/next` (предпросмотр и автовыделение вложенного) |
|
| Префиксы | `/prefixes`, `GET\|POST /prefixes/{id}/subnets/next` (предпросмотр и автовыделение вложенного) |
|
||||||
| Адреса | `GET\|POST /prefixes/{id}/addresses`, `POST /prefixes/{id}/addresses/next` (автоназначение из пула), `PATCH\|DELETE /addresses/{id}` |
|
| Адреса | `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 /audit`, `/audit/summary`, `/audit/facets`, `/audit/{uid}`, `GET\|PUT /journal/settings`, `POST /journal/clear` |
|
||||||
| Сводка | `GET /overview` |
|
| Сводка | `GET /overview` |
|
||||||
|
|
||||||
@@ -112,6 +113,13 @@ docs/changes/ планы и итоги доработок docs/reviews/
|
|||||||
- 20 — на IP;
|
- 20 — на IP;
|
||||||
- 50 — на логин со всех IP, кроме тех, с которых пользователь успешно входил за 30 дней.
|
- 50 — на логин со всех IP, кроме тех, с которых пользователь успешно входил за 30 дней.
|
||||||
- Сверх лимита — 429 с `Retry-After`.
|
- Сверх лимита — 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 клиента и метаданные запроса.
|
- Хранит IP клиента и метаданные запроса.
|
||||||
@@ -141,7 +149,8 @@ docs/changes/ планы и итоги доработок docs/reviews/
|
|||||||
## Интерфейс
|
## Интерфейс
|
||||||
- Экраны: «Обзор», «Префиксы» (дерево по VRF), «Адреса» подсети, «Организации», «Операторы», «Устройства», «Журнал», «Пользователи» (только `superadmin`).
|
- Экраны: «Обзор», «Префиксы» (дерево по VRF), «Адреса» подсети, «Организации», «Операторы», «Устройства», «Журнал», «Пользователи» (только `superadmin`).
|
||||||
- В «Пользователях» видна дата и IP последнего входа (изменение 035); «—», если пользователь ещё не входил.
|
- В «Пользователях» видна дата и IP последнего входа (изменение 035); «—», если пользователь ещё не входил.
|
||||||
- Логин в шапке раскрывает меню «Сменить пароль» / «Выйти» (изменение 036).
|
- Логин в шапке раскрывает меню «Сменить пароль» / «Двухфакторная аутентификация» / «Выйти» (изменения 036, 037).
|
||||||
|
- В «Пользователях» у записей с включённой 2FA — бейдж «2FA»; в меню строки суперадминистратора для чужих записей — «Сбросить 2FA» (изменение 037).
|
||||||
- Переключатель организации — только у `superadmin`; `admin`/`viewer` работают в своей организации.
|
- Переключатель организации — только у `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) |
|
| 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) |
|
| 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) |
|
| 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)
|
- [Ревью кодовой базы](docs/reviews/2026-09-26-codebase-review.md) (находки → изменения 011–023)
|
||||||
|
|||||||
@@ -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
@@ -9,11 +9,13 @@ from sqlalchemy import and_, delete, func, select
|
|||||||
from sqlalchemy.dialects.postgresql import insert
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import totp
|
||||||
|
from app.config import settings
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import KnownLogin, LoginAttempt, User
|
from app.models import KnownLogin, LoginAttempt, User
|
||||||
from app.request_context import request_meta
|
from app.request_context import request_meta
|
||||||
from app.schemas import LoginIn, TokenOut, UserOut
|
from app.schemas import LoginIn, MfaLoginIn, TokenOut, UserOut
|
||||||
from app.security import create_token, current_user, hash_password, verify_password
|
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
|
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"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
@@ -80,27 +82,21 @@ def _locked(retry: int) -> HTTPException:
|
|||||||
headers={"Retry-After": str(retry)})
|
headers={"Retry-After": str(retry)})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=TokenOut)
|
def _lock_pair(db: Session, name: str, ip: str | None) -> None:
|
||||||
def login(body: LoginIn, db: Session = Depends(get_db)):
|
"""Сериализация попыток одного логина (изменение 024, находка №1), затем — этого IP (изменение 027, находка №7):
|
||||||
ctx = request_meta.get()
|
порядок всегда «логин, затем IP» исключает взаимную блокировку; без второй блокировки параллельный перебор
|
||||||
ip = ctx["client_ip"] if ctx else None
|
разных логинов с одного IP проходит проверку лимита по IP одновременно, и лимит превышается на степень
|
||||||
name = body.username.strip().lower()
|
параллелизма — ценой служит то, что попытки с одного IP (в том числе за NAT) обрабатываются по одной,
|
||||||
# сериализация попыток одного логина (изменение 024, находка №1), затем — этого IP (изменение 027, находка №7):
|
и при массовом входе время ответа растёт на время проверки argon2/TOTP."""
|
||||||
# порядок всегда «логин, затем IP» исключает взаимную блокировку; без второй блокировки параллельный перебор
|
|
||||||
# разных логинов с одного IP проходит проверку лимита по IP одновременно, и лимит превышается на степень
|
|
||||||
# параллелизма — ценой служит то, что попытки с одного IP (в том числе за NAT) обрабатываются по одной,
|
|
||||||
# и при массовом входе время ответа растёт на время проверки argon2.
|
|
||||||
db.execute(select(func.pg_advisory_xact_lock(LOGIN_LOCK_NS, func.hashtext(name))))
|
db.execute(select(func.pg_advisory_xact_lock(LOGIN_LOCK_NS, func.hashtext(name))))
|
||||||
if ip is not None:
|
if ip is not None:
|
||||||
db.execute(select(func.pg_advisory_xact_lock(IP_LOCK_NS, func.hashtext(ip))))
|
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: # блокировка: пароль не проверяем, в журнал не пишем (запись о блокировке уже есть)
|
def _record_failure(db: Session, name: str, ip: str | None, known: bool, label: str, action: str, message: str) -> int:
|
||||||
raise _locked(retry)
|
"""Общий учёт неудачной попытки (пароль или второй фактор, изменение 037): та же запись в LoginAttempt и
|
||||||
user = db.scalar(select(User).where(User.username == body.username, User.is_active))
|
тот же выбор события в журнал (`session.locked`, если попытка впервые пересекла лимит; иначе `session.<action>`,
|
||||||
if user is None:
|
только первая неудача по этому логину в окне). Возвращает секунды до конца блокировки (0 — не заблокирован)."""
|
||||||
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)
|
_, _, before = _retry_after(db, name, ip, known)
|
||||||
db.add(LoginAttempt(client_ip=ip, username=name))
|
db.add(LoginAttempt(client_ip=ip, username=name))
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -111,23 +107,85 @@ def login(body: LoginIn, db: Session = Depends(get_db)):
|
|||||||
if why == "ip":
|
if why == "ip":
|
||||||
diff["distinct_logins"] = _distinct_logins(db, ip)
|
diff["distinct_logins"] = _distinct_logins(db, ip)
|
||||||
scope_ru = {"login_ip": "по логину и IP", "ip": "с IP", "login": "по логину"}[why]
|
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})")
|
message=f"Вход заблокирован на {max(1, -(-retry // 60))} мин.: {attempts} неудачных попыток ({scope_ru})")
|
||||||
elif before["login"] == 0: # в журнал — только первая неудача по этому логину в окне (не по IP: иначе перебор логинов с одного IP её не оставит)
|
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()
|
db.commit()
|
||||||
if retry:
|
return retry
|
||||||
raise _locked(retry)
|
|
||||||
raise HTTPException(401, "Неверный логин или пароль")
|
|
||||||
|
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))
|
db.execute(delete(LoginAttempt).where(LoginAttempt.username == name))
|
||||||
_remember_login(db, name, ip)
|
_remember_login(db, name, ip)
|
||||||
user.last_login_at = func.now() # изменение 035
|
user.last_login_at = func.now() # изменение 035
|
||||||
user.last_login_ip = ip # изменение 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()
|
db.commit()
|
||||||
return TokenOut(access_token=create_token(user))
|
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)
|
@router.get("/me", response_model=UserOut)
|
||||||
def me(user: User = Depends(current_user)):
|
def me(user: User = Depends(current_user)):
|
||||||
return user
|
return user
|
||||||
+103
-2
@@ -8,12 +8,19 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import schemas as s
|
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.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.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
|
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}: пароль изменён пользователем")
|
audit(db, user, "user", user, "password_reset", user.username, message=f"{user.username}: пароль изменён пользователем")
|
||||||
commit(db)
|
commit(db)
|
||||||
return {"ok": True, "access_token": create_token(user)} # прежние токены недействительны — текущая сессия получает новый
|
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}
|
||||||
@@ -10,6 +10,7 @@ class Settings(BaseSettings):
|
|||||||
admin_username: str = "admin"
|
admin_username: str = "admin"
|
||||||
admin_password: str = ""
|
admin_password: str = ""
|
||||||
trusted_proxies: str = "" # CIDR через запятую: от них принимается X-Forwarded-For
|
trusted_proxies: str = "" # CIDR через запятую: от них принимается X-Forwarded-For
|
||||||
|
totp_enc_key: str = "" # изменение 037: ключ Fernet для шифрования секретов TOTP; пусто — 2FA недоступна (503), потеря ключа = сброс 2FA всем
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
@@ -23,3 +24,10 @@ def validate_secrets(s: Settings = settings) -> None:
|
|||||||
raise RuntimeError("JWT_SECRET не задан, короче 32 символов или является заглушкой: сгенерируйте значение (python scripts/gen_env.py)")
|
raise RuntimeError("JWT_SECRET не задан, короче 32 символов или является заглушкой: сгенерируйте значение (python scripts/gen_env.py)")
|
||||||
if s.admin_password and len(s.admin_password) < 8:
|
if s.admin_password and len(s.admin_password) < 8:
|
||||||
raise RuntimeError("ADMIN_PASSWORD короче 8 символов: пароль слабее, чем допускает UI")
|
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
|
||||||
@@ -151,6 +151,15 @@ class User(Base):
|
|||||||
password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # токены с iat раньше — недействительны
|
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_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # изменение 035: время последнего успешного входа
|
||||||
last_login_ip: Mapped[str | None] = mapped_column(INET) # изменение 035: IP последнего успешного входа
|
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) # логин уникален без учёта регистра
|
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)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=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())
|
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
@@ -62,8 +62,11 @@ class LoginIn(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class TokenOut(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"
|
token_type: str = "bearer"
|
||||||
|
mfa_required: bool = False
|
||||||
|
mfa_token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
LOGIN_RE = re.compile(r"^[A-Za-z0-9._-]{3,100}$")
|
LOGIN_RE = re.compile(r"^[A-Za-z0-9._-]{3,100}$")
|
||||||
@@ -90,6 +93,8 @@ class UserOut(ORM):
|
|||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
last_login_at: datetime | None = None # изменение 035
|
last_login_at: datetime | None = None # изменение 035
|
||||||
last_login_ip: str | 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")
|
@field_validator("last_login_ip", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -123,6 +128,35 @@ class UserUpdate(BaseModel):
|
|||||||
# инвариант не проверить — реконсиляция по итоговому состоянию в update_user (находка №3)
|
# инвариант не проверить — реконсиляция по итоговому состоянию в 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):
|
class PasswordChange(BaseModel):
|
||||||
current_password: str
|
current_password: str
|
||||||
new_password: str = Field(min_length=8, max_length=128)
|
new_password: str = Field(min_length=8, max_length=128)
|
||||||
|
|||||||
@@ -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")
|
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(
|
def current_user(
|
||||||
cred: HTTPAuthorizationCredentials | None = Depends(_bearer), db: Session = Depends(get_db)
|
cred: HTTPAuthorizationCredentials | None = Depends(_bearer), db: Session = Depends(get_db)
|
||||||
) -> User:
|
) -> User:
|
||||||
@@ -54,6 +61,8 @@ def current_user(
|
|||||||
username = payload["sub"]
|
username = payload["sub"]
|
||||||
except (jwt.PyJWTError, KeyError):
|
except (jwt.PyJWTError, KeyError):
|
||||||
raise HTTPException(401, "Недействительный токен")
|
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))
|
user = db.scalar(select(User).where(User.username == username, User.is_active))
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(401, "Пользователь не найден")
|
raise HTTPException(401, "Пользователь не найден")
|
||||||
@@ -62,6 +71,22 @@ def current_user(
|
|||||||
return 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:
|
def admin_user(user: User = Depends(current_user)) -> User:
|
||||||
# изменение 032: admin_user проверяет роль != viewer (пропускает admin И superadmin)
|
# изменение 032: admin_user проверяет роль != viewer (пропускает admin И superadmin)
|
||||||
if user.role == Role.viewer:
|
if user.role == Role.viewer:
|
||||||
|
|||||||
+110
@@ -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
|
||||||
@@ -23,6 +23,7 @@ services:
|
|||||||
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-}
|
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 «Публикация»)
|
# 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"]
|
ports: ["${APP_BIND:-0.0.0.0}:${APP_PORT}:8000"]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Двухфакторная аутентификация TOTP, по выбору пользователя (изменение 037)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Стенд опубликован в интернет (изменение 034), и вход защищён только паролем и лимитами перебора. Нужен второй фактор,
|
||||||
|
который пользователь включает сам.
|
||||||
|
|
||||||
|
**Решения пользователя:**
|
||||||
|
- **Метод — TOTP:** Google Authenticator, Aegis, 1Password, Bitwarden; настройка по QR-коду.
|
||||||
|
- **Восстановление:** 10 одноразовых кодов, выдаются при включении; superadmin может сбросить 2FA другому пользователю.
|
||||||
|
- **Хранение:** секрет в БД шифруется отдельным ключом `TOTP_ENC_KEY` (Fernet).
|
||||||
|
|
||||||
|
Обязательность 2FA не вводится: у кого 2FA выключена, вход прежний.
|
||||||
|
|
||||||
|
## Модель и миграция `0015_user_totp.py`
|
||||||
|
- Таблица `users`, новые колонки (nullable):
|
||||||
|
- `totp_secret_enc` (Text) — зашифрованный подтверждённый секрет;
|
||||||
|
- `totp_pending_enc` (Text) — секрет на время настройки, до подтверждения кодом;
|
||||||
|
- `totp_enabled_at` (timestamptz) — признак включения;
|
||||||
|
- `totp_last_step` (BigInteger) — защита от повторного использования кода: шаг TOTP должен быть строго больше последнего принятого.
|
||||||
|
- Новая таблица `recovery_codes`: `id`, `user_id` (FK → `users.id`, `ON DELETE CASCADE`, индекс), `code_hash` (sha256 hex), `used_at` (timestamptz null).
|
||||||
|
sha256, а не argon2: коды высокоэнтропийные (10 символов base32), медленный хэш не нужен.
|
||||||
|
- `UserOut`: `totp_enabled: bool` (вычисляемое, `totp_enabled_at is not None`).
|
||||||
|
|
||||||
|
## Конфигурация и зависимости
|
||||||
|
- `app/config.py`: `totp_enc_key: str = ""`. `validate_secrets()`: если задан — проверить, что это корректный ключ Fernet (fail-fast).
|
||||||
|
Если не задан — приложение стартует, 2FA недоступна:
|
||||||
|
- `setup` → 503 «2FA не настроена на сервере (TOTP_ENC_KEY)»;
|
||||||
|
- вход пользователя с включённой 2FA → 503 (не пропускать без второго фактора).
|
||||||
|
- `docker-compose.yml`: `TOTP_ENC_KEY: ${TOTP_ENC_KEY:-}` в окружении `app`.
|
||||||
|
- `scripts/gen_env.py`: генерировать `TOTP_ENC_KEY` (`Fernet.generate_key()` или `base64.urlsafe_b64encode(os.urandom(32))`).
|
||||||
|
- `.env.example`: строка с пояснением — потеря ключа означает сброс 2FA всем.
|
||||||
|
- `requirements.txt`: `pyotp`, `segno` (QR в SVG на сервере, чистый Python), `cryptography`.
|
||||||
|
Обновить `requirements.lock`: `venv/bin/pip-compile --strip-extras -o requirements.lock requirements.txt`, команда из README.
|
||||||
|
- Новый модуль `app/totp.py`:
|
||||||
|
- `encrypt`/`decrypt` (Fernet);
|
||||||
|
- `new_secret()`;
|
||||||
|
- `provisioning_uri(username)` с issuer `IPAM Manager`;
|
||||||
|
- `qr_svg_data_uri(uri)` через segno: `data:image/svg+xml;base64,…`, CSP уже разрешает `img-src data:`;
|
||||||
|
- `verify(user, code)` — `pyotp.TOTP.verify` с окном ±1 шаг и проверкой `totp_last_step`, возвращает принятый шаг;
|
||||||
|
- `new_recovery_codes()` — 10 кодов вида `XXXXX-XXXXX` и их хэши;
|
||||||
|
- `use_recovery_code(db, user, code)` — нормализация (без дефиса, верхний регистр) и однократное погашение.
|
||||||
|
|
||||||
|
## Вход — `app/api/v1/auth.py`
|
||||||
|
- `POST /auth/login`: после успешной проверки пароля, если `user.totp_enabled_at`:
|
||||||
|
- вместо токена вернуть `{"mfa_required": true, "mfa_token": …}`;
|
||||||
|
- `mfa_token` — JWT с `typ="mfa"`, `sub`, `pv`, сроком 5 минут;
|
||||||
|
- `session.login` в журнал пока не пишется, `last_login_*` не обновляются, попытки входа не сбрасываются.
|
||||||
|
`TokenOut` расширить: `access_token: str | None`, `mfa_required: bool = False`, `mfa_token: str | None`.
|
||||||
|
- `POST /auth/login/2fa` `{mfa_token, code}`:
|
||||||
|
- токен разбирается и проверяется: `typ == "mfa"`, пользователь активен, `pv` совпадает;
|
||||||
|
- те же advisory-lock и лимиты, что у пароля (`_retry_after`, `LoginAttempt`); неверный код считается неудачной попыткой;
|
||||||
|
- `code` — 6 цифр TOTP либо код восстановления;
|
||||||
|
- успех: общая с паролем завершающая часть — вынести её из `login()` в хелпер `_complete_login` (очистка попыток,
|
||||||
|
`_remember_login`, `last_login_*`, `session.login`, `create_token`). При входе кодом восстановления в `diff` — `{"recovery_code": true}`
|
||||||
|
и остаток кодов;
|
||||||
|
- неудача: `session.2fa_failed` в журнал (только первая в окне — как `session.failed`), 401 «Неверный код».
|
||||||
|
- `app/security.py::current_user`: отклонять токены с `typ` (401) — `mfa_token` не должен работать как токен доступа.
|
||||||
|
|
||||||
|
## Управление 2FA — `app/api/v1/users.py`
|
||||||
|
Свои действия — любой роли, в стиле `POST /users/me/password`:
|
||||||
|
- `POST /users/me/2fa/setup` `{password}` — только если 2FA выключена. Новый секрет кладётся в `totp_pending_enc`,
|
||||||
|
ответ: `{secret, otpauth_uri, qr}` (qr — data URI SVG).
|
||||||
|
- `POST /users/me/2fa/enable` `{code}` — проверка кода по pending-секрету; перенос в `totp_secret_enc`, `totp_enabled_at = now()`,
|
||||||
|
`totp_last_step`; выпуск 10 кодов. Ответ: `{recovery_codes: [...]}` — показываются один раз. Журнал `user.2fa_enabled`.
|
||||||
|
- `POST /users/me/2fa/disable` `{password, code}` — `code` может быть TOTP или кодом восстановления. Очистить поля и удалить коды.
|
||||||
|
Журнал `user.2fa_disabled`.
|
||||||
|
- `POST /users/{id}/2fa/reset` — только `superadmin`, не для своей записи (409 — свою отключают через `/me/2fa/disable`).
|
||||||
|
Очистить поля и коды. Журнал `user.2fa_reset`.
|
||||||
|
- Журнал: все события без `organization_id` (системные, видит `superadmin`), тексты — явным `message`.
|
||||||
|
- Схемы в `app/schemas.py`: `MfaLoginIn`, `TotpSetupIn`, `TotpSetupOut`, `TotpCodeIn`, `TotpDisableIn`, `RecoveryCodesOut`.
|
||||||
|
- Ошибки: неверный пароль → 403, как в `/users/me/password`; неверный код → 422; 2FA уже включена или выключена → 409.
|
||||||
|
|
||||||
|
## UI — `web/app.js`
|
||||||
|
- **Вход.** Если ответ `mfa_required`, показать второй шаг: поле «Код из приложения или код восстановления»
|
||||||
|
(`autocomplete="one-time-code"`, `inputmode` не ограничивать — у кода восстановления есть буквы). `mfa_token` хранить в памяти.
|
||||||
|
«Назад» возвращает к паролю. Ошибка истёкшего токена → возврат к паролю с сообщением.
|
||||||
|
- **Меню логина** (изменение 036): пункт «Двухфакторная аутентификация» между «Сменить пароль» и «Выйти» → диалог.
|
||||||
|
- 2FA выключена: «Включить» → пароль → QR, секрет текстом для ручного ввода и поле кода → «Подтвердить» →
|
||||||
|
экран с 10 кодами восстановления, моноширинно, кнопка «Скопировать» и предупреждение «сохраните — больше не покажем».
|
||||||
|
- 2FA включена: статус «включена с <дата>» и «Отключить» (пароль + код).
|
||||||
|
- Диалоги — через существующие `openDialog`/`formBody`/`fInput`/`dlgFoot`, по образцу `passwordDialog`.
|
||||||
|
- **Раздел «Пользователи»:** бейдж `2FA` в колонке статуса у пользователей с включённой 2FA. В меню строки у `superadmin` —
|
||||||
|
«Сбросить 2FA» (с подтверждением) для чужих записей с включённой 2FA.
|
||||||
|
- `auth/me` отдаёт `totp_enabled` — диалог знает текущее состояние.
|
||||||
|
|
||||||
|
## Тесты (минимально)
|
||||||
|
Один сквозной тест `tests/test_users.py::test_totp_flow`, `pyotp` вычисляет коды:
|
||||||
|
- создать пользователя и войти;
|
||||||
|
- `setup` → `enable` (получить 10 кодов);
|
||||||
|
- вход паролем → `mfa_required`; `mfa_token` на `/auth/me` → 401;
|
||||||
|
- `/auth/login/2fa` с кодом → токен; повтор того же кода → 401 (повторное использование);
|
||||||
|
- вход кодом восстановления → успех, повтор того же кода → 401;
|
||||||
|
- `reset` суперадминистратором → вход снова только паролем;
|
||||||
|
- в `finally` удалить пользователя.
|
||||||
|
|
||||||
|
`pyotp` добавить в `requirements-dev.txt`, если он не подтягивает `requirements.txt`.
|
||||||
|
|
||||||
|
## Документация
|
||||||
|
- `docs/changes/037-totp-2fa/PLAN.md` — этот план; по завершении — `SUMMARY.md`.
|
||||||
|
- `README.md`:
|
||||||
|
- «Безопасность → Вход»: 2FA по желанию, коды восстановления, сброс суперадминистратором;
|
||||||
|
- конфигурация: `TOTP_ENC_KEY`;
|
||||||
|
- API: `/auth/login/2fa`, `/users/me/2fa/*`, `/users/{id}/2fa/reset`;
|
||||||
|
- миграции `0001`–`0015`;
|
||||||
|
- строка 037 в истории.
|
||||||
|
|
||||||
|
## Исполнение
|
||||||
|
По принятой схеме: код, миграцию, UI, зависимости и тест пишет агент на Sonnet (без запуска тестов и стенда).
|
||||||
|
Моя часть:
|
||||||
|
- ревью;
|
||||||
|
- `TOTP_ENC_KEY` в `.env` стенда;
|
||||||
|
- пересборка с `--force-recreate`;
|
||||||
|
- проверки и SUMMARY.
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
- Пересборка: `docker compose -p ipam_control_006 up -d --build --force-recreate app`. `alembic current` = `0015`, `alembic check` чисто,
|
||||||
|
откат до 0014 и повторный upgrade — без ошибок; стенд отдаёт новый `app.js`.
|
||||||
|
- Старт без `TOTP_ENC_KEY` → приложение работает, `setup` → 503. С некорректным ключом → отказ старта.
|
||||||
|
- `pytest -q` — все зелёные, включая `test_totp_flow`.
|
||||||
|
- API вручную:
|
||||||
|
- `mfa_token` не работает как токен доступа;
|
||||||
|
- лимит перебора кода срабатывает (429 после порогов);
|
||||||
|
- в БД `totp_secret_enc` не содержит base32-секрет открытым текстом;
|
||||||
|
- события `user.2fa_*` и `session.2fa_failed` есть в журнале.
|
||||||
|
- Ручная проверка в браузере (пользователь): включение по QR в приложении-аутентификаторе, вход с кодом, вход кодом восстановления,
|
||||||
|
отключение, сброс суперадминистратором.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Итог: двухфакторная аутентификация TOTP по выбору пользователя (изменение 037)
|
||||||
|
|
||||||
|
План: `PLAN.md`. Пользователь сам включает второй фактор: приложение-аутентификатор (TOTP), настройка по QR.
|
||||||
|
Выдаются 10 одноразовых кодов восстановления; superadmin может сбросить 2FA другому пользователю. Без 2FA вход прежний.
|
||||||
|
|
||||||
|
## Что сделано
|
||||||
|
| Где | Изменение |
|
||||||
|
|---|---|
|
||||||
|
| `app/models.py`, `alembic/versions/0015_user_totp.py` | `users.totp_secret_enc`, `totp_pending_enc`, `totp_enabled_at`, `totp_last_step`; таблица `recovery_codes` (sha256, `ON DELETE CASCADE`) |
|
||||||
|
| `app/totp.py` (новый) | Fernet-шифрование секрета (`TOTP_ENC_KEY`), генерация секрета и `otpauth://`, QR в SVG на сервере (segno, data URI), проверка кода ±1 шаг со строгим ростом шага (защита от повторного использования), коды восстановления |
|
||||||
|
| `app/config.py`, `docker-compose.yml`, `scripts/gen_env.py`, `.env.example` | `TOTP_ENC_KEY`: некорректный ключ — отказ старта; без ключа приложение стартует, 2FA недоступна (503) |
|
||||||
|
| `app/security.py` | `mfa_token` (`typ="mfa"`, 5 мин); `current_user` отклоняет токены с `typ` |
|
||||||
|
| `app/api/v1/auth.py` | Вход в два шага: `POST /auth/login` → `mfa_required` + `mfa_token`; `POST /auth/login/2fa` — TOTP или код восстановления под теми же advisory-lock и лимитами, что пароль. Общие хелперы `_lock_pair`, `_record_failure`, `_complete_login` |
|
||||||
|
| `app/api/v1/users.py` | `POST /users/me/2fa/setup` (пароль), `/enable` (код → 10 кодов восстановления), `/disable` (пароль + код, с лимитами перебора), `POST /users/{id}/2fa/reset` (superadmin, не себе). Журнал: `user.2fa_enabled/disabled/reset`, `session.2fa_failed` |
|
||||||
|
| `app/schemas.py` | `TokenOut` (`mfa_required`, `mfa_token`), `UserOut` (`totp_enabled`, `totp_enabled_at`), схемы 2FA |
|
||||||
|
| `web/app.js` | Второй шаг входа; пункт «Двухфакторная аутентификация» в меню логина (включение с QR, коды восстановления с копированием, отключение); бейдж `2FA` и «Сбросить 2FA» в «Пользователях»; `submitDialog` не закрывает окно, открытое следующим шагом цепочки |
|
||||||
|
| `requirements.txt`, `requirements.lock` | `pyotp`, `segno`, `cryptography` |
|
||||||
|
| `tests/test_users.py` | `test_totp_flow`: setup/enable, вход с кодом, повтор кода → 401, код восстановления однократен, сброс superadmin |
|
||||||
|
| `README.md` | Вход, конфигурация `TOTP_ENC_KEY`, API, миграции `0001`–`0015`, строка 037 |
|
||||||
|
|
||||||
|
## Найдено и исправлено в ходе цикла
|
||||||
|
- **Ревью — повторное использование кода:** `login_2fa` читал пользователя до advisory-lock; два параллельных запроса с одним кодом проходили оба.
|
||||||
|
Исправлено `db.refresh(user)` после блокировки; на стенде из двух одновременных запросов — 200 и 401.
|
||||||
|
- **Ревью — перебор кода при отключении:** `/users/me/2fa/disable` не ограничивал попытки кода (угроза при украденных токене и пароле).
|
||||||
|
Подключены лимиты входа; на стенде 4-я неверная попытка — 429.
|
||||||
|
- **Ручная проверка UI — обрыв настройки:** `submitDialog` безусловно закрывал окно после шага, закрывая уже открытое следующее окно цепочки
|
||||||
|
(после «Далее» диалог исчезал; на шаге подтверждения закрылось бы окно с кодами восстановления). Исправлено: окно закрывается,
|
||||||
|
только если обработчик не подменил `S.dialog`. Урок: у UI нет автотестов, и ревью было сосредоточено на серверной части —
|
||||||
|
цепочки диалогов проверяются только вручную.
|
||||||
|
|
||||||
|
## Проверки
|
||||||
|
- `pytest -q` — 16 passed. Миграция 0015 применена, `alembic check` чисто, откат до 0014 и повторный upgrade — без ошибок.
|
||||||
|
- На стенде: секрет в БД зашифрован (открытого base32 нет); `mfa_token` на `/auth/me` → 401; параллельный повтор кода отклонён;
|
||||||
|
лимит перебора кода при отключении срабатывает; события 2FA в журнале. Без ключа — старт разрешён, с неверным ключом — отказ.
|
||||||
|
- Ручная проверка UI — пользователь.
|
||||||
|
|
||||||
|
## Эксплуатация
|
||||||
|
- `TOTP_ENC_KEY` стенда сгенерирован и записан в `.env`. Ключ нужно хранить в резервной копии: его потеря = сброс 2FA всем пользователям.
|
||||||
|
- Пересборка стенда — с `--force-recreate` (см. изменение 036).
|
||||||
+9
-1
@@ -19,9 +19,13 @@ argon2-cffi==25.1.0
|
|||||||
argon2-cffi-bindings==26.1.0
|
argon2-cffi-bindings==26.1.0
|
||||||
# via argon2-cffi
|
# via argon2-cffi
|
||||||
cffi==2.1.1
|
cffi==2.1.1
|
||||||
# via argon2-cffi-bindings
|
# via
|
||||||
|
# argon2-cffi-bindings
|
||||||
|
# cryptography
|
||||||
click==8.5.0
|
click==8.5.0
|
||||||
# via uvicorn
|
# via uvicorn
|
||||||
|
cryptography==50.0.1
|
||||||
|
# via -r requirements.txt
|
||||||
dnspython==2.8.0
|
dnspython==2.8.0
|
||||||
# via email-validator
|
# via email-validator
|
||||||
email-validator==2.3.0
|
email-validator==2.3.0
|
||||||
@@ -58,12 +62,16 @@ pydantic-settings==2.15.0
|
|||||||
# via -r requirements.txt
|
# via -r requirements.txt
|
||||||
pyjwt==2.15.0
|
pyjwt==2.15.0
|
||||||
# via -r requirements.txt
|
# via -r requirements.txt
|
||||||
|
pyotp==2.10.0
|
||||||
|
# via -r requirements.txt
|
||||||
python-dotenv==1.2.3
|
python-dotenv==1.2.3
|
||||||
# via
|
# via
|
||||||
# pydantic-settings
|
# pydantic-settings
|
||||||
# uvicorn
|
# uvicorn
|
||||||
pyyaml==6.0.3
|
pyyaml==6.0.3
|
||||||
# via uvicorn
|
# via uvicorn
|
||||||
|
segno==1.6.6
|
||||||
|
# via -r requirements.txt
|
||||||
sqlalchemy==2.0.54
|
sqlalchemy==2.0.54
|
||||||
# via
|
# via
|
||||||
# -r requirements.txt
|
# -r requirements.txt
|
||||||
|
|||||||
@@ -7,3 +7,6 @@ pydantic-settings>=2.4
|
|||||||
email-validator>=2.2
|
email-validator>=2.2
|
||||||
pyjwt>=2.9
|
pyjwt>=2.9
|
||||||
argon2-cffi>=23.1
|
argon2-cffi>=23.1
|
||||||
|
pyotp>=2.9 # изменение 037: TOTP-коды двухфакторной аутентификации
|
||||||
|
segno>=1.6 # изменение 037: QR-код в SVG на сервере (чистый Python, без внешних вызовов)
|
||||||
|
cryptography>=43 # изменение 037: шифрование секрета TOTP (Fernet)
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
"""Генерирует .env со случайными учётными данными (не перезаписывает существующий)."""
|
"""Генерирует .env со случайными учётными данными (не перезаписывает существующий)."""
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
@@ -15,6 +17,8 @@ else:
|
|||||||
f"ADMIN_PASSWORD={secrets.token_urlsafe(18)}\n"
|
f"ADMIN_PASSWORD={secrets.token_urlsafe(18)}\n"
|
||||||
"APP_PORT=8088\n"
|
"APP_PORT=8088\n"
|
||||||
"DB_HOST_PORT=55432\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)
|
env.chmod(0o600)
|
||||||
print(f"создан {env}")
|
print(f"создан {env}")
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
"""Управление пользователями: один сквозной сценарий (создание → права → отключение → пароли → удаление)."""
|
"""Управление пользователями: один сквозной сценарий (создание → права → отключение → пароли → удаление)."""
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import pyotp
|
||||||
|
|
||||||
from tests.conftest import BASE
|
from tests.conftest import BASE
|
||||||
|
|
||||||
@@ -86,3 +88,79 @@ def test_users_management(client, org):
|
|||||||
other.close()
|
other.close()
|
||||||
if uid is not None:
|
if uid is not None:
|
||||||
client.delete(f"/users/{uid}")
|
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
@@ -41,6 +41,7 @@ let user = null;
|
|||||||
let orgs = [];
|
let orgs = [];
|
||||||
let S = {}; // локальное состояние текущего экрана
|
let S = {}; // локальное состояние текущего экрана
|
||||||
let menu = null; // открытое всплывающее меню {id, items:[{label,value,cls}], on}
|
let menu = null; // открытое всплывающее меню {id, items:[{label,value,cls}], on}
|
||||||
|
let pendingMfa = null; // изменение 037: {mfa_token} между первым и вторым шагом входа — только в памяти, не в sessionStorage
|
||||||
|
|
||||||
/* --------------------------------------------------------------------- api */
|
/* --------------------------------------------------------------------- api */
|
||||||
class ApiError extends Error {
|
class ApiError extends Error {
|
||||||
@@ -60,7 +61,7 @@ async function api(path, { method = "GET", body, params } = {}) {
|
|||||||
});
|
});
|
||||||
if (res.status === 204) return null;
|
if (res.status === 204) return null;
|
||||||
const data = await res.json().catch(() => 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;
|
store.token = null;
|
||||||
location.hash = "#/login";
|
location.hash = "#/login";
|
||||||
throw new ApiError(401, { message: "Сессия истекла" });
|
throw new ApiError(401, { message: "Сессия истекла" });
|
||||||
@@ -133,7 +134,8 @@ const NAV = [["overview", "Обзор"], ["prefixes", "Префиксы"], ["org
|
|||||||
function shell(active, content) {
|
function shell(active, content) {
|
||||||
const nav = NAV.filter(([, , role]) => role !== "superadmin" || isSuperadmin()); // изменение 032: раздел «Пользователи» виден только суперадминистратору
|
const nav = NAV.filter(([, , role]) => role !== "superadmin" || isSuperadmin()); // изменение 032: раздел «Пользователи» виден только суперадминистратору
|
||||||
// изменение 036: логин в шапке — триггер выпадающего меню («Сменить пароль» / «Выйти») вместо отдельных кнопок
|
// изменение 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>
|
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>
|
<nav class="nav">${nav.map(([k, l]) => `<a href="#/${k}" class="${k === active ? "active" : ""}">${l}</a>`).join("")}</nav>
|
||||||
<div class="grow"></div>${userMenu}</div>
|
<div class="grow"></div>${userMenu}</div>
|
||||||
@@ -184,7 +186,9 @@ async function submitDialog() {
|
|||||||
const h = S.dialog;
|
const h = S.dialog;
|
||||||
try {
|
try {
|
||||||
await h(readForm());
|
await h(readForm());
|
||||||
closeDialog();
|
// изменение 037: обработчик мог сам открыть следующее окно диалоговой цепочки (сменив S.dialog) — тогда
|
||||||
|
// закрывать нечего, иначе только что открытое окно закрылось бы сразу вслед за собой
|
||||||
|
if (S.dialog === h) closeDialog();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError) showFormError(e); else throw e;
|
if (e instanceof ApiError) showFormError(e); else throw e;
|
||||||
}
|
}
|
||||||
@@ -707,6 +711,8 @@ screens.users = async () => {
|
|||||||
const items2 = [
|
const items2 = [
|
||||||
{ label: "Редактировать", value: "edit:" + u.id },
|
{ label: "Редактировать", value: "edit:" + u.id },
|
||||||
{ label: u.is_active ? "Отключить доступ" : "Разрешить доступ", value: "toggle:" + u.id, cls: u.is_active ? "danger" : "" },
|
{ 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" },
|
{ label: "Удалить", value: "del:" + u.id, cls: "danger" },
|
||||||
];
|
];
|
||||||
const orgName = orgs.find((o) => o.id === u.organization_id)?.name || "—"; // изменение 032
|
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 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>${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 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 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="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>`;
|
<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
|
// ---- login
|
||||||
function loginScreen(err = "") {
|
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>
|
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>
|
<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>
|
<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 } });
|
await api(`/users/${id}`, { method: "PATCH", body: { is_active: !u.is_active } });
|
||||||
toast(u.is_active ? "Доступ отключён" : "Доступ разрешён"); await draw();
|
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(); });
|
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 === "bulk-status" || id === "bulk-type") return bulkPick(id, value);
|
||||||
else if (id === "user") { // изменение 036: меню логина в шапке
|
else if (id === "user") { // изменение 036: меню логина в шапке
|
||||||
document.querySelectorAll(".pop").forEach((el) => el.remove());
|
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 {
|
else {
|
||||||
const key = Object.keys(rowActions).find((k) => id.startsWith(k + "-"));
|
const key = Object.keys(rowActions).find((k) => id.startsWith(k + "-"));
|
||||||
@@ -988,6 +1072,8 @@ const actions = {
|
|||||||
"org-new": () => orgDialog(null),
|
"org-new": () => orgDialog(null),
|
||||||
"users-new": () => userDialog(null),
|
"users-new": () => userDialog(null),
|
||||||
"pw-change": () => passwordDialog(),
|
"pw-change": () => passwordDialog(),
|
||||||
|
"totp-disable-start": () => totpDisableStartDialog(), // изменение 037
|
||||||
|
"mfa-back": () => { pendingMfa = null; $("#app").innerHTML = loginScreen(); }, // изменение 037
|
||||||
"isp-new": () => ispDialog(null),
|
"isp-new": () => ispDialog(null),
|
||||||
"dev-new": () => deviceDialog(null),
|
"dev-new": () => deviceDialog(null),
|
||||||
"pfx-new": () => prefixDialog(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(); }
|
if (e.key === "Enter" && e.target.closest("#dlg-form") && e.target.tagName !== "TEXTAREA") { e.preventDefault(); submitDialog(); }
|
||||||
});
|
});
|
||||||
document.addEventListener("submit", async (e) => {
|
document.addEventListener("submit", async (e) => {
|
||||||
if (e.target.id !== "login-form") return;
|
if (e.target.id === "login-form") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const f = new FormData(e.target);
|
const f = new FormData(e.target);
|
||||||
try {
|
try {
|
||||||
const { access_token } = await api("/auth/login", { method: "POST", body: { username: f.get("username"), password: f.get("password") } });
|
const r = await api("/auth/login", { method: "POST", body: { username: f.get("username"), password: f.get("password") } });
|
||||||
store.token = access_token;
|
if (r.mfa_required) { // изменение 037: второй шаг — код из приложения или код восстановления
|
||||||
|
pendingMfa = { mfa_token: r.mfa_token };
|
||||||
|
$("#app").innerHTML = loginScreen();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
store.token = r.access_token;
|
||||||
user = null; current = "";
|
user = null; current = "";
|
||||||
location.hash = "#/overview";
|
location.hash = "#/overview";
|
||||||
draw();
|
draw();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
$("#app").innerHTML = loginScreen(err.message);
|
$("#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();
|
draw();
|
||||||
Reference in new issue
Block a user