From 189e8fb99b16fd6f6b8b6007f3d7af550fa9af6f Mon Sep 17 00:00:00 2001 From: root Date: Wed, 25 Mar 2026 03:14:51 -0500 Subject: [PATCH] Add authentication + security hardening - Session-based login with bcrypt password hashing - First-time setup flow creates admin account - @login_required on all page/API routes - @admin_required on admin panel and lab routes - Rate limiting: 60 req/min global, 5 login attempts/min - Security headers: X-Frame-Options, XSS Protection, nosniff - Login page with dark theme matching main UI - Logout button in header - users table in PostgreSQL Co-Authored-By: Claude Opus 4.6 (1M context) --- llm_team_ui.py | 251 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 250 insertions(+), 1 deletion(-) diff --git a/llm_team_ui.py b/llm_team_ui.py index 9c69331..958b44b 100644 --- a/llm_team_ui.py +++ b/llm_team_ui.py @@ -5,14 +5,238 @@ import json import os import time import threading +import secrets +import hashlib import requests import random import psycopg2 import psycopg2.extras +import bcrypt from concurrent.futures import ThreadPoolExecutor, as_completed -from flask import Flask, render_template_string, request, jsonify, Response +from flask import Flask, render_template_string, request, jsonify, Response, redirect, url_for, session +from functools import wraps app = Flask(__name__) +app.secret_key = os.environ.get("FLASK_SECRET", secrets.token_hex(32)) + +# ─── AUTH ───────────────────────────────────────────────────── + +_rate_limit = {} # ip -> (count, window_start) +RATE_LIMIT_WINDOW = 60 # seconds +RATE_LIMIT_MAX = 60 # requests per window +LOGIN_RATE_MAX = 5 # login attempts per window + + +def rate_limited(ip, max_req=RATE_LIMIT_MAX): + now = time.time() + if ip not in _rate_limit or now - _rate_limit[ip][1] > RATE_LIMIT_WINDOW: + _rate_limit[ip] = (1, now) + return False + count, start = _rate_limit[ip] + if count >= max_req: + return True + _rate_limit[ip] = (count + 1, start) + return False + + +def login_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if not session.get("user_id"): + if request.path.startswith("/api/"): + return jsonify({"error": "unauthorized"}), 401 + return redirect("/login") + return f(*args, **kwargs) + return decorated + + +def admin_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if not session.get("user_id"): + return redirect("/login") + if session.get("role") != "admin": + return "Forbidden", 403 + return f(*args, **kwargs) + return decorated + + +@app.before_request +def security_checks(): + ip = request.remote_addr + # Rate limit + if rate_limited(ip): + return jsonify({"error": "rate limited"}), 429 + # Allow login/static without auth + if request.path in ("/login", "/api/auth/login", "/api/auth/setup"): + return + if request.path.startswith("/static"): + return + + +@app.after_request +def security_headers(response): + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + if request.path.startswith("/api/"): + response.headers["Cache-Control"] = "no-store" + return response + + +LOGIN_HTML = """ + + +LLM Team - Login + + + + + +""" + + +@app.route("/login") +def login_page(): + if session.get("user_id"): + return redirect("/") + return LOGIN_HTML + + +@app.route("/api/auth/setup") +def auth_setup(): + try: + with get_db() as conn: + with conn.cursor() as cur: + cur.execute("SELECT COUNT(*) FROM users") + count = cur.fetchone()[0] + return jsonify({"needs_setup": count == 0}) + except Exception: + return jsonify({"needs_setup": True}) + + +@app.route("/api/auth/login", methods=["POST"]) +def auth_login(): + ip = request.remote_addr + if rate_limited(ip, LOGIN_RATE_MAX): + return jsonify({"error": "Too many attempts. Wait a minute."}), 429 + + data = request.json or {} + username = data.get("username", "").strip() + password = data.get("password", "") + is_setup = data.get("setup", False) + + if not username or not password: + return jsonify({"error": "Username and password required"}), 400 + + if len(password) < 8: + return jsonify({"error": "Password must be at least 8 characters"}), 400 + + try: + with get_db() as conn: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + if is_setup: + # First-time setup: create admin + cur.execute("SELECT COUNT(*) as c FROM users") + if cur.fetchone()["c"] > 0: + return jsonify({"error": "Setup already completed"}), 400 + pw_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() + cur.execute("INSERT INTO users (username, password_hash, role) VALUES (%s, %s, 'admin') RETURNING id", + (username, pw_hash)) + uid = cur.fetchone()["id"] + conn.commit() + session["user_id"] = uid + session["username"] = username + session["role"] = "admin" + session.permanent = True + return jsonify({"ok": True}) + + # Normal login + cur.execute("SELECT * FROM users WHERE username = %s", (username,)) + user = cur.fetchone() + if not user or not bcrypt.checkpw(password.encode(), user["password_hash"].encode()): + return jsonify({"error": "Invalid credentials"}), 401 + + session["user_id"] = user["id"] + session["username"] = user["username"] + session["role"] = user["role"] + session.permanent = True + return jsonify({"ok": True}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/auth/logout", methods=["POST"]) +def auth_logout(): + session.clear() + return jsonify({"ok": True}) + + +@app.route("/logout") +def logout_page(): + session.clear() + return redirect("/login") CONFIG_PATH = "/root/llm_team_config.json" DEFAULT_CONFIG = { @@ -237,6 +461,7 @@ HTML = r""" Lab Admin + Logout
@@ -1923,11 +2148,13 @@ def parallel_query(models, prompt): # ─── ROUTES ──────────────────────────────────────────────────── @app.route("/") +@login_required def index(): return render_template_string(HTML) @app.route("/api/models") +@login_required def get_models(): SKIP = {"nomic-embed-text", "mxbai-embed-large", "all-minilm", "snowflake-arctic-embed"} cfg = load_config() @@ -1968,11 +2195,13 @@ def get_models(): # ─── ADMIN ROUTES ───────────────────────────────────────────── @app.route("/admin") +@admin_required def admin_page(): return render_template_string(ADMIN_HTML) @app.route("/api/admin/config", methods=["GET"]) +@admin_required def admin_get_config(): cfg = load_config() safe = json.loads(json.dumps(cfg)) @@ -1986,6 +2215,7 @@ def admin_get_config(): @app.route("/api/admin/config", methods=["POST"]) +@admin_required def admin_save_config(): data = request.json cfg = load_config() @@ -2007,6 +2237,7 @@ def admin_save_config(): @app.route("/api/admin/test-provider", methods=["POST"]) +@admin_required def admin_test_provider(): data = request.json name = data.get("provider", "") @@ -2043,6 +2274,7 @@ def admin_test_provider(): _or_models_cache = {"data": None, "ts": 0} @app.route("/api/admin/openrouter/models") +@admin_required def admin_openrouter_models(): import time now = time.time() @@ -2067,6 +2299,7 @@ def admin_openrouter_models(): @app.route("/api/admin/ollama-models") +@admin_required def admin_ollama_models(): cfg = load_config() base = cfg["providers"]["ollama"].get("base_url", "http://localhost:11434") @@ -2092,6 +2325,7 @@ def admin_ollama_models(): # ─── HISTORY ROUTES ──────────────────────────────────────────── @app.route("/api/runs") +@login_required def get_runs(): try: with get_db() as conn: @@ -2106,6 +2340,7 @@ def get_runs(): @app.route("/api/runs/") +@login_required def get_run(run_id): try: with get_db() as conn: @@ -2121,6 +2356,7 @@ def get_run(run_id): @app.route("/api/runs/", methods=["DELETE"]) +@login_required def delete_run(run_id): try: with get_db() as conn: @@ -2133,6 +2369,7 @@ def delete_run(run_id): @app.route("/api/pipelines") +@login_required def get_pipelines(): try: with get_db() as conn: @@ -2147,6 +2384,7 @@ def get_pipelines(): @app.route("/api/pipelines/") +@login_required def get_pipeline(pid): try: with get_db() as conn: @@ -2363,11 +2601,13 @@ def _ratchet_loop(exp_id): # ─── LAB API ROUTES ─────────────────────────────────────────── @app.route("/lab") +@admin_required def lab_page(): return render_template_string(LAB_HTML) @app.route("/api/lab/experiments", methods=["GET"]) +@admin_required def lab_list(): with get_db() as conn: with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: @@ -2379,6 +2619,7 @@ def lab_list(): @app.route("/api/lab/experiments", methods=["POST"]) +@admin_required def lab_create(): d = request.json with get_db() as conn: @@ -2398,6 +2639,7 @@ def lab_create(): @app.route("/api/lab/experiments/", methods=["GET"]) +@admin_required def lab_get(eid): with get_db() as conn: with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: @@ -2414,6 +2656,7 @@ def lab_get(eid): @app.route("/api/lab/experiments/", methods=["PUT"]) +@admin_required def lab_update(eid): d = request.json sets, vals = [], [] @@ -2439,6 +2682,7 @@ def lab_update(eid): @app.route("/api/lab/experiments//start", methods=["POST"]) +@admin_required def lab_start(eid): with get_db() as conn: with conn.cursor() as cur: @@ -2453,6 +2697,7 @@ def lab_start(eid): @app.route("/api/lab/experiments//pause", methods=["POST"]) +@admin_required def lab_pause(eid): with get_db() as conn: with conn.cursor() as cur: @@ -2462,6 +2707,7 @@ def lab_pause(eid): @app.route("/api/lab/experiments//reset", methods=["POST"]) +@admin_required def lab_reset(eid): with get_db() as conn: with conn.cursor() as cur: @@ -2472,6 +2718,7 @@ def lab_reset(eid): @app.route("/api/lab/experiments//delete", methods=["DELETE"]) +@admin_required def lab_delete(eid): with get_db() as conn: with conn.cursor() as cur: @@ -2481,6 +2728,7 @@ def lab_delete(eid): @app.route("/api/lab/experiments//stream") +@admin_required def lab_stream(eid): q = [] _lab_streams.setdefault(eid, []).append(q) @@ -2504,6 +2752,7 @@ def lab_stream(eid): # ─── TEAM ROUTES ────────────────────────────────────────────── @app.route("/api/run", methods=["POST"]) +@login_required def run_team(): config = request.json mode = config["mode"]