Задача 037: двухфакторная аутентификация TOTP по выбору пользователя
Пользователь сам включает TOTP (QR, приложение-аутентификатор); 10 одноразовых кодов восстановления; superadmin сбрасывает 2FA другому пользователю. Вход в два шага: /auth/login → mfa_token, /auth/login/2fa — под теми же лимитами и advisory-lock, что пароль. Секрет шифруется Fernet-ключом TOTP_ENC_KEY, защита от повторного использования кода (totp_last_step), лимиты перебора кода при отключении. Миграция 0015, зависимости pyotp, segno, cryptography. UI: второй шаг входа, диалоги 2FA в меню логина, бейдж и сброс в «Пользователях»; submitDialog не закрывает окно, открытое следующим шагом цепочки. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c852f47f09
commit
d3495fb077
18 files changed
+835
-56
No files matched your search
+90
-32
@@ -9,11 +9,13 @@ from sqlalchemy import and_, delete, func, select
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import totp
|
||||
from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.models import KnownLogin, LoginAttempt, User
|
||||
from app.request_context import request_meta
|
||||
from app.schemas import LoginIn, TokenOut, UserOut
|
||||
from app.security import create_token, current_user, hash_password, verify_password
|
||||
from app.schemas import LoginIn, MfaLoginIn, TokenOut, UserOut
|
||||
from app.security import create_mfa_token, create_token, current_user, decode_mfa_token, hash_password, verify_password
|
||||
from app.services import ANONYMOUS, KNOWN_IP_DAYS, LOGIN_WINDOW, MAX_PER_IP, MAX_PER_LOGIN, MAX_PER_LOGIN_IP, audit
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
@@ -80,19 +82,57 @@ def _locked(retry: int) -> HTTPException:
|
||||
headers={"Retry-After": str(retry)})
|
||||
|
||||
|
||||
def _lock_pair(db: Session, name: str, ip: str | None) -> None:
|
||||
"""Сериализация попыток одного логина (изменение 024, находка №1), затем — этого IP (изменение 027, находка №7):
|
||||
порядок всегда «логин, затем IP» исключает взаимную блокировку; без второй блокировки параллельный перебор
|
||||
разных логинов с одного IP проходит проверку лимита по IP одновременно, и лимит превышается на степень
|
||||
параллелизма — ценой служит то, что попытки с одного IP (в том числе за NAT) обрабатываются по одной,
|
||||
и при массовом входе время ответа растёт на время проверки argon2/TOTP."""
|
||||
db.execute(select(func.pg_advisory_xact_lock(LOGIN_LOCK_NS, func.hashtext(name))))
|
||||
if ip is not None:
|
||||
db.execute(select(func.pg_advisory_xact_lock(IP_LOCK_NS, func.hashtext(ip))))
|
||||
|
||||
|
||||
def _record_failure(db: Session, name: str, ip: str | None, known: bool, label: str, action: str, message: str) -> int:
|
||||
"""Общий учёт неудачной попытки (пароль или второй фактор, изменение 037): та же запись в LoginAttempt и
|
||||
тот же выбор события в журнал (`session.locked`, если попытка впервые пересекла лимит; иначе `session.<action>`,
|
||||
только первая неудача по этому логину в окне). Возвращает секунды до конца блокировки (0 — не заблокирован)."""
|
||||
_, _, before = _retry_after(db, name, ip, known)
|
||||
db.add(LoginAttempt(client_ip=ip, username=name))
|
||||
db.flush()
|
||||
retry, why, after = _retry_after(db, name, ip, known)
|
||||
if retry and before[why] < after[why]: # именно этот запрос впервые пересёк лимит — запись пишем один раз
|
||||
attempts = after[why]
|
||||
diff = {"scope": why, "attempts": attempts, "retry_after_seconds": retry}
|
||||
if why == "ip":
|
||||
diff["distinct_logins"] = _distinct_logins(db, ip)
|
||||
scope_ru = {"login_ip": "по логину и IP", "ip": "с IP", "login": "по логину"}[why]
|
||||
audit(db, ANONYMOUS, "session", None, "locked", label, diff,
|
||||
message=f"Вход заблокирован на {max(1, -(-retry // 60))} мин.: {attempts} неудачных попыток ({scope_ru})")
|
||||
elif before["login"] == 0: # в журнал — только первая неудача по этому логину в окне (не по IP: иначе перебор логинов с одного IP её не оставит)
|
||||
audit(db, ANONYMOUS, "session", None, action, label, message=message)
|
||||
db.commit()
|
||||
return retry
|
||||
|
||||
|
||||
def _complete_login(db: Session, user: User, ip: str | None, name: str, diff: dict | None = None) -> TokenOut:
|
||||
"""Общее завершение успешного входа — паролем (без 2FA) или вторым фактором (изменение 037): очистка попыток,
|
||||
отметка известного IP, `last_login_*`, запись `session.login` в журнал, выдача токена доступа."""
|
||||
db.execute(delete(LoginAttempt).where(LoginAttempt.username == name))
|
||||
_remember_login(db, name, ip)
|
||||
user.last_login_at = func.now() # изменение 035
|
||||
user.last_login_ip = ip # изменение 035
|
||||
audit(db, user, "session", None, "login", user.username, diff, message=f"Вход в UI: {user.username}")
|
||||
db.commit()
|
||||
return TokenOut(access_token=create_token(user))
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenOut)
|
||||
def login(body: LoginIn, db: Session = Depends(get_db)):
|
||||
ctx = request_meta.get()
|
||||
ip = ctx["client_ip"] if ctx else None
|
||||
name = body.username.strip().lower()
|
||||
# сериализация попыток одного логина (изменение 024, находка №1), затем — этого IP (изменение 027, находка №7):
|
||||
# порядок всегда «логин, затем IP» исключает взаимную блокировку; без второй блокировки параллельный перебор
|
||||
# разных логинов с одного IP проходит проверку лимита по IP одновременно, и лимит превышается на степень
|
||||
# параллелизма — ценой служит то, что попытки с одного IP (в том числе за NAT) обрабатываются по одной,
|
||||
# и при массовом входе время ответа растёт на время проверки argon2.
|
||||
db.execute(select(func.pg_advisory_xact_lock(LOGIN_LOCK_NS, func.hashtext(name))))
|
||||
if ip is not None:
|
||||
db.execute(select(func.pg_advisory_xact_lock(IP_LOCK_NS, func.hashtext(ip))))
|
||||
_lock_pair(db, name, ip)
|
||||
known = _is_known(db, name, ip)
|
||||
retry, _, _ = _retry_after(db, name, ip, known)
|
||||
if retry: # блокировка: пароль не проверяем, в журнал не пишем (запись о блокировке уже есть)
|
||||
@@ -101,31 +141,49 @@ def login(body: LoginIn, db: Session = Depends(get_db)):
|
||||
if user is None:
|
||||
verify_password(body.password, _DUMMY_HASH)
|
||||
if user is None or not verify_password(body.password, user.password_hash):
|
||||
_, _, before = _retry_after(db, name, ip, known)
|
||||
db.add(LoginAttempt(client_ip=ip, username=name))
|
||||
db.flush()
|
||||
retry, why, after = _retry_after(db, name, ip, known)
|
||||
if retry and before[why] < after[why]: # именно этот запрос впервые пересёк лимит — запись пишем один раз
|
||||
attempts = after[why]
|
||||
diff = {"scope": why, "attempts": attempts, "retry_after_seconds": retry}
|
||||
if why == "ip":
|
||||
diff["distinct_logins"] = _distinct_logins(db, ip)
|
||||
scope_ru = {"login_ip": "по логину и IP", "ip": "с IP", "login": "по логину"}[why]
|
||||
audit(db, ANONYMOUS, "session", None, "locked", body.username[:100], diff,
|
||||
message=f"Вход заблокирован на {max(1, -(-retry // 60))} мин.: {attempts} неудачных попыток ({scope_ru})")
|
||||
elif before["login"] == 0: # в журнал — только первая неудача по этому логину в окне (не по IP: иначе перебор логинов с одного IP её не оставит)
|
||||
audit(db, ANONYMOUS, "session", None, "failed", body.username[:100], message="Неудачная попытка входа в UI")
|
||||
db.commit()
|
||||
retry = _record_failure(db, name, ip, known, body.username[:100], "failed", "Неудачная попытка входа в UI")
|
||||
if retry:
|
||||
raise _locked(retry)
|
||||
raise HTTPException(401, "Неверный логин или пароль")
|
||||
db.execute(delete(LoginAttempt).where(LoginAttempt.username == name))
|
||||
_remember_login(db, name, ip)
|
||||
user.last_login_at = func.now() # изменение 035
|
||||
user.last_login_ip = ip # изменение 035
|
||||
audit(db, user, "session", None, "login", user.username, message=f"Вход в UI: {user.username}")
|
||||
db.commit()
|
||||
return TokenOut(access_token=create_token(user))
|
||||
if user.totp_enabled: # изменение 037: второй фактор — токен доступа не выдаётся, попытки/last_login_* не трогаем
|
||||
if not settings.totp_enc_key:
|
||||
raise HTTPException(503, "2FA не настроена на сервере (TOTP_ENC_KEY)")
|
||||
return TokenOut(mfa_required=True, mfa_token=create_mfa_token(user))
|
||||
return _complete_login(db, user, ip, name)
|
||||
|
||||
|
||||
@router.post("/login/2fa", response_model=TokenOut)
|
||||
def login_2fa(body: MfaLoginIn, db: Session = Depends(get_db)):
|
||||
"""Второй шаг входа (изменение 037): код TOTP либо код восстановления. Те же лимиты и advisory-lock, что у пароля —
|
||||
неверный код тоже считается неудачной попыткой входа этого логина."""
|
||||
ctx = request_meta.get()
|
||||
ip = ctx["client_ip"] if ctx else None
|
||||
user = decode_mfa_token(body.mfa_token, db)
|
||||
name = user.username.lower()
|
||||
_lock_pair(db, name, ip)
|
||||
# изменение 037: user был загружен ДО advisory-lock — под конкурентным запросом с тем же кодом это устаревший
|
||||
# снимок (totp_last_step/totp_enabled ещё не видят чужой commit). После получения блокировки перечитываем
|
||||
# пользователя из БД, иначе два параллельных запроса с одинаковым кодом оба могут пройти проверку (гонка).
|
||||
db.refresh(user)
|
||||
known = _is_known(db, name, ip)
|
||||
retry, _, _ = _retry_after(db, name, ip, known)
|
||||
if retry:
|
||||
raise _locked(retry)
|
||||
if not user.totp_enabled: # 2FA отключена между шагами входа — токен больше не действителен
|
||||
raise HTTPException(401, "Недействительный токен")
|
||||
if not settings.totp_enc_key:
|
||||
raise HTTPException(503, "2FA не настроена на сервере (TOTP_ENC_KEY)")
|
||||
step = totp.verify(user, body.code)
|
||||
if step is not None:
|
||||
user.totp_last_step = step
|
||||
return _complete_login(db, user, ip, name)
|
||||
remaining = totp.use_recovery_code(db, user, body.code)
|
||||
if remaining is not None:
|
||||
return _complete_login(db, user, ip, name, diff={"recovery_code": True, "recovery_codes_left": remaining})
|
||||
retry = _record_failure(db, name, ip, known, user.username, "2fa_failed", "Неверный код 2FA при входе в UI")
|
||||
if retry:
|
||||
raise _locked(retry)
|
||||
raise HTTPException(401, "Неверный код")
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
|
||||
+103
-2
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user