""" LOGAR - Server Authentication & Access Control Managers Provides modular classes for Session Management, Rate Limiting, Password Hashing, Two-Factor Authentication (TOTP / RFC 6238), and Hermes API Tokens. """ import os import time import base64 import struct import secrets import hashlib import hmac import sqlite3 import urllib.parse import threading from datetime import datetime, timezone from typing import Optional, Dict, Any, List, Tuple from fastapi import Request, HTTPException class SessionManager: """Manages cryptographic session secrets and HMAC-signed session tokens.""" SESSION_COOKIE_NAME = "logar_server_session" _CACHED_SESSION_SECRETS: Dict[str, str] = {} @classmethod def get_session_secret(cls, db_path: Optional[str] = None, fallback_db: str = "logar_state.db") -> 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 fallback_db abs_db = os.path.abspath(target_db) if abs_db in cls._CACHED_SESSION_SECRETS: return cls._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]: cls._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() cls._CACHED_SESSION_SECRETS[abs_db] = new_secret return new_secret except Exception: fallback = secrets.token_hex(32) cls._CACHED_SESSION_SECRETS[abs_db] = fallback return fallback @classmethod def generate_session_token(cls, username: str, db_path: Optional[str] = None, fallback_db: str = "logar_state.db") -> 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}" secret = cls.get_session_secret(db_path=db_path, fallback_db=fallback_db) sig = hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest() return f"{payload}:{sig}" @classmethod def verify_session_token( cls, token: Optional[str], max_age_hours: int = 24, db_path: Optional[str] = None, fallback_db: str = "logar_state.db" ) -> Optional[str]: """Verifies session token authenticity and age.""" if not token or ":" not in token: return None parts = token.split(":") if len(parts) != 4: return None username, ts_str, nonce, sig = parts payload = f"{username}:{ts_str}:{nonce}" secret = cls.get_session_secret(db_path=db_path, fallback_db=fallback_db) 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: ts = int(ts_str) if datetime.now(timezone.utc).timestamp() - ts > max_age_hours * 3600: return None except Exception: return None return username class RateLimiter: """Sliding-window IP rate limiter with persistent SQLite tracking and memory fallback.""" def __init__(self): self._lock = threading.Lock() self._login_failures: Dict[str, List[float]] = {} self._enroll_failures: Dict[str, List[float]] = {} @staticmethod def get_client_ip(request: Optional[Request] = None) -> str: """Extracts client IP address respecting reverse-proxy headers.""" if not request: return "127.0.0.1" forwarded = request.headers.get("x-forwarded-for") if forwarded: return forwarded.split(",")[0].strip() real_ip = request.headers.get("x-real-ip") if real_ip: return real_ip.strip() if request.client: return request.client.host return "127.0.0.1" def check_ip_rate_limit( self, ip: str, store: Any = None, max_attempts: int = 10, window_seconds: int = 60, scope: str = "login", db_path: Optional[str] = None, fallback_db: str = "logar_state.db" ) -> bool: """Checks whether the client IP has exceeded failed attempts within the sliding window.""" now = datetime.now(timezone.utc).timestamp() cutoff = now - window_seconds target_db = db_path or fallback_db 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 self._lock: effective_store = store if isinstance(store, dict) else (self._login_failures if scope == "login" else self._enroll_failures) history = effective_store.get(ip, []) history = [ts for ts in history if now - ts < window_seconds] effective_store[ip] = history return len(history) < max_attempts def record_failed_ip_attempt( self, ip: str, store: Any = None, scope: str = "login", db_path: Optional[str] = None, fallback_db: str = "logar_state.db" ): """Records a persistent failed attempt timestamp for the client IP and scope.""" now = datetime.now(timezone.utc).timestamp() target_db = db_path or fallback_db 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 self._lock: effective_store = store if isinstance(store, dict) else (self._login_failures if scope == "login" else self._enroll_failures) if ip not in effective_store: effective_store[ip] = [] effective_store[ip].append(now) def reset_failed_ip_attempts( self, ip: str, store: Any = None, scope: str = "login", db_path: Optional[str] = None, fallback_db: str = "logar_state.db" ): """Resets persistent failed attempt records for client IP upon successful authentication.""" target_db = db_path or fallback_db 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 self._lock: effective_store = store if isinstance(store, dict) else (self._login_failures if scope == "login" else self._enroll_failures) if ip in effective_store: del effective_store[ip] class PasswordManager: """PBKDF2-HMAC-SHA256 password hashing and validation.""" @staticmethod def hash_password(password: str, salt: Optional[str] = None) -> Tuple[str, str]: """Hashes a password using PBKDF2-HMAC-SHA256 with 100,000 iterations.""" if not salt: salt = secrets.token_hex(16) pwd_hash = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 100000).hex() return pwd_hash, salt @staticmethod def verify_password(password: str, salt: str, password_hash: str) -> bool: """Verifies a password against the stored salt and PBKDF2 hash.""" test_hash, _ = PasswordManager.hash_password(password, salt=salt) return hmac.compare_digest(test_hash, password_hash) @staticmethod def verify_user_credentials(username: str, password: str, db_path: str = "logar_state.db") -> bool: """Checks user credentials against SQLite users table.""" conn = sqlite3.connect(db_path) c = conn.cursor() c.execute("SELECT password_hash, salt FROM users WHERE username = ?", (username,)) row = c.fetchone() conn.close() if not row: return False return PasswordManager.verify_password(password, row[1], row[0]) @staticmethod def change_user_password(username: str, new_password: str, db_path: str = "logar_state.db") -> bool: """Updates a user's password.""" pwd_hash, salt = PasswordManager.hash_password(new_password) conn = sqlite3.connect(db_path) c = conn.cursor() c.execute("UPDATE users SET password_hash = ?, salt = ? WHERE username = ?", (pwd_hash, salt, username)) changed = c.rowcount > 0 conn.commit() conn.close() return changed class TOTPManager: """RFC 6238 Two-Factor Authentication (TOTP) generator and validator.""" @staticmethod def generate_totp_secret(length: int = 20) -> str: """Generates a cryptographically random Base32-encoded TOTP secret key.""" return base64.b32encode(secrets.token_bytes(length)).decode("utf-8").replace("=", "") @staticmethod def get_totp_token(secret: str, intervals_no: Optional[int] = None) -> str: """Calculates a 6-digit TOTP code for the given counter interval according to RFC 6238.""" if intervals_no is None: intervals_no = int(time.time()) // 30 key = base64.b32decode(secret + "=" * ((8 - len(secret) % 8) % 8), casefold=True) msg = struct.pack(">Q", intervals_no) h = hmac.new(key, msg, hashlib.sha1).digest() o = h[19] & 15 h_int = (struct.unpack(">I", h[o:o+4])[0] & 0x7fffffff) % 1000000 return f"{h_int:06d}" @staticmethod def verify_totp_code(secret: str, code: str, window: int = 1) -> bool: """Verifies a 6-digit TOTP code with standard drift window (+/- 30s).""" if not secret or not code: return False code = str(code).strip().replace(" ", "") if len(code) != 6 or not code.isdigit(): return False now = int(time.time()) current_step = now // 30 for offset in range(-window, window + 1): if hmac.compare_digest(TOTPManager.get_totp_token(secret, current_step + offset), code): return True return False @staticmethod def get_totp_uri(username: str, secret: str, issuer: str = "LOGAR Server Hub") -> str: """Generates standard otpauth URI for QR code generation.""" user_enc = urllib.parse.quote(username) issuer_enc = urllib.parse.quote(issuer) return f"otpauth://totp/{issuer_enc}:{user_enc}?secret={secret}&issuer={issuer_enc}&algorithm=SHA1&digits=6&period=30" @staticmethod def get_user_totp_info(username: str, db_path: str = "logar_state.db") -> Dict[str, Any]: """Retrieves TOTP configuration for a user from the database.""" conn = sqlite3.connect(db_path) c = conn.cursor() try: c.execute("SELECT totp_secret, totp_enabled FROM users WHERE username = ?", (username,)) row = c.fetchone() if row: return {"secret": row[0] or "", "enabled": bool(row[1])} except Exception: pass finally: conn.close() return {"secret": "", "enabled": False} @staticmethod def enable_user_totp(username: str, secret: str, db_path: str = "logar_state.db") -> bool: """Enables and activates TOTP for a user in the database.""" conn = sqlite3.connect(db_path) c = conn.cursor() try: c.execute("UPDATE users SET totp_secret = ?, totp_enabled = 1 WHERE username = ?", (secret, username)) changed = c.rowcount > 0 conn.commit() return changed finally: conn.close() @staticmethod def disable_user_totp(username: str, db_path: str = "logar_state.db") -> bool: """Disables TOTP for a user in the database.""" conn = sqlite3.connect(db_path) c = conn.cursor() try: c.execute("UPDATE users SET totp_secret = NULL, totp_enabled = 0 WHERE username = ?", (username,)) changed = c.rowcount > 0 conn.commit() return changed finally: conn.close() class HermesTokenManager: """Manages Bearer API tokens for Hermes AI anomaly integration.""" @staticmethod def create_hermes_token(name: str, token: Optional[str] = None, db_path: str = "logar_state.db") -> Dict[str, Any]: """Creates or saves a named Hermes API token.""" if not token: token = f"hermes_{secrets.token_hex(20)}" now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") conn = sqlite3.connect(db_path) c = conn.cursor() c.execute( "INSERT OR REPLACE INTO hermes_tokens (token, name, created_at, status) VALUES (?, ?, ?, 'active')", (token, name, now_iso) ) conn.commit() conn.close() return {"token": token, "name": name, "created_at": now_iso, "status": "active"} @staticmethod def list_hermes_tokens(db_path: str = "logar_state.db") -> List[Dict[str, Any]]: """Lists all registered Hermes access tokens.""" conn = sqlite3.connect(db_path) c = conn.cursor() c.execute("SELECT token, name, created_at, last_used, status FROM hermes_tokens ORDER BY created_at DESC") rows = [ {"token": r[0], "name": r[1], "created_at": r[2], "last_used": r[3] or "Never", "status": r[4]} for r in c.fetchall() ] conn.close() return rows @staticmethod def revoke_hermes_token(token: str, db_path: str = "logar_state.db") -> bool: """Revokes an active Hermes token.""" conn = sqlite3.connect(db_path) c = conn.cursor() c.execute("UPDATE hermes_tokens SET status = 'revoked' WHERE token = ?", (token,)) changed = c.rowcount > 0 conn.commit() conn.close() return changed @staticmethod def delete_hermes_token(token: str, db_path: str = "logar_state.db") -> bool: """Deletes a Hermes token.""" conn = sqlite3.connect(db_path) c = conn.cursor() c.execute("DELETE FROM hermes_tokens WHERE token = ?", (token,)) changed = c.rowcount > 0 conn.commit() conn.close() return changed @staticmethod def verify_hermes_token(token: str, db_path: str = "logar_state.db") -> bool: """Verifies a Hermes token and updates last_used timestamp.""" if not token: return False conn = sqlite3.connect(db_path) c = conn.cursor() c.execute("SELECT token FROM hermes_tokens WHERE token = ? AND status = 'active'", (token,)) row = c.fetchone() if row: now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") c.execute("UPDATE hermes_tokens SET last_used = ? WHERE token = ?", (now_iso, token)) conn.commit() conn.close() return row is not None