diff --git a/.env.example b/.env.example index d5c7b9c..568112a 100644 --- a/.env.example +++ b/.env.example @@ -18,8 +18,13 @@ # id/secret OAuth-клиента — зарегистрировать приложение Synapse в gnexus-auth и подставить сюда. GAUTH_CLIENT_ID=set-me GAUTH_CLIENT_SECRET=set-me -# Куда gnexus-auth возвращает пользователя после логина (SPA). -GAUTH_REDIRECT_URI=https://synapse.localhost/oauth/callback +# Куда gnexus-auth возвращает пользователя после логина — endpoint callback'а на API. +# В проде: https://<домен synapse>/auth/callback +GAUTH_REDIRECT_URI=http://localhost:8001/auth/callback +# Проверять ли TLS-сертификат gnexus-auth (в LAN с self-signed — false). +GAUTH_VERIFY_TLS=false +# Публичный URL SPA для редиректа после callback. Пусто = тот же origin (прод). +SPA_PUBLIC_URL=http://localhost:5173 # --- Misc --- # Явные URL входа, если SPA запускается в dev-режиме на отдельном порту. diff --git a/CLAUDE.md b/CLAUDE.md index 4840846..fc6ce0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,9 @@ - **Python SDK**: `gnexus-gauth` (репо gnexus-auth-client-py, зависимость только httpx): полный OAuth2 Authorization Code + PKCE, exchange/refresh/revoke, `fetch_user()` → `AuthenticatedUser`. Для SPA у gnexus-auth есть свой JS-клиент (`packages/auth-client/`, доки 18–20). - **Роли**: системные — `superadmin > admin > user` (глобальные), плюс клиентские (per-сервис slug'и, пока не используем). **Доступ к админке = `system_role in ("admin", "superadmin")`** — аналог `SystemRole::canAccessAdminArea()` на сервере auth. - **userinfo-ответ**: `sub`, `email`, `system_role`, `profile`, `client.roles/permissions` (scopes: `openid email profile roles permissions`). Access-токен живёт ~30 мин, есть refresh-ротация с детекцией переиспользования. -- **Паттерн FastAPI**: dependency `get_current_user` (токен из заголовка, `fetch_user`, короткое кэширование userinfo) → `require_admin` → 403, если роль ниже admin. Инвалидировать кэш при 401; можно слушать webhooks `role.*`/`user.*` с HMAC-верификацией. +- **OAuth-поток на сервере** (`app/api/auth_routes.py`, клиент gnexus-gauth): `GET /auth/login` (state+PKCE в Redis, redirect на authorize) → `GET /auth/callback` (обмен кода, проверка роли **до выдачи токена**: не-админу access-токен сразу revoke) → SPA получает токены в URL-фрагменте `#`. Тихое обновление — `POST /auth/refresh`, логаут — `POST /auth/revoke` (revoke access-токена). +- Токены — opaque; каждый запрос API валидируется через userinfo. Креды клиента (GAUTH_CLIENT_ID/SECRET) — только в `.env`, в UI авторизация никак не конфигурируется. +- TLS gnexus-auth в LAN self-signed: `GAUTH_VERIFY_TLS=false` по умолчанию; в проде с нормальным сертификатом — `true`. - Токен передаётся `Authorization: Bearer` — cookie не годятся (сессия gnexus-auth не выходит за пределы auth-сервера); SPA-клиент хранит access/refresh сам (docs 18–20 описывают клиентскую сторону). ## GNexus UI Kit diff --git a/README.md b/README.md index 602d5d1..46c20c3 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,18 @@ ## Вход в админку -1. Зарегистровать OAuth-клиент `synapse` на сервере gnexus-auth и вписать креды в `.env` (`GAUTH_*`). -2. Получить access-токен через `POST /oauth/token` gnexus-auth (или вставить токен в форму входа — dev-режим каркаса). -3. Пользователь с `system_role` ниже `admin` получает экран «Доступ запрещён» (и 403 от API). +SSO-поток целиком на сервере (gnexus-auth-client-py, OAuth2 Authorization Code + PKCE): + +1. Зарегистрировать OAuth-клиент `synapse` в gnexus-auth, redirect URI — `<публичный URL synapse>/auth/callback`; креды в `.env` (`GAUTH_*`). +2. `GET /auth/login` кладёт state+PKCE в Redis и уводит браузер на форму логина gnexus-auth. +3. `GET /auth/callback` обменивает код, проверяет `system_role`: **админ получает токен** (access+refresh в URL-фрагменте), пользователю ниже admin токен не выдаётся — access-токен сразу revoke, браузер уходит на экран «Доступ запрещён». +4. SPA делает тихое обновление токена через `POST /auth/refresh`, логаут — `POST /auth/revoke`. API продолжает проверять каждый запрос через `GET /oauth/userinfo`. + +### Проверка без живого gnexus-auth + +```bash +curl -i http://localhost:8001/auth/login # 302 на authorize endpoint с client_id/code_challenge/state +``` ## Структура diff --git a/app/api/auth_routes.py b/app/api/auth_routes.py new file mode 100644 index 0000000..ed4f506 --- /dev/null +++ b/app/api/auth_routes.py @@ -0,0 +1,109 @@ +"""SSO-эндпоинты: login (redirect на gnexus-auth), callback (обмен кода), refresh, revoke. + +Весь OAuth2 Authorization Code + PKCE выполняется на сервере через gnexus-gauth: +токены никогда не проходят через браузер напрямую, а роль проверяется до выдачи +токена пользователю. +""" + +from urllib.parse import quote, urlencode + +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import RedirectResponse +from gnexus_gauth.client import GAuthClient +from pydantic import BaseModel + +from app.auth.client import GAUTH_SCOPES, get_gauth_client +from app.auth.deps import ADMIN_ROLES +from app.config import Settings, get_settings + +router = APIRouter(prefix="/auth") + + +def _spa_redirect(settings: Settings, path: str, fragment: dict | None = None) -> RedirectResponse: + """Редирект на адрес SPA (dev: SPA_PUBLIC_URL, прод: тот же origin).""" + url = f"{settings.spa_public_url.rstrip('/')}{path}" if settings.spa_public_url else path + if fragment: + # Фрагмент (#) — не логируется серверами и не уходит в историю запросов. + url += "#" + urlencode(fragment, safe="", quote_via=quote) + return RedirectResponse(url) + + +@router.get("/login") +def login( + return_to: str | None = None, + settings=Depends(get_settings), +) -> RedirectResponse: + """Начало SSO: state+PKCE складываются в Redis, браузер уходит на авторизацию gnexus-auth.""" + client = get_gauth_client() + request = client.build_authorization_request(return_to=return_to, scopes=GAUTH_SCOPES) + return RedirectResponse(request.authorization_url) + + +@router.get("/callback") +def callback( + code: str | None = None, + state: str | None = None, + error: str | None = None, + settings=Depends(get_settings), +) -> RedirectResponse: + """Код от gnexus-auth -> TokenSet. Пользователю без роли admin токен не выдаётся.""" + if error or not code or not state: + return _spa_redirect(settings, "/login", {"error": error or "missing_code_or_state"}) + + client = get_gauth_client() + try: + tokens = client.exchange_authorization_code(code, state) + except Exception: + return _spa_redirect(settings, "/login", {"error": "exchange_failed"}) + + try: + user = client.fetch_user(tokens.access_token) + except Exception: + return _spa_redirect(settings, "/login", {"error": "userinfo_failed"}) + + if (user.system_role or "") not in ADMIN_ROLES: + # Отзываем access-токен сразу: он валиден ~30 минут, не оставляем его пользователю. + try: + client.revoke_token(tokens.access_token) + except Exception: + pass + return _spa_redirect(settings, "/denied", {}) + + fragment = { + "access_token": tokens.access_token, + "expires_in": tokens.expires_in, + "token_type": tokens.token_type, + } + if tokens.refresh_token: + fragment["refresh_token"] = tokens.refresh_token + return _spa_redirect(settings, "/oauth/callback", fragment) + + +class RefreshRequest(BaseModel): + refresh_token: str + + +@router.post("/refresh") +def refresh(body: RefreshRequest) -> dict: + """Обновление access-токена (SPA делает это тихо до истечения текущего).""" + try: + tokens = get_gauth_client().refresh_token(body.refresh_token) + except Exception: + raise HTTPException(status_code=401, detail="Refresh-токен невалиден") from None + payload: dict = { + "access_token": tokens.access_token, + "expires_in": tokens.expires_in, + } + if tokens.refresh_token: + payload["refresh_token"] = tokens.refresh_token + return payload + + +@router.post("/revoke") +def revoke(request: Request) -> dict: + """Логаут: отзываем access-токен (гнексус-auth учитывает это мгновенно).""" + auth = request.headers.get("Authorization", "") + if not auth.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Требуется Bearer-токен") + get_gauth_client().revoke_token(auth.removeprefix("Bearer ")) + return {"revoked": True} \ No newline at end of file diff --git a/app/auth/client.py b/app/auth/client.py new file mode 100644 index 0000000..61a7ec2 --- /dev/null +++ b/app/auth/client.py @@ -0,0 +1,44 @@ +"""Сборка единственного на процесс gnexus-gauth клиента (SSO + валидация токенов).""" + +from functools import lru_cache + +import httpx +from gnexus_gauth.client import GAuthClient +from gnexus_gauth.config import GAuthConfig +from gnexus_gauth.oauth import HttpTokenEndpoint +from gnexus_gauth.runtime import HttpRuntimeUserProvider +from gnexus_gauth.support import SystemClock +from gnexus_gauth.webhook import HmacWebhookVerifier, JsonWebhookParser + +from app.auth.stores import RedisPkceStore, RedisStateStore +from app.config import Settings, get_settings + +# Scopes полного userinfo: системная роль + клиентские роли/права + профиль. +GAUTH_SCOPES = ["openid", "email", "profile", "roles", "permissions"] + + +def build_gauth(config: Settings) -> GAuthClient: + gconf = GAuthConfig( + base_url=config.gauth_base_url, + client_id=config.gauth_client_id, + client_secret=config.gauth_client_secret, + redirect_uri=config.gauth_redirect_uri, + user_agent="gnexus-synapse", + ) + # gnexus-auth в LAN живёт за self-signed TLS — verify отключается через env. + http = httpx.Client(verify=config.gauth_verify_tls) + return GAuthClient( + config=gconf, + token_endpoint=HttpTokenEndpoint(gconf, http), + runtime_user_provider=HttpRuntimeUserProvider(gconf, http), + webhook_verifier=HmacWebhookVerifier(gconf), + webhook_parser=JsonWebhookParser(), + state_store=RedisStateStore(), + pkce_store=RedisPkceStore(), + clock=SystemClock(), + ) + + +@lru_cache +def get_gauth_client() -> GAuthClient: + return build_gauth(get_settings()) \ No newline at end of file diff --git a/app/auth/deps.py b/app/auth/deps.py index 8725c6b..194fad3 100644 --- a/app/auth/deps.py +++ b/app/auth/deps.py @@ -2,40 +2,24 @@ Токены gnexus-auth — opaque (не JWT), валидация только «call home»: GET /oauth/userinfo с Bearer-токеном. Отзыв токена и блокировка -пользователя учитываются сервером auth мгновенно. +пользователя учитываются сервером auth мгновенно. Сам OAuth-поток +(state, PKCE, обмен кода) — в app/api/auth_routes.py через gnexus-gauth. Доступ к админке — system_role in ("admin", "superadmin"). """ from fastapi import Depends, HTTPException, Request, status -from gnexus_gauth.config import GAuthConfig from gnexus_gauth.dto import AuthenticatedUser -from gnexus_gauth.runtime import HttpRuntimeUserProvider -from app.config import Settings, get_settings +from app.auth.client import get_gauth_client +from app.config import get_settings ADMIN_ROLES = ("admin", "superadmin") -_user_provider: HttpRuntimeUserProvider | None = None - - -def _get_user_provider(settings: Settings) -> HttpRuntimeUserProvider: - """Один провайдер на процесс (внутри — свой httpx.Client).""" - global _user_provider - if _user_provider is None: - config = GAuthConfig( - base_url=settings.gauth_base_url, - client_id=settings.gauth_client_id, - client_secret=settings.gauth_client_secret, - redirect_uri=settings.gauth_redirect_uri, - ) - _user_provider = HttpRuntimeUserProvider(config) - return _user_provider - def get_current_user( request: Request, - settings: Settings = Depends(get_settings), + settings=Depends(get_settings), ) -> AuthenticatedUser: """Валидирует Bearer-токен через userinfo gnexus-auth.""" auth = request.headers.get("Authorization", "") @@ -47,7 +31,7 @@ ) try: # TODO: короткий TTL-кэш userinfo по токену (до 60 c) — после появления нагрузки. - return _get_user_provider(settings).fetch_user(auth.removeprefix("Bearer ")) + return get_gauth_client().fetch_user(auth.removeprefix("Bearer ")) except Exception: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, diff --git a/app/auth/stores.py b/app/auth/stores.py new file mode 100644 index 0000000..47dbb11 --- /dev/null +++ b/app/auth/stores.py @@ -0,0 +1,58 @@ +"""Redis-хранилища state/PKCE для OAuth-потока (TTL = срок state в gnexus-gauth). + +Redis вместо in-memory: переживает рестарт api-контейнера и +работает при нескольких воркерах uvicorn/gunicorn. +""" + +import json +from datetime import datetime + +from redis import Redis + +from app.config import get_settings + +STATE_PREFIX = "synapse:auth:state:" +PKCE_PREFIX = "synapse:auth:pkce:" + +_redis: Redis | None = None + + +def get_redis() -> Redis: + global _redis + if _redis is None: + _redis = Redis.from_url(get_settings().redis_url, decode_responses=True) + return _redis + + +def _ttl(expires_at: datetime) -> int: + return max(1, int(expires_at.timestamp() - datetime.now(expires_at.tzinfo).timestamp())) + + +class RedisStateStore: + """StateStoreInterface: state -> context (return_to, scopes).""" + + def put(self, state: str, expires_at: datetime, context: dict | None = None) -> None: + get_redis().setex(STATE_PREFIX + state, _ttl(expires_at), json.dumps(context or {})) + + def has(self, state: str) -> bool: + return bool(get_redis().exists(STATE_PREFIX + state)) + + def get_context(self, state: str) -> dict: + raw = get_redis().get(STATE_PREFIX + state) + return json.loads(raw) if raw else {} + + def forget(self, state: str) -> None: + get_redis().delete(STATE_PREFIX + state) + + +class RedisPkceStore: + """PkceStoreInterface: state -> PKCE verifier.""" + + def put(self, state: str, verifier: str, expires_at: datetime) -> None: + get_redis().setex(PKCE_PREFIX + state, _ttl(expires_at), verifier) + + def get(self, state: str) -> str | None: + return get_redis().get(PKCE_PREFIX + state) + + def forget(self, state: str) -> None: + get_redis().delete(PKCE_PREFIX + state) \ No newline at end of file diff --git a/app/config.py b/app/config.py index 5f0aa07..84ffb65 100644 --- a/app/config.py +++ b/app/config.py @@ -16,12 +16,20 @@ database_url: str = "postgresql+psycopg://synapse:synapse@localhost:5432/synapse" redis_url: str = "redis://localhost:6379/0" - # gnexus-auth (SSO). Пока клиент не зарегистрирован на сервере auth, - # здесь заглушки (не пустые — валидатор GAuthConfig требует непустые). + # gnexus-auth (SSO). Креды OAuth-клиента synapse — только из окружения + # (зарегистрировать клиент на сервере gnexus-auth и заполнить .env). gauth_base_url: str = "https://192.168.1.167" gauth_client_id: str = "set-me" gauth_client_secret: str = "set-me" - gauth_redirect_uri: str = "https://synapse.localhost/oauth/callback" + # Куда gnexus-auth возвращает пользователя — наш API-эндпоинт callback'а. + gauth_redirect_uri: str = "http://localhost:8001/auth/callback" + # gnexus-auth в LAN может сидеть за self-signed TLS (по умолчанию не проверяем), + # в проде с нормальным сертификатом выставить GAUTH_VERIFY_TLS=true. + gauth_verify_tls: bool = False + + # Публичный URL SPA для редиректа после callback. Пусто = тот же origin, + # что и API (прод: SPA раздаётся этим же контейнером). Dev: vite :5173. + spa_public_url: str = "http://localhost:5173" # SPA лежит в образе рядом с приложением; в dev используется vite-сервер. spa_dist_dir: Path = BASE_DIR / "spa_static" diff --git a/app/main.py b/app/main.py index 38902a5..a6f9fa0 100644 --- a/app/main.py +++ b/app/main.py @@ -4,6 +4,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles +from app.api.auth_routes import router as auth_router from app.api.routes import router as api_router from app.config import get_settings from app.worker.celery_app import celery_app # noqa: F401 — регистрирует задачи в очереди import'ов @@ -23,6 +24,7 @@ ) app.include_router(api_router) + app.include_router(auth_router) # Собранная SPA: в Docker-образе кладётся в spa_static/. Если собранной # статики нет (локальный запуск api без фронта) — просто пропускаем. diff --git a/frontend/src/api.js b/frontend/src/api.js index 7fc4817..4423af2 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1,15 +1,21 @@ -/** Тонкий клиент к API Synapse. Токен — opaque Bearer от gnexus-auth. */ +/** Тонкий клиент к API Synapse. Токены — opaque Bearer от gnexus-auth, приходят + * с /auth/callback (сервер сам ведёт OAuth, UI только принимает результат). */ const TOKEN_KEY = "synapse.access_token"; +const REFRESH_KEY = "synapse.refresh_token"; -export function getToken() { +function getItem(key) { try { - return localStorage.getItem(TOKEN_KEY) || ""; + return localStorage.getItem(key) || ""; } catch { return ""; } } +export function getToken() { + return getItem(TOKEN_KEY); +} + export function setToken(token) { try { localStorage.setItem(TOKEN_KEY, token); @@ -18,21 +24,67 @@ } } +export function getRefreshToken() { + return getItem(REFRESH_KEY); +} + +export function setRefreshToken(token) { + try { + localStorage.setItem(REFRESH_KEY, token); + } catch { + /* см. setToken */ + } +} + export function clearToken() { try { localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(REFRESH_KEY); } catch { /* игнорируем */ } } -async function apiFetch(path, options = {}) { +let refreshing = null; + +/** Тихое обновление access-токена по refresh-токену (одним полётом на все 401). */ +function refreshAccessToken() { + refreshing ??= fetch("/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refresh_token: getRefreshToken() }), + }) + .then(async (response) => { + if (!response.ok) throw new Error("refresh_failed"); + const payload = await response.json(); + setToken(payload.access_token); + if (payload.refresh_token) setRefreshToken(payload.refresh_token); + return payload.access_token; + }) + .finally(() => { + refreshing = null; + }); + return refreshing; +} + +async function apiFetch(path, options = {}, retried = false) { const headers = { ...(options.headers || {}) }; const token = getToken(); if (token) headers.Authorization = `Bearer ${token}`; if (options.body) headers["Content-Type"] = "application/json"; - const response = await fetch(path, { ...options, headers }); + let response = await fetch(path, { ...options, headers }); + + // Токен истёк — пробуем обновить и повторить запрос один раз. + if (response.status === 401 && retried === false && getRefreshToken()) { + try { + await refreshAccessToken(); + return apiFetch(path, options, true); + } catch { + clearToken(); + } + } + if (!response.ok) { let detail = response.statusText; try { @@ -52,7 +104,16 @@ return apiFetch("/api/healthz"); } -/** Профиль текущего пользователя. 401 — токена нет/невалиден, 403 — роль ниже admin. */ +/** Профиль текущего пользователя. 401 — токен невалиден, 403 — роль ниже admin. */ export function adminMe() { return apiFetch("/api/v1/admin/me"); +} + +/** Логаут: revoke access-токена на gnexus-auth. */ +export async function logout() { + try { + await apiFetch("/auth/revoke", { method: "POST" }); + } finally { + clearToken(); + } } \ No newline at end of file diff --git a/frontend/src/router.js b/frontend/src/router.js index e50258d..3b7c6a0 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -1,8 +1,9 @@ import { createRouter, createWebHistory } from "vue-router"; -import { getToken } from "./api.js"; +import { clearToken, getToken } from "./api.js"; import LoginView from "./views/LoginView.vue"; import DashboardView from "./views/DashboardView.vue"; import DeniedView from "./views/DeniedView.vue"; +import OauthCallbackView from "./views/OauthCallbackView.vue"; // Гейт ролей: /dashboard и далее — только system_role admin и выше. // Проверка роли происходит на сервере (require_admin), фронт лишь заранее @@ -13,6 +14,7 @@ { path: "/", redirect: "/dashboard" }, { path: "/login", component: LoginView }, { path: "/denied", component: DeniedView }, + { path: "/oauth/callback", component: OauthCallbackView }, { path: "/dashboard", component: DashboardView, diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index ad26c13..a5360bb 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -1,7 +1,15 @@ @@ -37,20 +14,11 @@
- Вход через gnexus-auth. Скелет: вставьте access-токен (dev-режим), финальный - OAuth-поток — в задаче #32. + Вход через единую систему gnexus-auth. Доступ — только роли admin и выше.
- -