From 11c1b6379b6415d19458617e4099a24f06de2502 Mon Sep 17 00:00:00 2001 From: iclaoudezin Date: Wed, 30 Sep 2026 12:03:36 +0000 Subject: [PATCH] Harden auth and API: username change, 2FA fixes, input validation - Add POST /api/auth/change-username (password + OTP when 2FA is on, format/reserved-name checks, uniqueness) and a Change Username modal in Account.vue; use the real username for the 2FA provisioning URI. - Stop creating the built-in admin/password user; the initial admin is seeded only from OVPMON_INITIAL_ADMIN_USER/PASSWORD. - Reject 2FA-pending temporary tokens on all protected routes (Flask token_required, Profiler verify_token); only /api/auth/verify-2fa accepts them. - Stop logging the OTP and TOTP secret in enable_2fa. - Profiler: validate profile username (pattern + realpath checks in schema, router, pki and generator) to prevent path traversal. - Restrict CORS to the panel origin in Profiler and Flask APIs. - UI: header username no longer sticks to the hardcoded Admin fallback; it is synced from /user/me and updated after a rename. Co-Authored-By: Claude Sonnet 5.5 --- APP_CORE/openvpn_api_v3.py | 88 +++++++++++++++++++++--- APP_PROFILER/main.py | 6 +- APP_PROFILER/routers/profiles.py | 8 +++ APP_PROFILER/schemas.py | 2 +- APP_PROFILER/services/generator.py | 5 ++ APP_PROFILER/services/pki.py | 10 +++ APP_PROFILER/utils/auth.py | 8 +++ APP_UI/src/App.vue | 26 +++++++- APP_UI/src/views/Account.vue | 104 +++++++++++++++++++++++++++++ 9 files changed, 241 insertions(+), 16 deletions(-) diff --git a/APP_CORE/openvpn_api_v3.py b/APP_CORE/openvpn_api_v3.py index 21e98b0..80be03d 100644 --- a/APP_CORE/openvpn_api_v3.py +++ b/APP_CORE/openvpn_api_v3.py @@ -25,7 +25,7 @@ logger = logging.getLogger(__name__) app = Flask(__name__) # Enable CORS for all routes with specific headers support -CORS(app, resources={r"/api/*": {"origins": "*"}}, supports_credentials=True) +CORS(app, resources={r"/api/*": {"origins": ["https://213.226.125.13:8088"]}}, supports_credentials=True) class OpenVPNAPI: def get_config_value(self, section, key, fallback=None): @@ -67,19 +67,23 @@ class OpenVPNAPI: return self.db_manager.get_connection() def ensure_default_admin(self): - """Create a default admin user if no users exist""" + """Create the initial admin only from OVPMON_INITIAL_ADMIN_USER/PASSWORD env (no built-in defaults)""" conn = self.get_db_connection() cursor = conn.cursor() try: cursor.execute("SELECT COUNT(*) FROM users") if cursor.fetchone()[0] == 0: - # Default: admin / password - password_hash = bcrypt.hashpw('password'.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') - cursor.execute("INSERT INTO users (username, password_hash) VALUES (?, ?)", ('admin', password_hash)) + user = os.getenv('OVPMON_INITIAL_ADMIN_USER') + pw = os.getenv('OVPMON_INITIAL_ADMIN_PASSWORD') + if not user or not pw: + logger.error("No users exist and OVPMON_INITIAL_ADMIN_USER/OVPMON_INITIAL_ADMIN_PASSWORD are not set: admin NOT created") + return + password_hash = bcrypt.hashpw(pw.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + cursor.execute("INSERT INTO users (username, password_hash) VALUES (?, ?)", (user, password_hash)) conn.commit() - logger.info("Default admin user created (admin/password)") + logger.info("Initial admin user created from environment") except Exception as e: - logger.error(f"Error ensuring default admin: {e}") + logger.error(f"Error ensuring initial admin: {e}") finally: conn.close() @@ -617,7 +621,9 @@ def token_required(f): try: data = jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"]) - # In a real app, you might want to verify user still exists in DB + # Temporary 2FA-pending tokens are valid only for /api/auth/verify-2fa + if data.get('is_2fa_pending'): + return jsonify({'success': False, 'error': '2FA verification required'}), 401 except jwt.ExpiredSignatureError: return jsonify({'success': False, 'error': 'Token has expired'}), 401 except Exception: @@ -720,7 +726,10 @@ def setup_2fa(): secret = pyotp.random_base32() totp = pyotp.TOTP(secret) - provisioning_uri = totp.provisioning_uri(name="admin", issuer_name="OpenVPN-Monitor") + _c = api.get_db_connection() + _row = _c.execute("SELECT username FROM users WHERE id = ?", (user_id,)).fetchone() + _c.close() + provisioning_uri = totp.provisioning_uri(name=(_row[0] if _row else "user"), issuer_name="OpenVPN-Monitor") return jsonify({ 'success': True, @@ -739,7 +748,7 @@ def enable_2fa(): if not secret or not otp: return jsonify({'success': False, 'error': 'Missing data'}), 400 - logger.info(f"Attempting 2FA activation. User OTP: {otp}, Secret: {secret}") + logger.info("Attempting 2FA activation") totp = pyotp.TOTP(secret) # Adding valid_window=1 to allow ±30 seconds clock drift @@ -834,6 +843,65 @@ def change_password(): finally: conn.close() +USERNAME_RE = __import__('re').compile(r'^[A-Za-z][A-Za-z0-9_.-]{2,31}$') +RESERVED_USERNAMES = {'admin', 'administrator', 'root', 'user', 'test', 'guest'} + +def _current_user_id(): + token = request.headers['Authorization'].split(' ')[1] + return jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"])['user_id'] + +@app.route('/api/auth/change-username', methods=['POST']) +@token_required +def change_username(): + data = request.get_json(silent=True) or {} + new_username = (data.get('new_username') or '').strip() + current_password = data.get('current_password') + otp = data.get('otp') + + if not new_username or not current_password: + return jsonify({'success': False, 'error': 'Missing data'}), 400 + if not USERNAME_RE.match(new_username) or new_username.lower() in RESERVED_USERNAMES: + return jsonify({'success': False, 'error': 'Invalid username (3-32 chars, letters/digits/._-, must start with a letter, reserved names not allowed)'}), 400 + + ip = request.remote_addr + if not api.check_rate_limit(ip): + return jsonify({'success': False, 'error': 'Too many attempts. Try again in 15 minutes.'}), 429 + + try: + user_id = _current_user_id() + except Exception: + return jsonify({'success': False, 'error': 'Token is invalid'}), 401 + + conn = api.get_db_connection() + cursor = conn.cursor() + try: + cursor.execute("SELECT password_hash, totp_secret, is_2fa_enabled FROM users WHERE id = ?", (user_id,)) + user = cursor.fetchone() + if not user or not bcrypt.checkpw(current_password.encode('utf-8'), user[0].encode('utf-8')): + api.record_login_attempt(ip, False) + return jsonify({'success': False, 'error': 'Invalid current password'}), 401 + if user[2]: + if not otp or not pyotp.TOTP(user[1]).verify(str(otp), valid_window=1): + api.record_login_attempt(ip, False) + return jsonify({'success': False, 'error': 'Invalid 2FA code'}), 401 + + cursor.execute("SELECT 1 FROM users WHERE lower(username) = lower(?) AND id != ?", (new_username, user_id)) + if cursor.fetchone(): + return jsonify({'success': False, 'error': 'Username is already taken'}), 409 + try: + cursor.execute("UPDATE users SET username = ? WHERE id = ?", (new_username, user_id)) + conn.commit() + except sqlite3.IntegrityError: + return jsonify({'success': False, 'error': 'Username is already taken'}), 409 + + logger.info(f"Username changed for user ID: {user_id}") + return jsonify({'success': True, 'username': new_username}) + except Exception as e: + logger.error(f"Error changing username: {e}") + return jsonify({'success': False, 'error': 'Internal server error'}), 500 + finally: + conn.close() + # --- USER ROUTES --- @app.route('/api/v1/user/me', methods=['GET']) diff --git a/APP_PROFILER/main.py b/APP_PROFILER/main.py index 5d211ba..e904e82 100644 --- a/APP_PROFILER/main.py +++ b/APP_PROFILER/main.py @@ -26,10 +26,10 @@ app = FastAPI( # Enable CORS app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=["https://213.226.125.13:8088"], allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=["GET", "POST", "PUT", "DELETE"], + allow_headers=["Authorization", "Content-Type"], ) app.include_router(system.router, prefix="/api", tags=["System"]) diff --git a/APP_PROFILER/routers/profiles.py b/APP_PROFILER/routers/profiles.py index 3f4e5fe..adcaaa7 100644 --- a/APP_PROFILER/routers/profiles.py +++ b/APP_PROFILER/routers/profiles.py @@ -79,6 +79,11 @@ def create_profile( if existing: raise HTTPException(status_code=400, detail="User already exists") + try: + pki.validate_username(profile_in.username) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid username") + # Build PKI try: pki.build_client(profile_in.username, db) @@ -89,6 +94,9 @@ def create_profile( client_conf_dir = "client-config" os.makedirs(client_conf_dir, exist_ok=True) file_path = os.path.join(client_conf_dir, f"{profile_in.username}.ovpn") + base_real = os.path.realpath(client_conf_dir) + if not os.path.realpath(file_path).startswith(base_real + os.sep): + raise HTTPException(status_code=400, detail="Invalid username") try: generator.generate_client_config(db, profile_in.username, file_path) diff --git a/APP_PROFILER/schemas.py b/APP_PROFILER/schemas.py index 9c541e2..f3faa92 100644 --- a/APP_PROFILER/schemas.py +++ b/APP_PROFILER/schemas.py @@ -66,7 +66,7 @@ class ConfigResponse(BaseModel): # --- User Profile Schemas --- class UserProfileBase(BaseModel): - username: str + username: str = Field(..., pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$") class UserProfileCreate(UserProfileBase): pass diff --git a/APP_PROFILER/services/generator.py b/APP_PROFILER/services/generator.py index 09257b1..21f4b7f 100644 --- a/APP_PROFILER/services/generator.py +++ b/APP_PROFILER/services/generator.py @@ -61,6 +61,11 @@ def generate_server_config(db: Session, output_path: str = "server.conf"): return config_content def generate_client_config(db: Session, username: str, output_path: str): + from .pki import validate_username + validate_username(username) + base = os.path.realpath(os.path.dirname(output_path) or ".") + if os.path.realpath(output_path) != os.path.join(base, os.path.basename(output_path)) or os.path.basename(output_path) != f"{username}.ovpn": + raise ValueError("Invalid output path") settings = get_system_settings(db) pki = get_pki_settings(db) diff --git a/APP_PROFILER/services/pki.py b/APP_PROFILER/services/pki.py index c69e319..15dc66e 100644 --- a/APP_PROFILER/services/pki.py +++ b/APP_PROFILER/services/pki.py @@ -7,6 +7,14 @@ from datetime import datetime logger = logging.getLogger(__name__) +import re +USERNAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$") + +def validate_username(username: str) -> str: + if not isinstance(username, str) or not USERNAME_RE.match(username) or ".." in username: + raise ValueError("Invalid username") + return username + EASY_RSA_DIR = os.path.join(os.getcwd(), "easy-rsa") PKI_DIR = os.path.join(EASY_RSA_DIR, "pki") INDEX_PATH = os.path.join(PKI_DIR, "index.txt") @@ -170,11 +178,13 @@ def clear_pki(db: Session): return "PKI directory did not exist, but User DB and Client profiles were wiped." def build_client(username: str, db: Session): + validate_username(username) env = _get_easyrsa_env(db) _run_easyrsa(["build-client-full", username, "nopass"], env) return True def revoke_client(username: str, db: Session): + validate_username(username) env = _get_easyrsa_env(db) _run_easyrsa(["revoke", username], env) _run_easyrsa(["gen-crl"], env) diff --git a/APP_PROFILER/utils/auth.py b/APP_PROFILER/utils/auth.py index 5ca5fe1..97806b1 100644 --- a/APP_PROFILER/utils/auth.py +++ b/APP_PROFILER/utils/auth.py @@ -43,7 +43,15 @@ async def verify_token(authorization: str = Header(None)): # print(f"[AUTH] Decoding token with SECRET_KEY starting with: {SECRET_KEY[:3]}...") payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"]) + if payload.get("is_2fa_pending"): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="2FA verification required", + headers={"WWW-Authenticate": "Bearer"}, + ) return payload + except HTTPException: + raise except Exception as e: error_type = type(e).__name__ error_detail = str(e) diff --git a/APP_UI/src/App.vue b/APP_UI/src/App.vue index 70e73ce..18ab9f2 100644 --- a/APP_UI/src/App.vue +++ b/APP_UI/src/App.vue @@ -99,7 +99,7 @@