Files
pvemanager/backend/app/auth.py
Dilshod Rasulov 50f2ee1966
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 46s
fix(security): authenticate VNC/terminal WebSockets and harden SSH/config
Critical security and robustness fixes from a code audit:

- WS terminal/VNC now require a JWT (?token=), validate the session,
  vms.console permission and VM ownership before bridging to Proxmox
  (previously the LXC terminal gave unauthenticated root shell access).
  Frontend ConsolePage passes the token in both WS URLs.
- Fix SSH fallback for `pvecm delnode`: SSHClient now supports password
  auth (it previously crashed with TypeError); ProxmoxClient stores the
  password for SSH fallbacks and the host port suffix is stripped.
- Add production startup warnings for default ADMIN_PASSWORD and wildcard
  CORS_ORIGINS.
- dashboard: use tz-aware utcnow() instead of naive datetime.utcnow().
- crypto: narrow decrypt_value except to InvalidToken and log failures.
- ssh_client: guard the shared connection cache with a lock; make
  unknown-host-key rejection opt-in via SSH_REJECT_UNKNOWN_HOSTS.
- logging: send loguru console output to stderr instead of stdout.
- Sync version fallback to 1.5.1; remove one-off refactor scripts.
- Add CODE_REVIEW.md audit report.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 06:31:26 +05:00

274 lines
8.5 KiB
Python

"""
Authentication module with enhanced security
"""
from datetime import datetime, timedelta, timezone
from typing import Optional
from functools import wraps
import jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session, joinedload
from .config import settings
from .db import get_db
from .models import User
# Password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# JWT Bearer token
security = HTTPBearer()
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against a hash"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Hash a password"""
return pwd_context.hash(password)
def create_access_token(data: dict, session_token: str = None, expires_delta: Optional[timedelta] = None) -> str:
"""Create JWT access token with optional session binding"""
to_encode = data.copy()
now = datetime.now(timezone.utc)
if expires_delta:
expire = now + expires_delta
else:
expire = now + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({
"exp": expire,
"iat": now
})
# Include session token for validation
if session_token:
to_encode["session"] = session_token
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def decode_access_token(token: str) -> Optional[dict]:
"""Decode JWT access token"""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has expired",
headers={"WWW-Authenticate": "Bearer"},
)
except jwt.PyJWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
) -> User:
"""Get current authenticated user with session validation"""
token = credentials.credentials
payload = decode_access_token(token)
username: str = payload.get("sub")
session_token: str = payload.get("session")
if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
# Validate session if present
if session_token:
from .services.security_service import SecurityService
session = SecurityService.validate_session(db, session_token)
if not session:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session expired or invalid. Please login again.",
headers={"WWW-Authenticate": "Bearer"},
)
user = db.query(User).options(joinedload(User.role)).filter(User.username == username).first()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is disabled"
)
# Check if account is locked
if user.is_locked():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account is locked. Please try again later."
)
return user
def get_current_session_token(
credentials: HTTPAuthorizationCredentials = Depends(security)
) -> str:
"""Get current session token from JWT"""
token = credentials.credentials
payload = decode_access_token(token)
return payload.get("session")
def authenticate_ws_token(token: Optional[str], db: Session) -> Optional[User]:
"""Authenticate a WebSocket connection from a JWT passed as a query parameter.
Browsers cannot set Authorization headers on WebSocket handshakes, so the
token is supplied via ?token=<jwt>. Returns the authenticated, active User
(with a valid session, if the token is session-bound) or ``None`` on any
failure. Unlike ``decode_access_token`` this never raises — callers close
the socket with an appropriate code instead.
"""
if not token:
return None
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
except jwt.PyJWTError:
return None
username = payload.get("sub")
if not username:
return None
# Validate the bound session, mirroring get_current_user.
session_token = payload.get("session")
if session_token:
from .services.security_service import SecurityService
if not SecurityService.validate_session(db, session_token):
return None
user = db.query(User).options(joinedload(User.role)).filter(User.username == username).first()
if user is None or not user.is_active:
return None
if user.is_locked():
return None
return user
def get_current_active_admin(current_user: User = Depends(get_current_user)) -> User:
"""Get current active admin user"""
if not current_user.is_admin and (not current_user.role or current_user.role.name != "admin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Administrator access required"
)
return current_user
class PermissionChecker:
"""
Dependency for checking permissions.
Usage: Depends(PermissionChecker("vms.create"))
Supports both legacy (vms.create) and new (vm:create) permission formats.
"""
def __init__(self, permission: str):
self.permission = permission
def __call__(self, current_user: User = Depends(get_current_user)) -> User:
# Use new RBAC engine
from .rbac import PermissionEngine
if not PermissionEngine.has_permission(current_user, self.permission):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Permission denied: {self.permission}"
)
return current_user
def check_permission(user: User, permission: str) -> bool:
"""Check if user has specific permission"""
from .rbac import PermissionEngine
return PermissionEngine.has_permission(user, permission)
def require_permission(user: User, permission: str) -> None:
"""Require user to have specific permission, raise 403 if not"""
from .rbac import PermissionEngine
PermissionEngine.check_permission(user, permission)
def _validate_ip(ip: str) -> bool:
"""Return True if ip is a valid IPv4 or IPv6 address."""
import ipaddress
try:
ipaddress.ip_address(ip)
return True
except ValueError:
return False
def get_client_ip(request: Request) -> str:
"""Get client IP from request, handling proxies.
Only X-Forwarded-For / X-Real-IP values that parse as valid IP addresses
are trusted, preventing header-injection / IP-spoofing attacks.
"""
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
candidate = forwarded.split(",")[0].strip()
if _validate_ip(candidate):
return candidate
real_ip = request.headers.get("x-real-ip", "").strip()
if real_ip and _validate_ip(real_ip):
return real_ip
if request.client:
return request.client.host
return "unknown"
# Optional auth - allows both authenticated and anonymous access
class OptionalHTTPBearer(HTTPBearer):
async def __call__(self, request: Request):
try:
return await super().__call__(request)
except HTTPException:
return None
optional_security = OptionalHTTPBearer()
def get_current_user_optional(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(optional_security),
db: Session = Depends(get_db)
) -> Optional[User]:
"""Get current user if authenticated, None otherwise"""
if credentials is None:
return None
try:
return get_current_user(credentials, db)
except HTTPException:
return None