Files
pvemanager/backend/app/services/security_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

630 lines
22 KiB
Python

"""
Security Service - Authentication, sessions, and protection
"""
import secrets
import hashlib
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, func
from loguru import logger
from ..models import (
User, Role, ActiveSession, LoginAttempt, BlockedIP, SecuritySetting
)
def utcnow() -> datetime:
"""Get current UTC time as timezone-aware datetime"""
return datetime.now(timezone.utc)
def make_tz_aware(dt: Optional[datetime]) -> Optional[datetime]:
"""Ensure datetime is timezone-aware. Defaults to UTC if naive."""
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
class SecurityService:
"""Service for security operations"""
# Cache for security settings: key -> (stored value or None, cached_at).
# Each key carries its own timestamp — a single shared timestamp was
# refreshed by every miss, so a frequently-missed key kept every other
# cached value alive forever and changes made by other code paths were
# never picked up.
_settings_cache: Dict[str, Tuple[Optional[str], datetime]] = {}
_cache_ttl = 60 # seconds
# Sliding-session bookkeeping is written at most this often per session.
# validate_session runs on every authenticated request; committing an
# UPDATE each time turned every API call (and every poll) into a DB write.
_SESSION_TOUCH_INTERVAL = 60 # seconds
@classmethod
def get_setting(cls, db: Session, key: str, default: str = None) -> str:
"""Get security setting with caching"""
now = utcnow()
cached = cls._settings_cache.get(key)
if cached and (now - cached[1]).total_seconds() < cls._cache_ttl:
value = cached[0]
else:
setting = db.query(SecuritySetting).filter(SecuritySetting.key == key).first()
# Cache the stored value only (None when absent), so callers that
# pass different defaults for the same key each get their own.
value = setting.value if setting else None
cls._settings_cache[key] = (value, now)
return default if value is None else value
@classmethod
def get_setting_int(cls, db: Session, key: str, default: int = 0) -> int:
"""Get security setting as integer"""
value = cls.get_setting(db, key, str(default))
try:
return int(value)
except (ValueError, TypeError):
return default
@classmethod
def get_setting_bool(cls, db: Session, key: str, default: bool = False) -> bool:
"""Get security setting as boolean"""
value = cls.get_setting(db, key, str(default).lower())
# Handle None/empty values gracefully
if value is None:
return default
# Accept real booleans from DB/cache
if isinstance(value, bool):
return value
return str(value).lower() in ('true', '1', 'yes', 'on')
@classmethod
def update_setting(cls, db: Session, key: str, value: str) -> bool:
"""Update security setting"""
setting = db.query(SecuritySetting).filter(SecuritySetting.key == key).first()
if setting:
setting.value = value
setting.updated_at = utcnow()
else:
setting = SecuritySetting(key=key, value=value)
db.add(setting)
db.commit()
# Clear cache
cls._settings_cache.pop(key, None)
return True
# ==================== IP Blocking ====================
@staticmethod
def is_ip_blocked(db: Session, ip_address: str) -> Tuple[bool, Optional[str]]:
"""Check if IP is blocked, returns (is_blocked, reason)"""
blocked = db.query(BlockedIP).filter(BlockedIP.ip_address == ip_address).first()
if not blocked:
return False, None
if blocked.is_permanent:
return True, blocked.reason
# Compare timezone-aware datetimes
now = utcnow()
if blocked.expires_at and make_tz_aware(blocked.expires_at) > now:
return True, blocked.reason
# Block expired, remove it
db.delete(blocked)
db.commit()
return False, None
@staticmethod
def block_ip(
db: Session,
ip_address: str,
reason: str,
blocked_by: str = "system",
duration_minutes: int = 60,
permanent: bool = False
) -> BlockedIP:
"""Block an IP address"""
blocked = db.query(BlockedIP).filter(BlockedIP.ip_address == ip_address).first()
if blocked:
blocked.reason = reason
blocked.blocked_by = blocked_by
blocked.blocked_at = utcnow()
blocked.attempts_count += 1
blocked.is_permanent = permanent
if not permanent:
blocked.expires_at = utcnow() + timedelta(minutes=duration_minutes)
else:
blocked = BlockedIP(
ip_address=ip_address,
reason=reason,
blocked_by=blocked_by,
expires_at=None if permanent else utcnow() + timedelta(minutes=duration_minutes),
is_permanent=permanent,
attempts_count=1
)
db.add(blocked)
db.commit()
logger.warning(f"IP {ip_address} blocked: {reason}")
return blocked
@staticmethod
def unblock_ip(db: Session, ip_address: str) -> bool:
"""Unblock an IP address"""
blocked = db.query(BlockedIP).filter(BlockedIP.ip_address == ip_address).first()
if blocked:
db.delete(blocked)
db.commit()
logger.info(f"IP {ip_address} unblocked")
return True
return False
# ==================== Login Attempts ====================
@staticmethod
def record_login_attempt(
db: Session,
ip_address: str,
username: str = None,
success: bool = False,
failure_reason: str = None,
user_agent: str = None
) -> LoginAttempt:
"""Record a login attempt"""
attempt = LoginAttempt(
username=username,
ip_address=ip_address,
user_agent=user_agent[:500] if user_agent else None,
success=success,
failure_reason=failure_reason
)
db.add(attempt)
db.commit()
return attempt
@classmethod
def check_brute_force(cls, db: Session, ip_address: str, username: str = None) -> Tuple[bool, str]:
"""
Check for brute force attacks
Returns (should_block, reason)
"""
threshold = cls.get_setting_int(db, "ip_block_threshold", 10)
window_minutes = 30
# Count recent failed attempts from this IP
since = utcnow() - timedelta(minutes=window_minutes)
failed_count = db.query(LoginAttempt).filter(
and_(
LoginAttempt.ip_address == ip_address,
LoginAttempt.success == False,
LoginAttempt.attempted_at > since
)
).count()
if failed_count >= threshold:
return True, f"Too many failed login attempts ({failed_count})"
return False, None
# ==================== Session Management ====================
@staticmethod
def generate_session_token() -> str:
"""Generate a secure session token"""
return secrets.token_hex(32)
@classmethod
def create_session(
cls,
db: Session,
user: User,
ip_address: str = None,
user_agent: str = None
) -> Tuple[ActiveSession, bool]:
"""
Create a new session for user.
Returns (session, was_other_session_terminated)
"""
session_timeout = cls.get_setting_int(db, "session_timeout_minutes", 60)
terminated_other = False
# Create new session
token = cls.generate_session_token()
expires_at = utcnow() + timedelta(minutes=session_timeout)
# Parse device info from user agent
device_info = cls._parse_device_info(user_agent) if user_agent else None
session = ActiveSession(
user_id=user.id,
session_token=token,
ip_address=ip_address,
user_agent=user_agent[:500] if user_agent else None,
device_info=device_info,
expires_at=expires_at,
is_active=True
)
db.add(session)
db.commit()
db.refresh(session)
logger.info(f"Session created for user {user.username} from {ip_address}")
return session, terminated_other
@staticmethod
def _parse_device_info(user_agent: str) -> str:
"""Parse device info from user agent"""
if not user_agent:
return "Unknown"
ua = user_agent.lower()
# Browser detection
browser = "Unknown"
if "firefox" in ua:
browser = "Firefox"
elif "edg" in ua:
browser = "Edge"
elif "chrome" in ua:
browser = "Chrome"
elif "safari" in ua:
browser = "Safari"
elif "opera" in ua:
browser = "Opera"
# OS detection
os = "Unknown"
if "windows" in ua:
os = "Windows"
elif "mac os" in ua or "macos" in ua:
os = "macOS"
elif "linux" in ua:
os = "Linux"
elif "android" in ua:
os = "Android"
elif "iphone" in ua or "ipad" in ua:
os = "iOS"
return f"{browser} on {os}"
@staticmethod
def validate_session(db: Session, token: str) -> Optional[ActiveSession]:
"""Validate session token and return session if valid"""
session = db.query(ActiveSession).filter(
and_(
ActiveSession.session_token == token,
ActiveSession.is_active == True
)
).first()
if not session:
return None
# Check expiration
# Compare timezone-aware datetimes
now = utcnow()
if make_tz_aware(session.expires_at) < now:
session.is_active = False
db.commit()
return None
# Update last activity and extend session expiration (sliding expiration),
# throttled so steady polling does not write on every request.
last = make_tz_aware(session.last_activity)
if last is None or (now - last).total_seconds() >= SecurityService._SESSION_TOUCH_INTERVAL:
session.last_activity = now
session_timeout = SecurityService.get_setting_int(db, "session_timeout_minutes", 60)
session.expires_at = now + timedelta(minutes=session_timeout)
db.commit()
return session
@staticmethod
def is_session_live(db: Session, token: str) -> bool:
"""Read-only validity check: active and not expired, WITHOUT the sliding
extension of validate_session — background checks (WebSocket watchdog)
must not keep an idle session alive."""
session = db.query(ActiveSession).filter(
ActiveSession.session_token == token, ActiveSession.is_active == True # noqa: E712
).first()
return bool(session and make_tz_aware(session.expires_at) >= utcnow())
@staticmethod
def terminate_session(db: Session, token: str) -> bool:
"""Terminate a session"""
session = db.query(ActiveSession).filter(
ActiveSession.session_token == token
).first()
if session:
session.is_active = False
db.commit()
return True
return False
@staticmethod
def terminate_all_user_sessions(db: Session, user_id: int, except_token: Optional[str] = None) -> int:
"""Terminate all sessions for a user (optionally keeping one session alive)"""
query = db.query(ActiveSession).filter(
and_(
ActiveSession.user_id == user_id,
ActiveSession.is_active == True
)
)
if except_token:
query = query.filter(ActiveSession.session_token != except_token)
count = query.update({"is_active": False}, synchronize_session=False)
db.commit()
return count
@staticmethod
def get_user_sessions(db: Session, user_id: int) -> list:
"""Get all active sessions for a user"""
return db.query(ActiveSession).filter(
and_(
ActiveSession.user_id == user_id,
ActiveSession.is_active == True
)
).all()
@staticmethod
def cleanup_expired_sessions(db: Session) -> int:
"""Clean up expired sessions"""
count = db.query(ActiveSession).filter(
or_(
ActiveSession.expires_at < utcnow(),
ActiveSession.is_active == False
)
).delete()
db.commit()
logger.info(f"Cleaned up {count} expired sessions")
return count
# ==================== Account Locking ====================
@classmethod
def increment_failed_attempts(cls, db: Session, user: User) -> bool:
"""
Increment failed login attempts for user.
Returns True if account was locked.
"""
max_attempts = cls.get_setting_int(db, "max_login_attempts", 5)
lockout_minutes = cls.get_setting_int(db, "lockout_duration_minutes", 30)
user.failed_login_attempts = (user.failed_login_attempts or 0) + 1
if user.failed_login_attempts >= max_attempts:
user.locked_until = utcnow() + timedelta(minutes=lockout_minutes)
db.commit()
logger.warning(f"User {user.username} locked out after {user.failed_login_attempts} failed attempts")
return True
db.commit()
return False
@staticmethod
def reset_failed_attempts(db: Session, user: User):
"""Reset failed login attempts after successful login"""
user.failed_login_attempts = 0
user.locked_until = None
db.commit()
@staticmethod
def is_account_locked(user: User) -> Tuple[bool, Optional[datetime]]:
"""Check if account is locked, returns (is_locked, locked_until)"""
if not user.locked_until:
return False, None
# Compare timezone-aware datetimes
if make_tz_aware(user.locked_until) > utcnow():
return True, user.locked_until
return False, None
# ==================== Role & Permission Management ====================
@staticmethod
def get_role(db: Session, role_name: str) -> Optional[Role]:
"""Get role by name"""
return db.query(Role).filter(Role.name == role_name).first()
@staticmethod
def get_all_roles(db: Session, include_inactive: bool = False) -> list:
"""Get all roles"""
query = db.query(Role)
if not include_inactive:
query = query.filter(Role.is_active == True)
return query.order_by(Role.name).all()
@staticmethod
def create_role(
db: Session,
name: str,
display_name: str,
description: str = None,
permissions: dict = None
) -> Role:
"""Create a new role"""
role = Role(
name=name.lower(),
display_name=display_name,
description=description,
permissions=permissions or {},
is_system=False
)
db.add(role)
db.commit()
db.refresh(role)
return role
@staticmethod
def update_role(
db: Session,
role_id: int,
display_name: str = None,
description: str = None,
permissions: dict = None
) -> Optional[Role]:
"""Update a role"""
role = db.query(Role).filter(Role.id == role_id).first()
if not role:
return None
if display_name:
role.display_name = display_name
if description is not None:
role.description = description
if permissions is not None:
role.permissions = permissions
role.updated_at = utcnow()
db.commit()
db.refresh(role)
return role
@staticmethod
def delete_role(db: Session, role_id: int) -> Tuple[bool, str]:
"""Delete a role if it's not a system role"""
role = db.query(Role).filter(Role.id == role_id).first()
if not role:
return False, "Role not found"
if role.is_system:
return False, "Cannot delete system role"
# Check if any users have this role
user_count = db.query(User).filter(User.role_id == role_id).count()
if user_count > 0:
return False, f"Cannot delete role with {user_count} assigned users"
db.delete(role)
db.commit()
return True, "Role deleted"
@staticmethod
def get_all_permissions() -> Dict[str, Dict[str, str]]:
"""
Get all available permissions grouped by category.
Returns permissions in legacy format for backwards compatibility with UI.
"""
try:
from ..rbac import get_permission_categories_legacy
return get_permission_categories_legacy()
except ImportError:
# Fallback to static list if RBAC module not available
return {
"Dashboard": {
"dashboard.view": "View dashboard"
},
"Proxmox Servers": {
"proxmox.view": "View servers",
"proxmox.manage": "Manage server settings",
"proxmox.servers.add": "Add new servers",
"proxmox.servers.edit": "Edit servers",
"proxmox.servers.delete": "Delete servers"
},
"Virtual Machines": {
"vms.view": "View VMs",
"vms.create": "Create VMs",
"vms.start": "Start VMs",
"vms.stop": "Stop VMs",
"vms.restart": "Restart VMs",
"vms.delete": "Delete VMs",
"vms.console": "Access VM console",
"vms.migrate": "Migrate VMs"
},
"Templates": {
"templates.view": "View templates",
"templates.manage": "Manage templates"
},
"IPAM": {
"ipam.view": "View IPAM",
"ipam.manage": "Manage IPAM"
},
"Logs": {
"logs.view": "View logs",
"logs.export": "Export logs",
"logs.delete": "Delete logs"
},
"Settings": {
"settings.view": "View settings",
"settings.panel": "Panel settings",
"settings.security": "Security settings"
},
"Users": {
"users.view": "View users",
"users.create": "Create users",
"users.edit": "Edit users",
"users.delete": "Delete users"
},
"Roles": {
"roles.view": "View roles",
"roles.manage": "Manage roles"
},
"Notifications": {
"notifications.manage": "Manage notifications"
}
}
@staticmethod
def get_all_permissions_v2() -> Dict[str, Dict[str, str]]:
"""
Get all available permissions in new format grouped by category.
Uses new resource:action format.
"""
try:
from ..rbac import get_permission_categories
return get_permission_categories()
except ImportError:
return SecurityService.get_all_permissions()
# ==================== Password Validation ====================
@classmethod
def validate_password(cls, db: Session, password: str, lang: str = "en") -> Tuple[bool, list]:
"""
Validate password against security requirements.
Returns (is_valid, list_of_errors)
"""
from ..i18n import t
errors = []
min_length = cls.get_setting_int(db, "password_min_length", 8)
require_upper = cls.get_setting_bool(db, "password_require_uppercase", True)
require_lower = cls.get_setting_bool(db, "password_require_lowercase", True)
require_numbers = cls.get_setting_bool(db, "password_require_numbers", True)
require_special = cls.get_setting_bool(db, "password_require_special", False)
if len(password) < min_length:
errors.append(t("password_error_min_length", lang, min_length=min_length))
if require_upper and not any(c.isupper() for c in password):
errors.append(t("password_error_uppercase", lang))
if require_lower and not any(c.islower() for c in password):
errors.append(t("password_error_lowercase", lang))
if require_numbers and not any(c.isdigit() for c in password):
errors.append(t("password_error_number", lang))
if require_special and not any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in password):
errors.append(t("password_error_special", lang))
return len(errors) == 0, errors