"""Двухфакторная аутентификация 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