Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 46s
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>
274 lines
8.5 KiB
Python
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
|