Files
LOGAR/src/hub/auth_manager.py
T
2026-10-02 23:14:55 +02:00

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