CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m18s
399 lines
16 KiB
Python
399 lines
16 KiB
Python
"""
|
|
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
|