Newer
Older
hard-panel / panel / backend / app / auth.py
"""gnexus-auth (OAuth PKCE) интеграция: конфиг клиента, обмен кода, сессии.

Ровно как в gnexus-creds: state/PKCE пишем напрямую в БД (наша — aiosqlite),
высокоуровневому GAuthClient-у здесь взять нечего — берём SDK-детали
(HttpTokenEndpoint, HttpRuntimeUserProvider, webhook-верификатор) напрямую.
Синхронные httpx-запросы к gnexus-auth выполняются в потоке (asyncio.to_thread),
чтобы медленный/опавший auth-сервер не замораживал event loop панели.

Режим: GHARD_AUTH_CLIENT_ID пуст → панель открыта (проверка в security);
задан → браузер шлюзится через gnexus-auth, агент — по X-Server-Key,
скрипты/MCP — по Bearer GHARD_ADMIN_TOKEN.
"""

import uuid
from datetime import datetime, timedelta, timezone

import httpx

from app.config import get_settings
from app.db import get_db

SESSION_COOKIE = "ghard_session"

SCOPES = ["openid", "email", "profile", "roles", "permissions"]

# Лениво собираемый синхронный SDK-контекст (httpx-клиент живёт один на процесс)
_ctx: dict = {}


def _now() -> datetime:
    return datetime.now(timezone.utc)


def get_ctx() -> dict:
    global _ctx
    if not _ctx:
        settings = get_settings()
        from gnexus_gauth.config import GAuthConfig
        from gnexus_gauth.oauth import HttpTokenEndpoint
        from gnexus_gauth.runtime import HttpRuntimeUserProvider
        from gnexus_gauth.webhook import HmacWebhookVerifier, JsonWebhookParser

        gconf = GAuthConfig(
            base_url=settings.auth_base_url,
            client_id=settings.auth_client_id,
            client_secret=settings.auth_client_secret,
            redirect_uri=settings.auth_redirect_uri,
            user_agent="ghard-monitor",
        )
        # gnexus-auth живёт внутри сети — TLS не проверяем (по умолчанию http)
        http = httpx.Client(timeout=15.0, verify=False)
        _ctx = {
            "config": gconf,
            "http": http,
            "authorize": None,  # AuthorizationUrlBuilder строится по требованию
            "token": HttpTokenEndpoint(gconf, http),
            "runtime": HttpRuntimeUserProvider(gconf, http),
            "webhook": HmacWebhookVerifier(gconf),
            "parser": JsonWebhookParser(),
        }
    return _ctx


def auth_enabled() -> bool:
    return bool(get_settings().auth_client_id)


def safe_return_to(value: str | None) -> str:
    """Только внутренние пути: '/path', не '//evil' и не 'https://…'."""
    if not value or not value.startswith("/") or value.startswith("//") or "\\" in value:
        return "/"
    return value


def allowlisted(email: str, user_id: str) -> bool:
    """GHARD_AUTH_ALLOWLIST: пусто = любой залогиненный; иначе перечислены
    через запятую e-mail или user_id."""
    raw = get_settings().auth_allowlist.strip()
    if not raw:
        return True
    allowed = {item.strip().lower() for item in raw.split(",") if item.strip()}
    return email.lower() in allowed or str(user_id).lower() in allowed


def exchange_and_fetch(code: str, pkce_verifier: str) -> tuple:
    """Синхронный обмен кода → (TokenSet, AuthenticatedUser). Выполнять в треде."""
    ctx = get_ctx()
    token_set = ctx["token"].exchange_authorization_code(code, pkce_verifier)
    user = ctx["runtime"].fetch_user(token_set.access_token)
    return token_set, user


# --- Сессии в SQLite -----------------------------------------------------------

async def create_session(user) -> str:
    """user — gnexus_gauth.dto.AuthenticatedUser. Возвращает id сессии."""
    db = get_db()
    session_id = uuid.uuid4().hex
    profile = user.profile or {}
    settings = get_settings()
    await db.execute(
        "INSERT INTO sessions (id, user_id, email, display_name, avatar_url, expires_at, created_at)"
        " VALUES (?, ?, ?, ?, ?, ?, ?)",
        (
            session_id,
            user.user_id,
            user.email,
            profile.get("display_name") or profile.get("name") or user.email,
            profile.get("avatar_url") or "",
            (_now() + timedelta(seconds=settings.session_ttl_seconds)).isoformat(),
            _now().isoformat(),
        ),
    )
    await db.commit()
    return session_id


async def get_session(session_id: str | None):
    """Свежая сессия по cookie-значению или None."""
    if not session_id:
        return None
    db = get_db()
    cursor = await db.execute(
        "SELECT id, user_id, email, display_name, avatar_url"
        " FROM sessions WHERE id = ? AND expires_at > ?",
        (session_id, _now().isoformat()),
    )
    row = await cursor.fetchone()
    return dict(row) if row else None


async def delete_session(session_id: str | None) -> None:
    if not session_id:
        return
    db = get_db()
    await db.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
    await db.commit()


async def purge_expired() -> None:
    """Севшие сессии и state — подчистить (вызывается из фоновых циклов)."""
    db = get_db()
    now = _now().isoformat()
    await db.execute("DELETE FROM sessions WHERE expires_at < ?", (now,))
    await db.execute("DELETE FROM oauth_states WHERE expires_at < ?", (now,))
    await db.commit()


async def apply_webhook_update(user_id: str, event_type: str, metadata: dict) -> None:
    """Webhook gnexus-auth — отражаем в наших живых сессиях.

    auth.global_logout = у пользователя заканчиваются все его сессии в
    панели (SPA-cookie перестаёт работать, как и сессия в gauth);
    user.updated может обновлять имя/аватар.
    """
    db = get_db()
    cutoff = _now().isoformat()
    if event_type == "auth.global_logout":
        await db.execute(
            "DELETE FROM sessions WHERE user_id = ? AND expires_at > ?", (user_id, cutoff)
        )
    else:
        profile = (metadata.get("profile") or {}) if isinstance(metadata, dict) else {}
        display_name = profile.get("display_name") or profile.get("name")
        avatar = profile.get("avatar_url")
        if display_name:
            await db.execute(
                "UPDATE sessions SET display_name = ? WHERE user_id = ? AND expires_at > ?",
                (display_name, user_id, cutoff),
            )
        if avatar:
            await db.execute(
                "UPDATE sessions SET avatar_url = ? WHERE user_id = ? AND expires_at > ?",
                (avatar, user_id, cutoff),
            )
    await db.commit()