Задача 037: двухфакторная аутентификация TOTP по выбору пользователя

Пользователь сам включает TOTP (QR, приложение-аутентификатор); 10 одноразовых кодов
восстановления; superadmin сбрасывает 2FA другому пользователю. Вход в два шага:
/auth/login → mfa_token, /auth/login/2fa — под теми же лимитами и advisory-lock, что пароль.
Секрет шифруется Fernet-ключом TOTP_ENC_KEY, защита от повторного использования кода
(totp_last_step), лимиты перебора кода при отключении. Миграция 0015, зависимости pyotp,
segno, cryptography. UI: второй шаг входа, диалоги 2FA в меню логина, бейдж и сброс
в «Пользователях»; submitDialog не закрывает окно, открытое следующим шагом цепочки.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ayurishchevandClaude Opus 5.5 committed 2026-09-27 16:57:14 +03:00
1 parent c852f47f09
commit d3495fb077
18 files changed
+835 -56

No files matched your search

+90 -32
View File
@@ -9,11 +9,13 @@ from sqlalchemy import and_, delete, func, select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.orm import Session
from app import totp
from app.config import settings
from app.db import get_db
from app.models import KnownLogin, LoginAttempt, User
from app.request_context import request_meta
from app.schemas import LoginIn, TokenOut, UserOut
from app.security import create_token, current_user, hash_password, verify_password
from app.schemas import LoginIn, MfaLoginIn, TokenOut, UserOut
from app.security import create_mfa_token, create_token, current_user, decode_mfa_token, hash_password, verify_password
from app.services import ANONYMOUS, KNOWN_IP_DAYS, LOGIN_WINDOW, MAX_PER_IP, MAX_PER_LOGIN, MAX_PER_LOGIN_IP, audit
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -80,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
View File
@@ -8,12 +8,19 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app import schemas as s
from app import totp
# изменение 037: лимиты и advisory-lock входа переиспользуются для /users/me/2fa/disable (иначе код 2FA
# можно перебирать неограниченно, имея украденные пароль и токен доступа) — импорт из app.api.v1.auth, а не
# дублирование в app/services.py: это специфика входа (LoginAttempt/KnownLogin), auth.py не зависит от users.py
from app.api.v1.auth import _is_known, _lock_pair, _locked, _record_failure, _retry_after
from app.config import settings
from app.db import get_db
from app.models import Organization, Role, User
from app.models import Organization, RecoveryCode, Role, User
from app.request_context import request_meta
from app.security import admin_user, create_token, current_user, hash_password, superadmin_user, verify_password
from app.services import MAX_OFFSET, apply_update, audit, commit, contains, count, flush, get_or_404, refuse_delete
@@ -122,3 +129,97 @@ def change_own_password(body: s.PasswordChange, db: Session = Depends(get_db), u
audit(db, user, "user", user, "password_reset", user.username, message=f"{user.username}: пароль изменён пользователем")
commit(db)
return {"ok": True, "access_token": create_token(user)} # прежние токены недействительны — текущая сессия получает новый
def _clear_totp(user: User) -> None:
"""Полный сброс состояния 2FA (изменение 037): используется и при своём отключении, и при сбросе суперадминистратором."""
user.totp_secret_enc = None
user.totp_pending_enc = None
user.totp_enabled_at = None
user.totp_last_step = None
@router.post("/users/me/2fa/setup", response_model=s.TotpSetupOut)
def setup_totp(body: s.TotpSetupIn, db: Session = Depends(get_db), user: User = Depends(current_user)):
"""Начало включения 2FA (изменение 037): доступно любой роли, требует текущий пароль (как /users/me/password).
Секрет кладётся в totp_pending_enc — действует до подтверждения кодом в /users/me/2fa/enable."""
if not settings.totp_enc_key:
raise HTTPException(503, "2FA не настроена на сервере (TOTP_ENC_KEY)")
if user.totp_enabled:
raise HTTPException(409, "Двухфакторная аутентификация уже включена")
if not verify_password(body.password, user.password_hash):
raise HTTPException(403, "Неверный пароль")
secret = totp.new_secret()
user.totp_pending_enc = totp.encrypt(secret)
commit(db)
uri = totp.provisioning_uri(secret, user.username)
return s.TotpSetupOut(secret=secret, otpauth_uri=uri, qr=totp.qr_svg_data_uri(uri))
@router.post("/users/me/2fa/enable", response_model=s.RecoveryCodesOut)
def enable_totp(body: s.TotpCodeIn, db: Session = Depends(get_db), user: User = Depends(current_user)):
"""Подтверждение включения 2FA кодом по pending-секрету: перенос в totp_secret_enc, выпуск кодов восстановления
(изменение 037). Коды показываются только в этом ответе — сервер их больше не хранит открытым текстом."""
if not settings.totp_enc_key:
raise HTTPException(503, "2FA не настроена на сервере (TOTP_ENC_KEY)")
if user.totp_enabled:
raise HTTPException(409, "Двухфакторная аутентификация уже включена")
if not user.totp_pending_enc:
raise HTTPException(409, "Настройка 2FA не начата: сначала POST /users/me/2fa/setup")
step = totp.check_code(totp.decrypt(user.totp_pending_enc), body.code, None)
if step is None:
raise HTTPException(422, "Неверный код")
user.totp_secret_enc = user.totp_pending_enc
user.totp_pending_enc = None
user.totp_enabled_at = datetime.now(timezone.utc)
user.totp_last_step = step
db.execute(delete(RecoveryCode).where(RecoveryCode.user_id == user.id)) # коды прошлой неудавшейся настройки не остаются
codes = totp.new_recovery_codes()
db.add_all(RecoveryCode(user_id=user.id, code_hash=h) for _, h in codes)
audit(db, user, "user", user, "2fa_enabled", user.username, message=f"{user.username}: включена двухфакторная аутентификация")
commit(db)
return s.RecoveryCodesOut(recovery_codes=[c for c, _ in codes])
@router.post("/users/me/2fa/disable")
def disable_totp(body: s.TotpDisableIn, db: Session = Depends(get_db), user: User = Depends(current_user)):
"""Отключение 2FA: пароль и код (TOTP либо восстановления). Очищает секреты и все коды восстановления (изменение 037).
Код ограничен теми же лимитами перебора, что и вход (`_retry_after`/`LoginAttempt`, изменение 037): без этого
имеющий пароль и (действующий) токен доступа мог бы перебирать 6-значный TOTP-код неограниченно."""
if not user.totp_enabled:
raise HTTPException(409, "Двухфакторная аутентификация не включена")
ctx = request_meta.get()
ip = ctx["client_ip"] if ctx else None
name = user.username.lower()
_lock_pair(db, name, ip)
known = _is_known(db, name, ip)
retry, _, _ = _retry_after(db, name, ip, known)
if retry: # блокировка: пароль и код не проверяем, как в /auth/login
raise _locked(retry)
if not verify_password(body.password, user.password_hash):
raise HTTPException(403, "Неверный пароль") # неверный пароль — как в /users/me/password, без учёта попыток
if not settings.totp_enc_key:
raise HTTPException(503, "2FA не настроена на сервере (TOTP_ENC_KEY)")
if totp.verify(user, body.code) is None and totp.use_recovery_code(db, user, body.code) is None:
retry = _record_failure(db, name, ip, known, user.username, "2fa_failed", "Неверный код 2FA при отключении")
if retry:
raise _locked(retry)
raise HTTPException(422, "Неверный код")
_clear_totp(user)
db.execute(delete(RecoveryCode).where(RecoveryCode.user_id == user.id))
audit(db, user, "user", user, "2fa_disabled", user.username, message=f"{user.username}: отключена двухфакторная аутентификация")
commit(db)
return {"ok": True}
@router.post("/users/{id}/2fa/reset")
def reset_totp(id: int, db: Session = Depends(get_db), user: User = Depends(superadmin_user)):
"""Сброс 2FA другому пользователю — только superadmin; свою 2FA отключают через /users/me/2fa/disable (изменение 037)."""
if id == user.id:
raise HTTPException(409, "Свою двухфакторную аутентификацию отключают через /users/me/2fa/disable")
u = get_or_404(db, User, id, "Пользователь")
_clear_totp(u)
db.execute(delete(RecoveryCode).where(RecoveryCode.user_id == u.id))
audit(db, user, "user", u, "2fa_reset", u.username, message=f"Пользователю {u.username}: двухфакторная аутентификация сброшена суперадминистратором {user.username}")
commit(db)
return {"ok": True}
+8
View File
@@ -10,6 +10,7 @@ class Settings(BaseSettings):
admin_username: str = "admin"
admin_password: str = ""
trusted_proxies: str = "" # CIDR через запятую: от них принимается X-Forwarded-For
totp_enc_key: str = "" # изменение 037: ключ Fernet для шифрования секретов TOTP; пусто — 2FA недоступна (503), потеря ключа = сброс 2FA всем
settings = Settings()
@@ -23,3 +24,10 @@ def validate_secrets(s: Settings = settings) -> None:
raise RuntimeError("JWT_SECRET не задан, короче 32 символов или является заглушкой: сгенерируйте значение (python scripts/gen_env.py)")
if s.admin_password and len(s.admin_password) < 8:
raise RuntimeError("ADMIN_PASSWORD короче 8 символов: пароль слабее, чем допускает UI")
if s.totp_enc_key: # изменение 037: не задан — приложение стартует, 2FA недоступна; задан некорректно — отказ старта
from cryptography.fernet import Fernet
try:
Fernet(s.totp_enc_key.encode())
except (ValueError, TypeError) as e:
raise RuntimeError("TOTP_ENC_KEY некорректен: нужен ключ Fernet (python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\")") from e
+19
View File
@@ -151,6 +151,15 @@ class User(Base):
password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # токены с iat раньше — недействительны
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # изменение 035: время последнего успешного входа
last_login_ip: Mapped[str | None] = mapped_column(INET) # изменение 035: IP последнего успешного входа
# изменение 037: двухфакторная аутентификация TOTP, по выбору пользователя
totp_secret_enc: Mapped[str | None] = mapped_column(Text) # зашифрованный (Fernet, TOTP_ENC_KEY) подтверждённый секрет
totp_pending_enc: Mapped[str | None] = mapped_column(Text) # секрет на время настройки, до подтверждения кодом
totp_enabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # NULL — 2FA выключена
totp_last_step: Mapped[int | None] = mapped_column(BigInteger) # защита от повторного использования кода: шаг должен строго возрастать
@property
def totp_enabled(self) -> bool: # изменение 037: вычисляемое поле для UserOut (from_attributes читает его как обычный атрибут)
return self.totp_enabled_at is not None
Index("uq_users_lower_username", func.lower(User.username), unique=True) # логин уникален без учёта регистра
@@ -207,3 +216,13 @@ class ClearAttempt(Base):
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class RecoveryCode(Base):
"""Коды восстановления 2FA (изменение 037): выдаются 10 штук при включении, каждый гасится один раз.
Хэш — sha256 (не argon2): код высокоэнтропийный (10 символов base32), медленный хэш не нужен."""
__tablename__ = "recovery_codes"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
code_hash: Mapped[str] = mapped_column(String(64))
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+35 -1
View File
@@ -62,8 +62,11 @@ class LoginIn(BaseModel):
class TokenOut(BaseModel):
access_token: str
# изменение 037: при включённой 2FA access_token не выдаётся сразу — вместо него mfa_token на второй шаг (/auth/login/2fa)
access_token: str | None = None
token_type: str = "bearer"
mfa_required: bool = False
mfa_token: str | None = None
LOGIN_RE = re.compile(r"^[A-Za-z0-9._-]{3,100}$")
@@ -90,6 +93,8 @@ class UserOut(ORM):
is_active: bool = True
last_login_at: datetime | None = None # изменение 035
last_login_ip: str | None = None # изменение 035
totp_enabled: bool = False # изменение 037: вычисляемое — User.totp_enabled (property), totp_enabled_at is not None
totp_enabled_at: datetime | None = None # изменение 037: для статуса «включена с <дата>» в UI
@field_validator("last_login_ip", mode="before")
@classmethod
@@ -123,6 +128,35 @@ class UserUpdate(BaseModel):
# инвариант не проверить — реконсиляция по итоговому состоянию в update_user (находка №3)
# --- 2FA (изменение 037)
class MfaLoginIn(BaseModel):
mfa_token: str
code: str = Field(max_length=20) # 6 цифр TOTP либо код восстановления вида XXXXX-XXXXX
class TotpSetupIn(BaseModel):
password: str = Field(max_length=128)
class TotpSetupOut(BaseModel):
secret: str
otpauth_uri: str
qr: str # data:image/svg+xml;base64,…
class TotpCodeIn(BaseModel):
code: str = Field(max_length=20)
class TotpDisableIn(BaseModel):
password: str = Field(max_length=128)
code: str = Field(max_length=20)
class RecoveryCodesOut(BaseModel):
recovery_codes: list[str]
class PasswordChange(BaseModel):
current_password: str
new_password: str = Field(min_length=8, max_length=128)
+25
View File
@@ -44,6 +44,13 @@ def create_token(user: User) -> str:
return jwt.encode({"sub": user.username, "iat": now, "exp": exp, "pv": _password_version(user)}, settings.jwt_secret, algorithm="HS256")
def create_mfa_token(user: User) -> str:
"""Промежуточный токен второго шага входа (изменение 037): typ="mfa", короткий срок жизни, не работает как токен доступа."""
now = datetime.now(timezone.utc)
exp = now + timedelta(minutes=5)
return jwt.encode({"sub": user.username, "iat": now, "exp": exp, "pv": _password_version(user), "typ": "mfa"}, settings.jwt_secret, algorithm="HS256")
def current_user(
cred: HTTPAuthorizationCredentials | None = Depends(_bearer), db: Session = Depends(get_db)
) -> User:
@@ -54,6 +61,8 @@ def current_user(
username = payload["sub"]
except (jwt.PyJWTError, KeyError):
raise HTTPException(401, "Недействительный токен")
if payload.get("typ"): # изменение 037: mfa_token (typ="mfa") не работает как токен доступа; токены доступа typ не имеют
raise HTTPException(401, "Недействительный токен")
user = db.scalar(select(User).where(User.username == username, User.is_active))
if user is None:
raise HTTPException(401, "Пользователь не найден")
@@ -62,6 +71,22 @@ def current_user(
return user
def decode_mfa_token(token: str, db: Session) -> User:
"""Разбор и проверка mfa_token второго шага входа (изменение 037): typ == "mfa", пользователь активен,
версия пароля совпадает (иначе токен выдан до смены пароля — второй шаг не должен переживать смену пароля)."""
try:
payload = jwt.decode(token, settings.jwt_secret, algorithms=["HS256"])
username = payload["sub"]
except (jwt.PyJWTError, KeyError):
raise HTTPException(401, "Недействительный или истёкший токен, войдите заново")
if payload.get("typ") != "mfa":
raise HTTPException(401, "Недействительный токен")
user = db.scalar(select(User).where(User.username == username, User.is_active))
if user is None or payload.get("pv", 0) != _password_version(user):
raise HTTPException(401, "Недействительный или истёкший токен, войдите заново")
return user
def admin_user(user: User = Depends(current_user)) -> User:
# изменение 032: admin_user проверяет роль != viewer (пропускает admin И superadmin)
if user.role == Role.viewer:
+110
View File
@@ -0,0 +1,110 @@
"""Двухфакторная аутентификация TOTP, по выбору пользователя (изменение 037).
Секрет хранится в БД зашифрованным (Fernet, ключ `TOTP_ENC_KEY`) — читаемый дамп БД не даёт готового секрета
для генератора кодов. Коды восстановления хранятся хэшем sha256 (не argon2: код высокоэнтропийный,
10 символов base32 — медленный хэш не даёт защиты, которая была бы нужна для короткого пользовательского пароля).
"""
import base64
import hashlib
import io
import secrets
import time
import pyotp
import segno
from cryptography.fernet import Fernet, InvalidToken
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.config import settings
from app.models import RecoveryCode, User
ISSUER = "IPAM Manager"
STEP_SECONDS = 30 # шаг TOTP по умолчанию (RFC 6238), используется и pyotp
RECOVERY_CODES_COUNT = 10
_RECOVERY_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" # base32-алфавит без спутываемых символов (0/O, 1/I и т.п. base32 не использует)
def _fernet() -> Fernet:
if not settings.totp_enc_key:
raise RuntimeError("TOTP_ENC_KEY не задан")
return Fernet(settings.totp_enc_key.encode())
def encrypt(secret: str) -> str:
return _fernet().encrypt(secret.encode()).decode()
def decrypt(token: str) -> str:
try:
return _fernet().decrypt(token.encode()).decode()
except InvalidToken as e: # смена/потеря TOTP_ENC_KEY после включения 2FA — секрет не читается
raise RuntimeError("TOTP_ENC_KEY не подходит к сохранённому секрету") from e
def new_secret() -> str:
return pyotp.random_base32()
def provisioning_uri(secret: str, username: str) -> str:
return pyotp.TOTP(secret).provisioning_uri(name=username, issuer_name=ISSUER)
def qr_svg_data_uri(uri: str) -> str:
"""QR в SVG, отрисованный на сервере (без внешних вызовов); отдаётся как data: URI — CSP уже разрешает img-src data:."""
buf = io.BytesIO()
segno.make(uri, error="m").save(buf, kind="svg", scale=4, border=2, dark="#111111", light="#ffffff", xmldecl=False)
return "data:image/svg+xml;base64," + base64.b64encode(buf.getvalue()).decode()
def check_code(secret: str, code: str, last_step: int | None) -> int | None:
"""Проверка TOTP-кода по конкретному секрету с окном ±1 шаг (изменение 037). Шаг принятого кода должен быть
строго больше `last_step` (повторное использование в том же/более раннем шаге отклоняется). Возвращает
принятый шаг (для сохранения в totp_last_step) или None, если код не подошёл."""
code = code.strip().replace(" ", "")
if not code.isdigit():
return None
totp = pyotp.TOTP(secret, interval=STEP_SECONDS)
now_step = int(time.time()) // STEP_SECONDS
for step in (now_step, now_step - 1, now_step + 1):
if last_step is not None and step <= last_step:
continue
if secrets.compare_digest(totp.generate_otp(step), code):
return step
return None
def verify(user: User, code: str) -> int | None:
"""Проверка кода по подтверждённому (включённому) секрету пользователя."""
if not user.totp_secret_enc:
return None
return check_code(decrypt(user.totp_secret_enc), code, user.totp_last_step)
def new_recovery_codes() -> list[tuple[str, str]]:
"""10 кодов восстановления вида XXXXX-XXXXX и их хэши sha256 (hex) — для сохранения в БД."""
out = []
for _ in range(RECOVERY_CODES_COUNT):
raw = "".join(secrets.choice(_RECOVERY_ALPHABET) for _ in range(10))
code = f"{raw[:5]}-{raw[5:]}"
out.append((code, hashlib.sha256(raw.encode()).hexdigest()))
return out
def _normalize_recovery(code: str) -> str:
return code.strip().upper().replace("-", "").replace(" ", "")
def use_recovery_code(db: Session, user: User, code: str) -> int | None:
"""Погашает код восстановления, если он подходит и ещё не использован. Возвращает остаток неиспользованных
кодов (может быть 0) или None, если код неверен/уже использован."""
norm = _normalize_recovery(code)
if not norm:
return None
h = hashlib.sha256(norm.encode()).hexdigest()
row = db.scalar(select(RecoveryCode).where(RecoveryCode.user_id == user.id, RecoveryCode.code_hash == h, RecoveryCode.used_at.is_(None)))
if row is None:
return None
row.used_at = func.now()
db.flush()
return db.scalar(select(func.count()).select_from(RecoveryCode).where(RecoveryCode.user_id == user.id, RecoveryCode.used_at.is_(None))) or 0