104 lines
3.3 KiB
Python
104 lines
3.3 KiB
Python
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
|