Files
pvemanager/backend/app/services/two_factor_service.py
markmorado 09186e23b9
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
Deploy to Production / backend-tests (push) Has been cancelled
Deploy to Production / frontend-build (push) Has been cancelled
Frontend Checks / Build & typecheck (push) Has been cancelled
SonarCloud / SonarCloud Analysis (push) Has been cancelled
Deploy to Production / Trigger production update (push) Has been cancelled
Security audit: tenant isolation, injection fixes, hardening and performance
Security
- Enforce guest ownership and server assignment across Proxmox endpoints
  (VM/LXC actions, firewall, backups, tasks, bulk operations, deploy,
  LXC/ISO creation, golden template) and WebSocket channels
- Keep the panel's PVE root ticket server-side for consoles; single-use
  WebSocket tickets instead of the JWT in URLs; re-check access on open
  WebSocket bridges
- Validate identifiers that reach Proxmox API paths and node shell
  commands; reject backup archives / foreign volumes in create requests
- Close privilege escalation in user management, TOTP replay, reset-link
  poisoning, X-Forwarded-For spoofing, stored XSS (group icons, SVG logos)
- SSH host key pinning (TOFU), encrypted 2FA seeds and notification
  secrets, password changes end other sessions, redact secrets in logs
- Quotas count in-flight creations; 2FA failures count toward lockout

Fixes
- Consoles on non-default API ports, large App Store/script files,
  interrupted tasks after restart, impersonation lost on token refresh,
  HTTPExceptions swallowed into 500s, WebSocket client reconnects

Performance and process
- Fewer DB writes and queries on hot paths, lazy-loaded locales and
  heavy pages, off-loop blocking work, indexes (migrations 44-46)
- Deploy waits for backend tests and frontend build; frontend checks
  workflow; API bound to loopback without the SSL profile
2026-10-23 00:04:16 +05:00

114 lines
3.8 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 hmac
import io
import secrets
import threading
import time
from typing import Dict, List, Optional
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)
# Last accepted time step per user. RFC 6238 §5.2: a code must not be accepted
# twice — otherwise a shoulder-surfed or phished code stays usable for the
# whole ±1-step window (~90 s). Kept in memory: the panel runs one worker.
_last_used_step: Dict[object, int] = {}
_last_used_lock = threading.Lock()
def verify_totp(secret: str, code: str, *, replay_key: Optional[object] = None) -> bool:
"""Verify a 6-digit TOTP code against the secret.
With ``replay_key`` (e.g. the user id) a code is accepted only once, and
never for a time step older than the last accepted one.
"""
if not secret or not code:
return False
code = code.strip().replace(" ", "")
if not code.isdigit():
return False
try:
totp = pyotp.TOTP(secret)
now = time.time()
for offset in range(-TOTP_VALID_WINDOW, TOTP_VALID_WINDOW + 1):
at = now + offset * totp.interval
if not hmac.compare_digest(totp.at(at), code):
continue
if replay_key is None:
return True
step = int(at // totp.interval)
with _last_used_lock:
if step <= _last_used_step.get(replay_key, -1):
return False
_last_used_step[replay_key] = step
return True
return False
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