from __future__ import annotations import hashlib import hmac import secrets from datetime import datetime, timedelta, timezone from http.cookies import SimpleCookie from typing import Any from .config import Config from .store import AlertStore SESSION_COOKIE = "mikrosuricata_session" class SessionAuth: """Small dependency-free username/password session manager backed by SQLite.""" def __init__(self, config: Config, store: AlertStore) -> None: self.config = config self.store = store @property def enabled(self) -> bool: return bool(self._password()) def authenticate(self, username: str, password: str) -> bool: expected_password = self._password() if not expected_password: return False return hmac.compare_digest(username, self.config.admin_username) and hmac.compare_digest( password, expected_password ) def create_session(self, username: str) -> tuple[str, dict[str, Any]]: token = secrets.token_urlsafe(36) csrf = secrets.token_urlsafe(24) expires_at = datetime.now(timezone.utc) + timedelta(hours=self.config.session_hours) self.store.create_web_session(self._hash(token), username, csrf, expires_at) session = self.store.get_web_session(self._hash(token), touch=False) if session is None: raise RuntimeError("could not create web session") return token, session def session_from_cookie(self, cookie_header: str, *, touch: bool = True) -> dict[str, Any] | None: token = self.cookie_token(cookie_header) if not token: return None session = self.store.get_web_session(self._hash(token), touch=touch) if session is not None: session["token_hash"] = self._hash(token) return session def delete_session_from_cookie(self, cookie_header: str) -> None: token = self.cookie_token(cookie_header) if token: self.store.delete_web_session(self._hash(token)) def cookie_header(self, token: str) -> str: max_age = self.config.session_hours * 3600 parts = [ f"{SESSION_COOKIE}={token}", "Path=/", f"Max-Age={max_age}", "HttpOnly", "SameSite=Strict", ] if self.config.session_cookie_secure: parts.append("Secure") return "; ".join(parts) def clear_cookie_header(self) -> str: parts = [ f"{SESSION_COOKIE}=", "Path=/", "Max-Age=0", "HttpOnly", "SameSite=Strict", ] if self.config.session_cookie_secure: parts.append("Secure") return "; ".join(parts) @staticmethod def cookie_token(cookie_header: str) -> str: if not cookie_header: return "" cookie = SimpleCookie() try: cookie.load(cookie_header) except Exception: return "" morsel = cookie.get(SESSION_COOKIE) return morsel.value if morsel else "" @staticmethod def _hash(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() def _password(self) -> str: # ADMIN_TOKEN remains a migration fallback only; the UI no longer stores or sends it. return self.config.admin_password or self.config.admin_token