diff --git a/AppImage/components/proxmox-dashboard.tsx b/AppImage/components/proxmox-dashboard.tsx index ae092b5..d62fe27 100644 --- a/AppImage/components/proxmox-dashboard.tsx +++ b/AppImage/components/proxmox-dashboard.tsx @@ -311,11 +311,6 @@ export function ProxmoxDashboard() { try { const token = localStorage.getItem("proxmenux-auth-token") - const hasDeclined = localStorage.getItem("proxmenux-auth-declined") === "true" - - console.log("[v0] Token in localStorage:", token ? "EXISTS" : "NOT FOUND") - console.log("[v0] Has declined flag:", hasDeclined) - const headers: HeadersInit = { "Content-Type": "application/json" } if (token) { @@ -325,40 +320,23 @@ export function ProxmoxDashboard() { const apiUrl = getApiUrl("/api/auth/status") console.log("[v0] Auth status API URL:", apiUrl) - const response = await fetch(apiUrl, { - headers, - }) - + const response = await fetch(apiUrl, { headers }) const data = await response.json() console.log("[v0] Auth status response data:", JSON.stringify(data, null, 2)) setAuthRequired(data.auth_enabled) setIsAuthenticated(data.authenticated) - // If auth is not configured and user hasn't declined, show the modal - const shouldShowModal = !data.auth_configured && !hasDeclined - console.log("[v0] Auth configured:", data.auth_configured) - console.log("[v0] Should show modal:", shouldShowModal) - - setAuthDeclined(!shouldShowModal) + // auth_configured will be true if user either set up auth OR skipped it + const shouldShowModal = !data.auth_configured + setAuthDeclined(data.auth_configured) // If configured (either way), don't show modal setAuthChecked(true) - console.log("[v0] Final auth state:", { - authRequired: data.auth_enabled, - isAuthenticated: data.authenticated, - authConfigured: data.auth_configured, - hasDeclined: hasDeclined, - shouldShowModal: shouldShowModal, - authDeclined: !shouldShowModal, - }) - console.log("[v0] ===== AUTH CHECK END =====") - if (data.authenticated && token) { setupTokenRefresh() } } catch (error) { console.error("[v0] Failed to check auth status:", error) - console.log("[v0] ===== AUTH CHECK FAILED =====") setAuthDeclined(false) setAuthChecked(true) } diff --git a/AppImage/scripts/auth_manager.py b/AppImage/scripts/auth_manager.py new file mode 100644 index 0000000..05dccd8 --- /dev/null +++ b/AppImage/scripts/auth_manager.py @@ -0,0 +1,277 @@ +""" +Authentication Manager Module +Handles all authentication-related operations including: +- Loading/saving auth configuration +- Password hashing and verification +- JWT token generation and validation +- Auth status checking +""" + +import os +import json +import hashlib +from datetime import datetime, timedelta +from pathlib import Path + +try: + import jwt + JWT_AVAILABLE = True +except ImportError: + JWT_AVAILABLE = False + print("Warning: PyJWT not available. Authentication features will be limited.") + +# Configuration +CONFIG_DIR = Path.home() / ".config" / "proxmenux-monitor" +AUTH_CONFIG_FILE = CONFIG_DIR / "auth.json" +JWT_SECRET = "proxmenux-monitor-secret-key-change-in-production" +JWT_ALGORITHM = "HS256" +TOKEN_EXPIRATION_HOURS = 24 + + +def ensure_config_dir(): + """Ensure the configuration directory exists""" + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + + +def load_auth_config(): + """ + Load authentication configuration from file + Returns dict with structure: + { + "enabled": bool, + "username": str, + "password_hash": str, + "declined": bool, # True if user explicitly declined auth + "configured": bool # True if auth has been set up (enabled or declined) + } + """ + if not AUTH_CONFIG_FILE.exists(): + return { + "enabled": False, + "username": None, + "password_hash": None, + "declined": False, + "configured": False + } + + try: + with open(AUTH_CONFIG_FILE, 'r') as f: + config = json.load(f) + # Ensure all required fields exist + config.setdefault("declined", False) + config.setdefault("configured", config.get("enabled", False) or config.get("declined", False)) + return config + except Exception as e: + print(f"Error loading auth config: {e}") + return { + "enabled": False, + "username": None, + "password_hash": None, + "declined": False, + "configured": False + } + + +def save_auth_config(config): + """Save authentication configuration to file""" + ensure_config_dir() + try: + with open(AUTH_CONFIG_FILE, 'w') as f: + json.dump(config, f, indent=2) + return True + except Exception as e: + print(f"Error saving auth config: {e}") + return False + + +def hash_password(password): + """Hash a password using SHA-256""" + return hashlib.sha256(password.encode()).hexdigest() + + +def verify_password(password, password_hash): + """Verify a password against its hash""" + return hash_password(password) == password_hash + + +def generate_token(username): + """Generate a JWT token for the given username""" + if not JWT_AVAILABLE: + return None + + payload = { + 'username': username, + 'exp': datetime.utcnow() + timedelta(hours=TOKEN_EXPIRATION_HOURS), + 'iat': datetime.utcnow() + } + + try: + token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) + return token + except Exception as e: + print(f"Error generating token: {e}") + return None + + +def verify_token(token): + """ + Verify a JWT token + Returns username if valid, None otherwise + """ + if not JWT_AVAILABLE or not token: + return None + + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + return payload.get('username') + except jwt.ExpiredSignatureError: + print("Token has expired") + return None + except jwt.InvalidTokenError as e: + print(f"Invalid token: {e}") + return None + + +def get_auth_status(): + """ + Get current authentication status + Returns dict with: + { + "enabled": bool, + "configured": bool, + "declined": bool, + "username": str or None + } + """ + config = load_auth_config() + return { + "enabled": config.get("enabled", False), + "configured": config.get("configured", False), + "declined": config.get("declined", False), + "username": config.get("username") if config.get("enabled") else None + } + + +def setup_auth(username, password): + """ + Set up authentication with username and password + Returns (success: bool, message: str) + """ + if not username or not password: + return False, "Username and password are required" + + if len(password) < 6: + return False, "Password must be at least 6 characters" + + config = { + "enabled": True, + "username": username, + "password_hash": hash_password(password), + "declined": False, + "configured": True + } + + if save_auth_config(config): + return True, "Authentication configured successfully" + else: + return False, "Failed to save authentication configuration" + + +def decline_auth(): + """ + Mark authentication as declined by user + Returns (success: bool, message: str) + """ + config = load_auth_config() + config["enabled"] = False + config["declined"] = True + config["configured"] = True + config["username"] = None + config["password_hash"] = None + + if save_auth_config(config): + return True, "Authentication declined" + else: + return False, "Failed to save configuration" + + +def disable_auth(): + """ + Disable authentication (different from decline - can be re-enabled) + Returns (success: bool, message: str) + """ + config = load_auth_config() + config["enabled"] = False + # Keep configured=True and don't set declined=True + # This allows re-enabling without showing the setup modal again + + if save_auth_config(config): + return True, "Authentication disabled" + else: + return False, "Failed to save configuration" + + +def enable_auth(): + """ + Enable authentication (must already be configured) + Returns (success: bool, message: str) + """ + config = load_auth_config() + + if not config.get("username") or not config.get("password_hash"): + return False, "Authentication not configured. Please set up username and password first." + + config["enabled"] = True + config["declined"] = False + + if save_auth_config(config): + return True, "Authentication enabled" + else: + return False, "Failed to save configuration" + + +def change_password(old_password, new_password): + """ + Change the authentication password + Returns (success: bool, message: str) + """ + config = load_auth_config() + + if not config.get("enabled"): + return False, "Authentication is not enabled" + + if not verify_password(old_password, config.get("password_hash", "")): + return False, "Current password is incorrect" + + if len(new_password) < 6: + return False, "New password must be at least 6 characters" + + config["password_hash"] = hash_password(new_password) + + if save_auth_config(config): + return True, "Password changed successfully" + else: + return False, "Failed to save new password" + + +def authenticate(username, password): + """ + Authenticate a user with username and password + Returns (success: bool, token: str or None, message: str) + """ + config = load_auth_config() + + if not config.get("enabled"): + return False, None, "Authentication is not enabled" + + if username != config.get("username"): + return False, None, "Invalid username or password" + + if not verify_password(password, config.get("password_hash", "")): + return False, None, "Invalid username or password" + + token = generate_token(username) + if token: + return True, token, "Authentication successful" + else: + return False, None, "Failed to generate authentication token" diff --git a/AppImage/scripts/flask_auth_routes.py b/AppImage/scripts/flask_auth_routes.py new file mode 100644 index 0000000..28ff338 --- /dev/null +++ b/AppImage/scripts/flask_auth_routes.py @@ -0,0 +1,116 @@ +""" +Flask Authentication Routes +Provides REST API endpoints for authentication management +""" + +from flask import jsonify, request +import auth_manager + + +def register_auth_routes(app): + """Register authentication routes with the Flask app""" + + @app.route('/api/auth/status', methods=['GET']) + def auth_status(): + """Get current authentication status""" + try: + status = auth_manager.get_auth_status() + return jsonify(status) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + + @app.route('/api/auth/setup', methods=['POST']) + def auth_setup(): + """Set up authentication with username and password""" + try: + data = request.json + username = data.get('username') + password = data.get('password') + + success, message = auth_manager.setup_auth(username, password) + + if success: + return jsonify({"success": True, "message": message}) + else: + return jsonify({"success": False, "message": message}), 400 + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + + + @app.route('/api/auth/decline', methods=['POST']) + def auth_decline(): + """Decline authentication setup""" + try: + success, message = auth_manager.decline_auth() + + if success: + return jsonify({"success": True, "message": message}) + else: + return jsonify({"success": False, "message": message}), 400 + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + + + @app.route('/api/auth/login', methods=['POST']) + def auth_login(): + """Authenticate user and return JWT token""" + try: + data = request.json + username = data.get('username') + password = data.get('password') + + success, token, message = auth_manager.authenticate(username, password) + + if success: + return jsonify({"success": True, "token": token, "message": message}) + else: + return jsonify({"success": False, "message": message}), 401 + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + + + @app.route('/api/auth/enable', methods=['POST']) + def auth_enable(): + """Enable authentication""" + try: + success, message = auth_manager.enable_auth() + + if success: + return jsonify({"success": True, "message": message}) + else: + return jsonify({"success": False, "message": message}), 400 + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + + + @app.route('/api/auth/disable', methods=['POST']) + def auth_disable(): + """Disable authentication""" + try: + success, message = auth_manager.disable_auth() + + if success: + return jsonify({"success": True, "message": message}) + else: + return jsonify({"success": False, "message": message}), 400 + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + + + @app.route('/api/auth/change-password', methods=['POST']) + def auth_change_password(): + """Change authentication password""" + try: + data = request.json + old_password = data.get('old_password') + new_password = data.get('new_password') + + success, message = auth_manager.change_password(old_password, new_password) + + if success: + return jsonify({"success": True, "message": message}) + else: + return jsonify({"success": False, "message": message}), 400 + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 5eaab60..3f7d66f 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -28,275 +28,13 @@ import jwt from functools import wraps from pathlib import Path +from flask_auth_routes import auth_bp + app = Flask(__name__) CORS(app) # Enable CORS for Next.js frontend -# Authentication configuration -AUTH_CONFIG_DIR = Path.home() / ".config" / "proxmenux-monitor" -AUTH_CONFIG_FILE = AUTH_CONFIG_DIR / "auth.json" -JWT_SECRET = secrets.token_hex(32) # Generate a random secret for JWT -SESSION_TIMEOUT = 30 * 60 # 30 minutes in seconds +app.register_blueprint(auth_bp) -# Ensure config directory exists -AUTH_CONFIG_DIR.mkdir(parents=True, exist_ok=True) - -def hash_password(password: str) -> str: - """Hash a password using SHA-256""" - return hashlib.sha256(password.encode()).hexdigest() - -def load_auth_config(): - """Load authentication configuration from file""" - if not AUTH_CONFIG_FILE.exists(): - return {} # Return empty dict if file doesn't exist - - try: - with open(AUTH_CONFIG_FILE, 'r') as f: - return json.load(f) - except (json.JSONDecodeError, FileNotFoundError): # Handle potential errors - return {} # Return empty dict on error - -def save_auth_config(config): - """Save authentication configuration to file""" - with open(AUTH_CONFIG_FILE, 'w') as f: - json.dump(config, f, indent=2) - -def require_auth(f): - """Decorator to require authentication for endpoints""" - @wraps(f) - def decorated_function(*args, **kwargs): - auth_config = load_auth_config() - - # If auth is not enabled, allow access - if not auth_config.get("auth_enabled", False): - return f(*args, **kwargs) - - # Check for Authorization header - auth_header = request.headers.get('Authorization') - if not auth_header or not auth_header.startswith('Bearer '): - return jsonify({"error": "Authentication required"}), 401 - - token = auth_header.split(' ')[1] - - try: - # Verify JWT token - payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256']) - - # Check if token is expired - if time.time() > payload.get('exp', 0): - return jsonify({"error": "Token expired"}), 401 - - return f(*args, **kwargs) - except jwt.InvalidTokenError: - return jsonify({"error": "Invalid token"}), 401 - - return decorated_function - -# Authentication endpoints -@app.route('/api/auth/status', methods=['GET']) -def auth_status(): - """Check if authentication is enabled and if current session is valid""" - try: - auth_config = load_auth_config() - is_configured = auth_config is not None and len(auth_config) > 0 - is_enabled = auth_config.get("auth_enabled", False) if is_configured else False - - # Check if user has valid token - is_authenticated = False - if is_enabled: - auth_header = request.headers.get('Authorization') - if auth_header and auth_header.startswith('Bearer '): - token = auth_header.split(' ')[1] - try: - payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256']) - if time.time() <= payload.get('exp', 0): - is_authenticated = True - except: - pass - - return jsonify({ - "auth_enabled": is_enabled, - "auth_configured": is_configured, # New field to indicate if auth has been set up - "authenticated": is_authenticated or not is_enabled - }) - except Exception as e: - return jsonify({"error": str(e)}), 500 - -@app.route('/api/auth/setup', methods=['POST']) -def auth_setup(): - """Setup authentication for the first time""" - try: - data = request.get_json() - username = data.get('username', '').strip() - password = data.get('password', '').strip() - - if not username or not password: - return jsonify({"error": "Username and password are required"}), 400 - - if len(password) < 6: - return jsonify({"error": "Password must be at least 6 characters"}), 400 - - # Hash password and save config - password_hash = hash_password(password) - - auth_config = { - "auth_enabled": True, - "username": username, - "password_hash": password_hash, - "created_at": datetime.now().isoformat() - } - - save_auth_config(auth_config) - - # Generate JWT token - token = jwt.encode({ - 'username': username, - 'exp': time.time() + SESSION_TIMEOUT - }, JWT_SECRET, algorithm='HS256') - - return jsonify({ - "success": True, - "token": token - }) - except Exception as e: - return jsonify({"error": str(e)}), 500 - -@app.route('/api/auth/skip', methods=['POST']) -def auth_skip(): - """Skip authentication setup""" - try: - auth_config = { - "auth_enabled": False, - "skipped": True, - "skipped_at": datetime.now().isoformat() - } - - save_auth_config(auth_config) - - return jsonify({"success": True}) - except Exception as e: - return jsonify({"error": str(e)}), 500 - -@app.route('/api/auth/login', methods=['POST']) -def auth_login(): - """Login with username and password""" - try: - data = request.get_json() - username = data.get('username', '').strip() - password = data.get('password', '').strip() - - if not username or not password: - return jsonify({"error": "Username and password are required"}), 400 - - # Load auth config - auth_config = load_auth_config() - - if not auth_config or not auth_config.get("auth_enabled", False): - return jsonify({"error": "Authentication is not enabled"}), 400 - - # Verify credentials - stored_username = auth_config.get("username", "") - stored_password_hash = auth_config.get("password_hash", "") - - if username != stored_username or hash_password(password) != stored_password_hash: - return jsonify({"error": "Invalid username or password"}), 401 - - # Generate JWT token - token = jwt.encode({ - 'username': username, - 'exp': time.time() + SESSION_TIMEOUT - }, JWT_SECRET, algorithm='HS256') - - return jsonify({ - "success": True, - "token": token - }) - except Exception as e: - return jsonify({"error": str(e)}), 500 - -@app.route('/api/auth/refresh', methods=['POST']) -def auth_refresh(): - """Refresh JWT token""" - try: - auth_header = request.headers.get('Authorization') - if not auth_header or not auth_header.startswith('Bearer '): - return jsonify({"error": "No token provided"}), 401 - - token = auth_header.split(' ')[1] - - try: - # Verify current token - payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256']) - username = payload.get('username') - - # Generate new token - new_token = jwt.encode({ - 'username': username, - 'exp': time.time() + SESSION_TIMEOUT - }, JWT_SECRET, algorithm='HS256') - - return jsonify({ - "success": True, - "token": new_token - }) - except jwt.InvalidTokenError: - return jsonify({"error": "Invalid token"}), 401 - except Exception as e: - return jsonify({"error": str(e)}), 500 - -@app.route('/api/auth/logout', methods=['POST']) -@require_auth -def auth_logout(): - """Logout (client should delete token)""" - return jsonify({"success": True}) - -@app.route('/api/auth/disable', methods=['POST']) -@require_auth -def auth_disable(): - """Disable authentication""" - try: - auth_config = { - "auth_enabled": False, - "disabled_at": datetime.now().isoformat() - } - - save_auth_config(auth_config) - - return jsonify({"success": True}) - except Exception as e: - return jsonify({"error": str(e)}), 500 - -@app.route('/api/auth/change-password', methods=['POST']) -@require_auth -def auth_change_password(): - """Change password""" - try: - data = request.get_json() - current_password = data.get('current_password', '').strip() - new_password = data.get('new_password', '').strip() - - if not current_password or not new_password: - return jsonify({"error": "Current and new password are required"}), 400 - - if len(new_password) < 6: - return jsonify({"error": "New password must be at least 6 characters"}), 400 - - # Load auth config - auth_config = load_auth_config() - - # Verify current password - stored_password_hash = auth_config.get("password_hash", "") - if hash_password(current_password) != stored_password_hash: - return jsonify({"error": "Current password is incorrect"}), 401 - - # Update password - auth_config["password_hash"] = hash_password(new_password) - auth_config["updated_at"] = datetime.now().isoformat() - - save_auth_config(auth_config) - - return jsonify({"success": True}) - except Exception as e: - return jsonify({"error": str(e)}), 500 # app = Flask(__name__) # CORS(app) # Enable CORS for Next.js frontend @@ -2465,7 +2203,7 @@ def get_proxmox_vms(): # print(f"[v0] Error getting VM/LXC info: {e}") pass return { - 'error': f'Unable to access VM information: {str(e)}', + 'error': 'Unable to access VM information: {str(e)}', 'vms': [] } except Exception as e: @@ -3567,7 +3305,7 @@ def get_detailed_gpu_info(gpu): gfx_clock = clocks['GFX_SCLK'] if 'value' in gfx_clock: detailed_info['clock_graphics'] = f"{gfx_clock['value']} MHz" - # print(f"[v0] Graphics Clock: {detailed_info['clock_graphics']}", flush=True) + # print(f"[v0] Graphics Clock: {detailed_info['clock_graphics']} MHz", flush=True) pass data_retrieved = True @@ -4380,7 +4118,7 @@ def get_hardware_info(): # print(f"[v0] Error getting storage info: {e}") pass - # Graphics Cards (from lspci - will be duplicated by new PCI device listing, but kept for now) + # Graphics Cards try: # Try nvidia-smi first result = subprocess.run(['nvidia-smi', '--query-gpu=name,memory.total,memory.used,temperature.gpu,power.draw,utilization.gpu,utilization.memory,clocks.graphics,clocks.memory', '--format=csv,noheader,nounits'],