diff --git a/LOGAR-LICENSING b/LOGAR-LICENSING index 5a8d69c..1252941 160000 --- a/LOGAR-LICENSING +++ b/LOGAR-LICENSING @@ -1 +1 @@ -Subproject commit 5a8d69c005e79062d0934d62e0e2122337d2bc3b +Subproject commit 1252941d9392a0c53ad7fb69fb2786851360041f diff --git a/src/Server.py b/src/Server.py index 3678660..4fb2f9f 100644 --- a/src/Server.py +++ b/src/Server.py @@ -76,6 +76,40 @@ EVALUATION_WINDOW_HOURS = 12 RUN_THRESHOLD = 1 SESSION_COOKIE_NAME = "logar_server_session" +_CACHED_SESSION_SECRETS: Dict[str, str] = {} + + +def get_session_secret(db_path: Optional[str] = None) -> str: + """Retrieves or persists a cryptographically secure session secret in the database.""" + env_sec = os.environ.get("LOGAR_SESSION_SECRET") + if env_sec: + return env_sec + target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE + abs_db = os.path.abspath(target_db) + if abs_db in _CACHED_SESSION_SECRETS: + return _CACHED_SESSION_SECRETS[abs_db] + try: + conn = sqlite3.connect(target_db, timeout=5.0) + c = conn.cursor() + c.execute("CREATE TABLE IF NOT EXISTS server_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)") + c.execute("SELECT value FROM server_meta WHERE key = 'session_secret'") + row = c.fetchone() + if row and row[0]: + _CACHED_SESSION_SECRETS[abs_db] = row[0] + conn.close() + return row[0] + new_secret = secrets.token_hex(32) + c.execute("INSERT OR REPLACE INTO server_meta (key, value) VALUES ('session_secret', ?)", (new_secret,)) + conn.commit() + conn.close() + _CACHED_SESSION_SECRETS[abs_db] = new_secret + return new_secret + except Exception: + fallback = secrets.token_hex(32) + _CACHED_SESSION_SECRETS[abs_db] = fallback + return fallback + + SESSION_SECRET = os.environ.get("LOGAR_SESSION_SECRET") or secrets.token_hex(32) DEFAULT_ADMIN_USER = os.environ.get("LOGAR_ADMIN_USER", "admin") DEFAULT_ADMIN_PASS = os.environ.get("LOGAR_ADMIN_PASS", "admin_logar_2026!") @@ -143,29 +177,81 @@ def get_client_ip(request: Optional[Request] = None) -> str: return "127.0.0.1" -def check_ip_rate_limit(ip: str, store: Dict[str, List[float]], max_attempts: int = 10, window_seconds: int = 60) -> bool: - """Checks whether the client IP has exceeded failed attempts within the sliding time window.""" +def check_ip_rate_limit( + ip: str, + store: Any = None, + max_attempts: int = 10, + window_seconds: int = 60, + scope: str = "login", + db_path: Optional[str] = None +) -> bool: + """Checks whether the client IP has exceeded failed attempts within the sliding time window using persistent DB storage.""" now = datetime.now(timezone.utc).timestamp() - with _RATE_LIMIT_LOCK: - history = store.get(ip, []) - history = [ts for ts in history if now - ts < window_seconds] - store[ip] = history - return len(history) < max_attempts + cutoff = now - window_seconds + target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE + try: + conn = sqlite3.connect(target_db, timeout=5.0) + c = conn.cursor() + c.execute("CREATE TABLE IF NOT EXISTS rate_limits (ip TEXT NOT NULL, scope TEXT NOT NULL, attempt_time REAL NOT NULL)") + c.execute("DELETE FROM rate_limits WHERE attempt_time < ?", (cutoff,)) + c.execute("SELECT COUNT(*) FROM rate_limits WHERE ip = ? AND scope = ? AND attempt_time >= ?", (ip, scope, cutoff)) + cnt = c.fetchone()[0] + conn.commit() + conn.close() + return cnt < max_attempts + except Exception: + with _RATE_LIMIT_LOCK: + if isinstance(store, dict): + history = store.get(ip, []) + history = [ts for ts in history if now - ts < window_seconds] + store[ip] = history + return len(history) < max_attempts + return True -def record_failed_ip_attempt(ip: str, store: Dict[str, List[float]]): - """Records a failed attempt timestamp for the client IP.""" +def record_failed_ip_attempt( + ip: str, + store: Any = None, + scope: str = "login", + db_path: Optional[str] = None +): + """Records a persistent failed attempt timestamp for the client IP and scope.""" now = datetime.now(timezone.utc).timestamp() + target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE + try: + conn = sqlite3.connect(target_db, timeout=5.0) + c = conn.cursor() + c.execute("CREATE TABLE IF NOT EXISTS rate_limits (ip TEXT NOT NULL, scope TEXT NOT NULL, attempt_time REAL NOT NULL)") + c.execute("INSERT INTO rate_limits (ip, scope, attempt_time) VALUES (?, ?, ?)", (ip, scope, now)) + conn.commit() + conn.close() + except Exception: + pass with _RATE_LIMIT_LOCK: - if ip not in store: - store[ip] = [] - store[ip].append(now) + if isinstance(store, dict): + if ip not in store: + store[ip] = [] + store[ip].append(now) -def reset_failed_ip_attempts(ip: str, store: Dict[str, List[float]]): - """Resets failed attempt records for client IP upon successful authentication.""" +def reset_failed_ip_attempts( + ip: str, + store: Any = None, + scope: str = "login", + db_path: Optional[str] = None +): + """Resets persistent failed attempt records for client IP upon successful authentication.""" + target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE + try: + conn = sqlite3.connect(target_db, timeout=5.0) + c = conn.cursor() + c.execute("DELETE FROM rate_limits WHERE ip = ? AND scope = ?", (ip, scope)) + conn.commit() + conn.close() + except Exception: + pass with _RATE_LIMIT_LOCK: - if ip in store: + if isinstance(store, dict) and ip in store: del store[ip] @@ -185,16 +271,17 @@ def verify_password(password: str, salt: str, password_hash: str) -> bool: return hmac.compare_digest(test_hash, password_hash) -def generate_session_token(username: str) -> str: +def generate_session_token(username: str, db_path: Optional[str] = None) -> str: """Generates an HMAC-signed session cookie string.""" ts = int(datetime.now(timezone.utc).timestamp()) nonce = secrets.token_hex(8) payload = f"{username}:{ts}:{nonce}" - sig = hmac.new(SESSION_SECRET.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest() + secret = get_session_secret(db_path) + sig = hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest() return f"{payload}:{sig}" -def verify_session_token(token: str, max_age_hours: int = 24) -> Optional[str]: +def verify_session_token(token: Optional[str], max_age_hours: int = 24, db_path: Optional[str] = None) -> Optional[str]: """Verifies session token authenticity and age.""" if not token or ":" not in token: return None @@ -203,7 +290,8 @@ def verify_session_token(token: str, max_age_hours: int = 24) -> Optional[str]: return None username, ts_str, nonce, sig = parts payload = f"{username}:{ts_str}:{nonce}" - expected_sig = hmac.new(SESSION_SECRET.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest() + secret = get_session_secret(db_path) + expected_sig = hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest() if not hmac.compare_digest(sig, expected_sig): return None try: @@ -217,15 +305,16 @@ def verify_session_token(token: str, max_age_hours: int = 24) -> Optional[str]: def get_current_user_optional(request: Request) -> Optional[str]: """Extracts authenticated username from cookie or Bearer header if valid.""" + db_path = SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE cookie = request.cookies.get(SESSION_COOKIE_NAME) if cookie: - user = verify_session_token(cookie) + user = verify_session_token(cookie, db_path=db_path) if user: return user auth_header = request.headers.get("Authorization") if auth_header and auth_header.startswith("Bearer "): token = auth_header.split(" ", 1)[1].strip() - user = verify_session_token(token) + user = verify_session_token(token, db_path=db_path) if user: return user return None @@ -602,9 +691,10 @@ def load_or_init_config(config_path: str = CONFIG_FILE_NAME) -> Dict[str, Any]: config["db_path"] = resolve_writable_path(config["db_path"], "LOGAR-Server", DEFAULT_DB_FILE) config["cert_dir"] = resolve_writable_path(config["cert_dir"], "LOGAR-Server", "certs") - global SESSION_SECRET - if not os.environ.get("LOGAR_SESSION_SECRET"): + global SESSION_SECRET, _CACHED_SESSION_SECRET + if not os.environ.get("LOGAR_SESSION_SECRET") and "session_secret" in config: SESSION_SECRET = config["session_secret"] + _CACHED_SESSION_SECRET = config["session_secret"] return config # Fallback to writable data dir if CWD is read-only / Program Files @@ -645,6 +735,7 @@ def load_or_init_config(config_path: str = CONFIG_FILE_NAME) -> Dict[str, Any]: } if not os.environ.get("LOGAR_SESSION_SECRET"): SESSION_SECRET = session_sec + _CACHED_SESSION_SECRET = session_sec os.makedirs(os.path.dirname(os.path.abspath(config_path)), exist_ok=True) with open(config_path, "w", encoding="utf-8") as f: @@ -894,6 +985,23 @@ def init_db(db_path: str, enrollment_secret: Optional[str] = None, max_seats: in conn.execute("INSERT INTO hermes_tokens (token, name, created_at, status) VALUES (?, 'Default Hermes Agent', ?, 'active')", (initial_token, now_iso)) + # 7. Rate Limits & Server Meta Tables + conn.execute(""" + CREATE TABLE IF NOT EXISTS rate_limits ( + ip TEXT NOT NULL, + scope TEXT NOT NULL, + attempt_time REAL NOT NULL + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_rate_limits_ip_scope ON rate_limits (ip, scope, attempt_time)") + + conn.execute(""" + CREATE TABLE IF NOT EXISTS server_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + """) + status = "active" if max_seats > 0 else "unlicensed" if enrollment_secret: conn.execute(""" @@ -1508,7 +1616,8 @@ def login_page(request: Request): async def process_login(request: Request): """Processes login credentials and issues session cookie with 2FA TOTP enforcement.""" client_ip = get_client_ip(request) - if not check_ip_rate_limit(client_ip, _LOGIN_FAILURES, max_attempts=10, window_seconds=60): + db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) + if not check_ip_rate_limit(client_ip, _LOGIN_FAILURES, max_attempts=10, window_seconds=60, scope="login", db_path=db_path): raise HTTPException(status_code=429, detail="Too many failed login attempts. Please try again later.") username, password, totp_code = None, None, None @@ -1530,9 +1639,8 @@ async def process_login(request: Request): if not username or not password: raise HTTPException(status_code=400, detail="Username and password are required") - db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) if not verify_user_credentials(username, password, db_path=db_path): - record_failed_ip_attempt(client_ip, _LOGIN_FAILURES) + record_failed_ip_attempt(client_ip, _LOGIN_FAILURES, scope="login", db_path=db_path) await asyncio.sleep(0.5) raise HTTPException(status_code=401, detail="Invalid username or password") @@ -1546,12 +1654,12 @@ async def process_login(request: Request): "message": "Two-factor authentication code required" }) if not verify_totp_code(totp_info["secret"], totp_code): - record_failed_ip_attempt(client_ip, _LOGIN_FAILURES) + record_failed_ip_attempt(client_ip, _LOGIN_FAILURES, scope="login", db_path=db_path) await asyncio.sleep(0.3) raise HTTPException(status_code=401, detail="Invalid 6-digit 2FA verification code") - reset_failed_ip_attempts(client_ip, _LOGIN_FAILURES) - token = generate_session_token(username) + reset_failed_ip_attempts(client_ip, _LOGIN_FAILURES, scope="login", db_path=db_path) + token = generate_session_token(username, db_path=db_path) is_https = request.url.scheme == "https" or request.headers.get("x-forwarded-proto") == "https" response = JSONResponse(content={"status": "success", "username": username, "token": token}) response.set_cookie( @@ -1642,10 +1750,10 @@ def enroll_client(req: ClientEnrollRequest, request: Request = None): and recording the client in the SQLite accounting database. """ client_ip = get_client_ip(request) - if not check_ip_rate_limit(client_ip, _ENROLL_FAILURES, max_attempts=15, window_seconds=60): + db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) + if not check_ip_rate_limit(client_ip, _ENROLL_FAILURES, max_attempts=15, window_seconds=60, scope="enroll", db_path=db_path): raise HTTPException(status_code=429, detail="Too many failed enrollment attempts. Please try again later.") - db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) conn = sqlite3.connect(db_path) c = conn.cursor() @@ -1664,11 +1772,11 @@ def enroll_client(req: ClientEnrollRequest, request: Request = None): if not secrets.compare_digest(str(req.enrollment_secret), str(expected_secret)): conn.close() - record_failed_ip_attempt(client_ip, _ENROLL_FAILURES) + record_failed_ip_attempt(client_ip, _ENROLL_FAILURES, scope="enroll", db_path=db_path) time.sleep(0.5) raise HTTPException(status_code=403, detail="Invalid enrollment secret") - reset_failed_ip_attempts(client_ip, _ENROLL_FAILURES) + reset_failed_ip_attempts(client_ip, _ENROLL_FAILURES, scope="enroll", db_path=db_path) ca_cert = SERVER_STATE.get("ca_cert") ca_key = SERVER_STATE.get("ca_key") diff --git a/tests/test_server.py b/tests/test_server.py index a22cf13..20749e8 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -510,6 +510,43 @@ class TestServerComponent(unittest.TestCase): info_disabled = Server.get_user_totp_info("admin", db_path=self.test_db) self.assertFalse(info_disabled["enabled"]) + def test_persistent_session_secret_and_rate_limiting(self): + """Tests DB persistence of session secret and sliding window rate limiting.""" + Server.init_db(self.test_db) + + # 1. Session secret persistence + sec1 = Server.get_session_secret(self.test_db) + self.assertTrue(len(sec1) >= 32) + conn = sqlite3.connect(self.test_db) + c = conn.cursor() + c.execute("SELECT value FROM server_meta WHERE key = 'session_secret'") + row = c.fetchone() + conn.close() + self.assertIsNotNone(row) + self.assertEqual(row[0], sec1) + + # Token generated with test_db + token = Server.generate_session_token("admin", db_path=self.test_db) + self.assertEqual(Server.verify_session_token(token, db_path=self.test_db), "admin") + + # 2. Rate limiting + test_ip = "10.0.0.99" + Server.reset_failed_ip_attempts(test_ip, scope="login", db_path=self.test_db) + + self.assertTrue(Server.check_ip_rate_limit(test_ip, max_attempts=3, window_seconds=60, scope="login", db_path=self.test_db)) + Server.record_failed_ip_attempt(test_ip, scope="login", db_path=self.test_db) + Server.record_failed_ip_attempt(test_ip, scope="login", db_path=self.test_db) + self.assertTrue(Server.check_ip_rate_limit(test_ip, max_attempts=3, window_seconds=60, scope="login", db_path=self.test_db)) + + Server.record_failed_ip_attempt(test_ip, scope="login", db_path=self.test_db) + self.assertFalse(Server.check_ip_rate_limit(test_ip, max_attempts=3, window_seconds=60, scope="login", db_path=self.test_db)) + + # Different scope ('enroll') should still be allowed + self.assertTrue(Server.check_ip_rate_limit(test_ip, max_attempts=3, window_seconds=60, scope="enroll", db_path=self.test_db)) + + Server.reset_failed_ip_attempts(test_ip, scope="login", db_path=self.test_db) + self.assertTrue(Server.check_ip_rate_limit(test_ip, max_attempts=3, window_seconds=60, scope="login", db_path=self.test_db)) + if __name__ == "__main__": unittest.main()