Files
pvemanager/backend/app/services/two_factor_service.py
markmorado bec7b20e31
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 58s
Deploy to Production / Trigger production update (push) Successful in 8s
feat(auth): двухфакторная аутентификация (TOTP) для входа в панель
Бэкенд:
- two_factor_service: генерация секрета, otpauth-URI, QR как inline-SVG,
  одноразовые backup-коды (хранятся как SHA-256)
- эндпоинты /api/auth/2fa/{status,setup,enable,disable}; гейт 2FA в login
  (неверный код -> 400, чтобы SPA не сбрасывала форму); two_factor_enabled в /me
- админский сброс: POST /api/users/{id}/disable-2fa
- модель/схемы/init.sql + идемпотентная миграция №30 (two_factor_backup_codes)
- зависимости: pyotp, qrcode

Фронтенд:
- двухшаговый вход с полем кода (TOTP/backup)
- карточка 2FA в профиле: QR, проверка, backup-коды, отключение
- бейдж/сброс 2FA в списке пользователей; локали ru/en

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:08:38 +05:00

86 lines
2.7 KiB
Python

"""Two-Factor Authentication (TOTP) service.
Self-service, panel-side 2FA independent of Proxmox. Uses time-based one-time
passwords (RFC 6238) compatible with Google Authenticator, Authy, 1Password,
etc. Backup (recovery) codes are single-use and stored only as SHA-256 hashes.
"""
import hashlib
import io
import secrets
from typing import List
import pyotp
import qrcode
import qrcode.image.svg
# Number of one-time recovery codes generated when 2FA is enabled.
BACKUP_CODES_COUNT = 10
# Accept codes from the adjacent time step to tolerate clock drift (~±30s).
TOTP_VALID_WINDOW = 1
def generate_secret() -> str:
"""Generate a new base32 TOTP secret."""
return pyotp.random_base32()
def build_otpauth_uri(secret: str, username: str, issuer: str = "PVEmanager") -> str:
"""Build an otpauth:// provisioning URI for authenticator apps."""
return pyotp.TOTP(secret).provisioning_uri(name=username, issuer_name=issuer)
def verify_totp(secret: str, code: str) -> bool:
"""Verify a 6-digit TOTP code against the secret."""
if not secret or not code:
return False
code = code.strip().replace(" ", "")
if not code.isdigit():
return False
try:
return pyotp.TOTP(secret).verify(code, valid_window=TOTP_VALID_WINDOW)
except Exception:
return False
def qr_svg(uri: str) -> str:
"""Render the provisioning URI as an inline SVG string (no Pillow needed)."""
img = qrcode.make(uri, image_factory=qrcode.image.svg.SvgPathImage, box_size=10, border=2)
buf = io.BytesIO()
img.save(buf)
return buf.getvalue().decode("utf-8")
def generate_backup_codes(count: int = BACKUP_CODES_COUNT) -> List[str]:
"""Generate human-friendly single-use recovery codes (format: xxxx-xxxx)."""
codes = []
for _ in range(count):
raw = secrets.token_hex(4) # 8 hex chars
codes.append(f"{raw[:4]}-{raw[4:]}")
return codes
def hash_backup_code(code: str) -> str:
"""SHA-256 hash of a normalized backup code (case/format-insensitive)."""
normalized = code.strip().lower().replace("-", "").replace(" ", "")
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
def hash_backup_codes(codes: List[str]) -> List[str]:
return [hash_backup_code(c) for c in codes]
def consume_backup_code(code: str, stored_hashes: List[str]) -> bool:
"""Check a backup code against stored hashes; remove it in-place if matched.
Returns True and mutates ``stored_hashes`` (removing the used hash) on a
match, otherwise returns False and leaves the list unchanged.
"""
if not code or not stored_hashes:
return False
h = hash_backup_code(code)
if h in stored_hashes:
stored_hashes.remove(h)
return True
return False