diff --git a/README.md b/README.md index 4915f91..884154e 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,48 @@ Bearer-токеном — удобно, когда панель торчит в интернет, а UI тебе не нужен. Единственная настоящая авторизация в системе — ключи агентов (`ghm_...`). +## Авторизация через gnexus-auth + +По умолчанию панель открыта (личный инструмент). Если панель читает +несколько человек, её можно закрыть единым входом gnexus-auth — +веб-интерфейс потребует кнопку **Sign in** (OAuth PKCE) вместо доступа без логина. +Скрипты и MCP при этом продолжают работать с Bearer-токеном `GHARD_ADMIN_TOKEN`, +агенты — со своими ключами `X-Server-Key` (см. матрицу ниже). + +**1. Зарегистрируй OAuth-клиент** в админке gnexus-auth (`/admin/clients`): + +- redirect URI — `http://<адрес-панели>/auth/callback` (то, что в браузере) +- scopes — `openid email profile roles permissions` +- webhook URL — `http://<адрес-панели>/webhooks/gnexus-auth` (авто-логаут + при `global_logout` в gnexus-auth, обновление имени/аватарки в сессии) + +**2. Пропиши в `.env`** (см. `panel/backend/.env.example`): + +```ini +GHARD_AUTH_BASE_URL=https://gnexus-auth.example.com +GHARD_AUTH_CLIENT_ID=gnx_hard_panel +GHARD_AUTH_CLIENT_SECRET=... +GHARD_AUTH_REDIRECT_URI=... +GHARD_AUTH_WEBHOOK_SECRET=... +# опционально: пусто = пускаем любого залогиненного, иначе список через запятую +# GHARD_AUTH_ALLOWLIST=user@example.com +``` + +и `docker compose up -d --build`. Всё — при открытии панели будет кнопка Sign in; +сессия живёт в cookie `ghard_session` (httponly) `GHARD_SESSION_TTL_SECONDS` (7 суток). + +### Матрица доступа + +| Кто | Как авторизуется | Зависит от | +|---|---|---| +| Браузер | cookie-сессия gnexus-auth (`/auth/login` → PKCE → callback) | `GHARD_AUTH_CLIENT_ID` задан | +| Скрипты, MCP | `Authorization: Bearer ` | `GHARD_ADMIN_TOKEN` задан | +| Агент (`hard-monitor`) | заголовок `X-Server-Key: ghm_…` (только ingest своего сервера) | ключ сервера в БД панели | + +Если все три выключены — панель полностью открыта (личный режим). +Любая включённая строка закрывает `/api/v1/*` только для себя: токен и +cookie взаимозаменяемы, агентский ключ проверяется только на `/ingest`. + **2. Запуск:** ```bash diff --git a/docker-compose.yml b/docker-compose.yml index d8692a8..1e76ba7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,13 @@ environment: # задай свой токен: `cp panel/backend/.env.example .env` и поправь GHARD_ADMIN_TOKEN: ${GHARD_ADMIN_TOKEN:-change-me} + # gnexus-auth (OAuth PKCE): задаёшь CLIENT_ID — панель закрывается входом + GHARD_AUTH_BASE_URL: ${GHARD_AUTH_BASE_URL:-} + GHARD_AUTH_CLIENT_ID: ${GHARD_AUTH_CLIENT_ID:-} + GHARD_AUTH_CLIENT_SECRET: ${GHARD_AUTH_CLIENT_SECRET:-} + GHARD_AUTH_REDIRECT_URI: ${GHARD_AUTH_REDIRECT_URI:-} + GHARD_AUTH_WEBHOOK_SECRET: ${GHARD_AUTH_WEBHOOK_SECRET:-} + GHARD_AUTH_ALLOWLIST: ${GHARD_AUTH_ALLOWLIST:-} volumes: - panel-data:/data restart: unless-stopped diff --git a/panel/backend/.env.example b/panel/backend/.env.example index 04d83da..6bf60cd 100644 --- a/panel/backend/.env.example +++ b/panel/backend/.env.example @@ -1,7 +1,8 @@ # GHard Monitor panel — настройки (env-префикс GHARD_) # Опциональный Bearer-токен API (сгенерируй: openssl rand -hex 32). -# Пусто = API открыт без авторизации (веб-интерфейс работает без логина). +# Пусто = API открыт (если авторизация gnexus-auth тоже выключена). +# Токен удобен для скриптов/MCP — он работает и при включённом gnexus-auth. # GHARD_ADMIN_TOKEN= # Путь к SQLite-файлу (в docker — volume /data) @@ -11,4 +12,19 @@ GHARD_OFFLINE_MULTIPLIER=3 # период опроса сетевых хранилищ (раздел Storage), сек -GHARD_SHARE_INTERVAL=60 \ No newline at end of file +GHARD_SHARE_INTERVAL=60 + +# --- gnexus-auth (OAuth PKCE) ------------------------------------------------ +# Задай GHARD_AUTH_CLIENT_ID — и веб-интерфейс закроется входом через +# gnexus-auth (клиент регистрируется в админке gnexus-auth: /admin/clients). +# GHARD_AUTH_BASE_URL=https://gnexus-auth.local +# GHARD_AUTH_CLIENT_ID=gnx_hard_panel +# GHARD_AUTH_CLIENT_SECRET= +# Redirect URI, который регистрируется для клиента +# GHARD_AUTH_REDIRECT_URI=http://localhost:8001/auth/callback +# Секрет вебхука gnexus-auth (авто-логаут при global_logout, смена профиля) +# GHARD_AUTH_WEBHOOK_SECRET= +# Пусто = пускаем любого залогиненного; через запятую e-mail/user_id +# GHARD_AUTH_ALLOWLIST= +# Время жизни cookie-сессии, сек (7 суток) +# GHARD_SESSION_TTL_SECONDS=604800 diff --git a/panel/backend/Dockerfile b/panel/backend/Dockerfile index 7ad5876..845254c 100644 --- a/panel/backend/Dockerfile +++ b/panel/backend/Dockerfile @@ -18,6 +18,10 @@ WORKDIR /app +# git нужен pip-у: gnexus-gauth ставится из git-репозитория +RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \ + && rm -rf /var/lib/apt/lists/* + COPY panel/backend/pyproject.toml ./ COPY panel/backend/app ./app RUN pip install --no-cache-dir . diff --git a/panel/backend/app/api/auth_routes.py b/panel/backend/app/api/auth_routes.py new file mode 100644 index 0000000..4798222 --- /dev/null +++ b/panel/backend/app/api/auth_routes.py @@ -0,0 +1,192 @@ +"""OAuth/session-роуты gnexus-auth — скроировано с gnexus-creds. + +GET /auth/login?return_to=/… — 302 на gnexus-auth (PKCE, state в oauth_states) +GET /auth/callback?code&state — обмен кода, сессия в cookie, redirect на return_to +POST /auth/logout — удалить сессию +GET /api/v1/me — профиль из сессии (для экрана входа и identity в дровере) +POST /webhooks/gnexus-auth(+/) — HMAC-верифицированные события gnexus-auth +""" + +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Request, Response +from fastapi.responses import JSONResponse, RedirectResponse +from gnexus_gauth.exceptions import ( + StateValidationException, + TokenExchangeException, + TransportException, +) +from gnexus_gauth.oauth import AuthorizationUrlBuilder, PkceGenerator + +from app.auth import ( + SCOPES, + SESSION_COOKIE, + allowlisted, + auth_enabled, + create_session, + delete_session, + exchange_and_fetch, + get_ctx, + safe_return_to, +) +from app.config import get_settings +from app.db import get_db +from app.security import auth_mode_error + +router = APIRouter() +webhook_router = APIRouter() + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _now_iso() -> str: + return _now().isoformat() + + +@router.get("/auth/login") +async def login(request: Request, return_to: str = "/") -> RedirectResponse: + """Начало OAuth: state + PKCE → oauth_states, 302 на gnexus-auth.""" + settings = get_settings() + return_to = safe_return_to(return_to) + ctx = get_ctx() + state = PkceGenerator.generate_state() + verifier = PkceGenerator.generate_verifier() + challenge = PkceGenerator.generate_challenge(verifier) + db = get_db() + await db.execute( + "INSERT INTO oauth_states (state, pkce_verifier, return_to, expires_at) VALUES (?, ?, ?, ?)", + (state, verifier, return_to, (_now() + timedelta(seconds=settings.session_ttl_seconds)).isoformat()), + ) + await db.commit() + url = AuthorizationUrlBuilder(ctx["config"]).build( + state=state, + pkce_challenge=challenge, + return_to=return_to, + scopes=SCOPES, + ) + return RedirectResponse(url, status_code=302) + + +@router.get("/auth/callback") +async def callback(request: Request, code: str = "", state: str = ""): + """Обмен кода на токен, сессия в cookie, редирект на return_to.""" + if not state or not code: + raise auth_mode_error() + settings = get_settings() + db = get_db() + cursor = await db.execute( + "SELECT pkce_verifier, return_to, expires_at FROM oauth_states WHERE state = ?", + (state,), + ) + saved = await cursor.fetchone() + if saved is None or saved["expires_at"] < _now_iso(): + return JSONResponse( + {"detail": "Invalid or expired OAuth state."}, status_code=400 + ) + try: + import asyncio + + _, user = await asyncio.to_thread( + exchange_and_fetch, code, saved["pkce_verifier"] + ) + except (TokenExchangeException, StateValidationException, TransportException): + return JSONResponse( + {"detail": "Authorization server is unreachable or rejected the login."}, + status_code=502, + ) + if not allowlisted(user.email, user.user_id): + return JSONResponse({"detail": "User is not allowed here."}, status_code=403) + if user.status in {"disabled", "blocked", "deleted"}: + return JSONResponse({"detail": "User is disabled."}, status_code=403) + session_id = await create_session(user) + await db.execute("DELETE FROM oauth_states WHERE state = ?", (state,)) + await db.commit() + response = RedirectResponse(safe_return_to(saved["return_to"]), status_code=302) + response.set_cookie( + SESSION_COOKIE, + session_id, + httponly=True, + samesite="lax", + max_age=settings.session_ttl_seconds, + secure=bool(settings.auth_redirect_uri.startswith("https://")), + ) + return response + + +@router.post("/auth/logout") +async def logout(request: Request) -> Response: + """Удалить свою сессию (cookie гасится тоже).""" + settings = get_settings() + await delete_session(request.cookies.get(SESSION_COOKIE)) + response = JSONResponse({"status": "ok"}) + response.delete_cookie( + SESSION_COOKIE, + httponly=True, + samesite="lax", + secure=bool(settings.auth_redirect_uri.startswith("https://")), + ) + return response + + +# --- /me для фронтенда (в api/v1 — тот же префикс SPA) ------------------------- + +me_router = APIRouter(prefix="/api/v1") + + +@me_router.get("/me", dependencies=[]) +async def me(request: Request) -> dict: + """Профиль текущего пользователя + флаг режима авторизации. + + Auth выключен → {auth_enabled: false} — SPA молча открывает панель. + Включён и сессии нет → 401 (SPA показывает экран входа). + Включён и сессия есть → {auth_enabled: true, user: …}. + """ + if not auth_enabled(): + return {"auth_enabled": False, "user": None} + from app.auth import get_session + + session = await get_session(request.cookies.get(SESSION_COOKIE)) + if session is None: + raise auth_mode_error() + return { + "auth_enabled": True, + "user": { + "user_id": session["user_id"], + "email": session["email"], + "display_name": session["display_name"], + "avatar_url": session["avatar_url"], + }, + } + + +# --- Webhook gnexus-auth (HMAC) ------------------------------------------------ + +@webhook_router.post("/webhooks/gnexus-auth") +@webhook_router.post("/webhooks/gnexus-auth/") +async def gnexus_auth_webhook(request: Request) -> dict: + from app.auth import apply_webhook_update, get_ctx + + settings = get_settings() + db = get_db() + raw = (await request.body()).decode("utf-8", errors="replace") + headers = dict(request.headers) + try: + event = get_ctx()["webhook"].verify_and_parse_webhook( + raw, headers, settings.auth_webhook_secret + ) + except Exception: + return JSONResponse({"detail": "invalid webhook signature"}, status_code=401) + subject = ( + event.target_identifiers.get("sub") + or event.target_identifiers.get("user_id") + or event.metadata.get("sub") + ) + if subject: + await apply_webhook_update(str(subject), event.event_type, event.metadata) + return {"status": "ok"} + + +# /api/v1/me проверяет сессию без require_admin — он сам является gate-палкой. +# Остальное API защищается в security.py (routers подключаются ниже). \ No newline at end of file diff --git a/panel/backend/app/auth.py b/panel/backend/app/auth.py new file mode 100644 index 0000000..a981234 --- /dev/null +++ b/panel/backend/app/auth.py @@ -0,0 +1,176 @@ +"""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() \ No newline at end of file diff --git a/panel/backend/app/config.py b/panel/backend/app/config.py index 5907fad..cfca9bd 100644 --- a/panel/backend/app/config.py +++ b/panel/backend/app/config.py @@ -11,8 +11,18 @@ database_path: Path = Path("/data/hard-panel.db") # пусто = API панели открыт без авторизации (личная панель); - # если задан — доступ только с Bearer-токеном + # если задан — доступ только с Bearer-токеном (скрипты, MCP) admin_token: str = "" + # gnexus-auth: задан client_id — браузерная авторизация через gnexus-auth + # (OAuth PKCE + сессионная cookie); пусто — панель открыта, как выше + auth_base_url: str = "" + auth_client_id: str = "" + auth_client_secret: str = "" + auth_redirect_uri: str = "" + auth_webhook_secret: str = "" + # пусто = пропускаем любого залогиненного; через запятую — e-mail/user_id + auth_allowlist: str = "" + session_ttl_seconds: int = 7 * 24 * 3600 # offline = нет данных дольше offline_multiplier * interval агента (сек) offline_multiplier: int = 3 # как часто панель опрашивает сетевые хранилища (сек) diff --git a/panel/backend/app/main.py b/panel/backend/app/main.py index d640a8b..d02097c 100644 --- a/panel/backend/app/main.py +++ b/panel/backend/app/main.py @@ -2,13 +2,15 @@ from contextlib import asynccontextmanager from pathlib import Path -from fastapi import FastAPI +from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware -from starlette.routing import Route +from fastapi.routing import APIRoute from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles -from app.api import ingest, servers, shares +from app.security import require_admin + +from app.api import auth_routes, ingest, servers, shares from app.db import close_db, init_db from app.mcp import mcp, mcp_asgi from app.shares_probe import loop as shares_probe_loop @@ -23,14 +25,28 @@ await init_db() # фоновый пробер сетевых хранилищ (share_samples) probe_task = asyncio.create_task(shares_probe_loop()) + # подчистка expired-сессий и oauth state (каждые 10 минут) + gc_task = asyncio.create_task(auth_gc_loop()) # lifespan смонтированных sub-app не вызывается — MCP session manager # стартуем здесь, иначе /mcp отвечает 500 ("Task group is not initialized") async with mcp.session_manager.run(): yield probe_task.cancel() + gc_task.cancel() await close_db() +async def auth_gc_loop() -> None: + from app.auth import purge_expired + + while True: + try: + await purge_expired() + except Exception as exc: # не роняем цикл из-за одной ошибки + print(f"auth gc error: {exc}", flush=True) + await asyncio.sleep(600) + + app = FastAPI(title="GHard Monitor API", version="0.1.0", lifespan=lifespan) # В проде фронт раздаётся тем же origin; CORS нужен для vite dev-сервера (этап 3) @@ -41,13 +57,20 @@ allow_headers=["*"], ) +# OAuth/session-роуты gnexus-auth: должны быть зарегистрированы ДО SPA catch-all +app.include_router(auth_routes.router) +app.include_router(auth_routes.me_router) +app.include_router(auth_routes.webhook_router) + app.include_router(ingest.router) app.include_router(servers.router) app.include_router(shares.router) -# MCP для ИИ-агентов (streamable HTTP, stateless) — POST/GET/DELETE /mcp +# MCP для ИИ-агентов (streamable HTTP, stateless) — POST/GET/DELETE /mcp. +# Доступ как у REST API (require_admin): Bearer-токен, если задан; +# браузерная cookie gnexus-auth тоже проходит (для MCP-клиента-агента). app.router.routes.append( - Route("/mcp", mcp_asgi, methods=["GET", "POST", "DELETE"]) + APIRoute("/mcp", mcp_asgi, methods=["GET", "POST", "DELETE"], dependencies=[Depends(require_admin)]) ) diff --git a/panel/backend/app/schema.sql b/panel/backend/app/schema.sql index fbf2e81..a2e1e6b 100644 --- a/panel/backend/app/schema.sql +++ b/panel/backend/app/schema.sql @@ -71,4 +71,24 @@ ok INTEGER NOT NULL DEFAULT 1 -- 1 = путь примонтирован и читается ); -CREATE INDEX IF NOT EXISTS idx_share_samples_share_ts ON share_samples(share_id, ts); \ No newline at end of file +CREATE INDEX IF NOT EXISTS idx_share_samples_share_ts ON share_samples(share_id, ts); +-- Незавершённые OAuth-потоки gnexus-auth (state → pkce_verifier, 10 минут) +CREATE TABLE IF NOT EXISTS oauth_states ( + state TEXT PRIMARY KEY, + pkce_verifier TEXT NOT NULL, + return_to TEXT NOT NULL DEFAULT '/', + expires_at TEXT NOT NULL +); + +-- Браузерные сессии gnexus-auth (cookie ↔ профиль; TTL по expires_at) +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + email TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + avatar_url TEXT NOT NULL DEFAULT '', + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); diff --git a/panel/backend/app/security.py b/panel/backend/app/security.py index a8e699c..ff37534 100644 --- a/panel/backend/app/security.py +++ b/panel/backend/app/security.py @@ -1,7 +1,7 @@ import hashlib import secrets -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from app.config import get_settings @@ -18,19 +18,37 @@ return hashlib.sha256(key.encode()).hexdigest() +def auth_mode_error() -> HTTPException: + """401/403, когда браузер не залогинен в gnexus-auth (GHARD_AUTH_CLIENT_ID задан).""" + return HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="not authenticated") + + async def require_admin( + request: Request, creds: HTTPAuthorizationCredentials | None = Depends(_bearer), ) -> None: - """Админ-доступ к API панели. + """Доступ к API панели (данные, CRUD, MCP). - GHARD_ADMIN_TOKEN не задан — API открыт (личная панель без логина). - Задан — требуется Bearer-токен. + Порядок: + 1. Bearer GHARD_ADMIN_TOKEN (если задан) — скрипты, MCP, curl. + 2. Агенты: заголовок X-Server-Key проверяется только в ingest-роутере. + 3. GHARD_AUTH_CLIENT_ID задан → браузер с валидной сессионной cookie. + 4. auth выключен и admin_token пуст → API открыт (личная панель). """ - token = get_settings().admin_token + settings = get_settings() + token = settings.admin_token + if token and creds is not None and creds.scheme.lower() == "bearer" and creds.credentials == token: + return + if settings.auth_client_id: + from app.auth import SESSION_COOKIE, get_session + + session = await get_session(request.cookies.get(SESSION_COOKIE)) + if session is None: + raise auth_mode_error() + return if not token: return - if creds is None or creds.scheme.lower() != "bearer" or creds.credentials != token: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="invalid admin token", - ) \ No newline at end of file + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="invalid admin token", + ) \ No newline at end of file diff --git a/panel/backend/pyproject.toml b/panel/backend/pyproject.toml index aaad607..cac5722 100644 --- a/panel/backend/pyproject.toml +++ b/panel/backend/pyproject.toml @@ -10,6 +10,7 @@ "pydantic>=2.7", "pydantic-settings>=2.3", "mcp>=1.10,<2", + "gnexus-gauth @ git+https://git.gnexus.space/git/root/gnexus-auth-client-py.git", ] [build-system] diff --git a/panel/frontend/src/App.vue b/panel/frontend/src/App.vue index ae9af76..2366883 100644 --- a/panel/frontend/src/App.vue +++ b/panel/frontend/src/App.vue @@ -1,27 +1,127 @@