diff --git a/.env.example b/.env.example index efa2c11..c42e535 100644 --- a/.env.example +++ b/.env.example @@ -20,7 +20,7 @@ GAUTH_CLIENT_SECRET=set-me # Куда gnexus-auth возвращает пользователя после логина — endpoint callback'а на API. # В проде: https://<домен synapse>/auth/callback -GAUTH_REDIRECT_URI=http://localhost:8001/auth/callback +GAUTH_REDIRECT_URI=http://localhost:8012/auth/callback # Проверять ли TLS-сертификат gnexus-auth (в LAN с self-signed — false). GAUTH_VERIFY_TLS=false # Секрет подписи вебхуков gnexus-auth — показывается один раз при создании/ротации клиента. @@ -30,4 +30,4 @@ # --- Misc --- # Порт API на хосте. -SYNAPSE_PORT=8000 \ No newline at end of file +SYNAPSE_PORT=8012 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index fc6ce0e..ec99829 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,8 @@ - **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`. +- **Single sign-out** (правило владельца): выход из gnexus-auth обязан завершать сессию в Synapse. Прямой logout в gnexus-auth НЕ отзывает OAuth-токены (access до 30 мин, refresh 30 дней переживают logout) — поэтому callback сохраняет выданные токены в Redis по user_id (`store_login`), а вебхук `auth.logout`/`auth.global_logout`/`session.revoked` отзывает их все. SPA-poll `/api/v1/admin/me` каждые 45 c: 401 → уход на /login. Выход из Synapse (`POST /auth/logout`) тоже отзывает входы пользователя; завершение самой SSO-сессии (RP-initiated logout) — TODO в gnexus-auth, зафиксировано в #32. +- Локальная разработка: gnexus-auth = `http://gnexus-auth.local` (в контейнерах — `extra_hosts: host-gateway` в compose), API на `localhost:8012`. - Токен передаётся `Authorization: Bearer` — cookie не годятся (сессия gnexus-auth не выходит за пределы auth-сервера); SPA-клиент хранит access/refresh сам (docs 18–20 описывают клиентскую сторону). ## GNexus UI Kit diff --git a/README.md b/README.md index 46c20c3..7c4456d 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ ### Проверка без живого gnexus-auth ```bash -curl -i http://localhost:8001/auth/login # 302 на authorize endpoint с client_id/code_challenge/state +curl -i http://localhost:8012/auth/login # 302 на authorize endpoint с client_id/code_challenge/state ``` ## Структура diff --git a/app/api/auth_routes.py b/app/api/auth_routes.py index 7327540..bd08c8a 100644 --- a/app/api/auth_routes.py +++ b/app/api/auth_routes.py @@ -15,6 +15,7 @@ from app.auth.client import GAUTH_SCOPES, get_gauth_client from app.auth.deps import ADMIN_ROLES +from app.auth.stores import revoke_logins_for_user, store_login from app.config import Settings, get_settings logger = logging.getLogger("synapse.auth") @@ -72,6 +73,10 @@ pass return _spa_redirect(settings, "/denied", {}) + # Учёт входа: при выходе пользователя из gnexus-auth вебхук найдёт + # этот токен и отзовёт его (см. webhooks()). + store_login(user.user_id, state, tokens.access_token, tokens.refresh_token) + fragment = { "access_token": tokens.access_token, "expires_in": tokens.expires_in, @@ -102,23 +107,45 @@ return payload -@router.post("/revoke") -def revoke(request: Request) -> dict: - """Логаут: отзываем access-токен (гнексус-auth учитывает это мгновенно).""" +@router.post("/logout") +def full_logout(request: Request) -> dict: + """Выход из Synapse: revoke всех известных входов пользователя (access+refresh). + + SSO-сессия gnexus-auth (web-cookie) пока не завершается — RP-initiated + logout требует поддержки на стороне gnexus-auth; зафиксировано в #32. + После revoke любой API-запрос и heartbeat получат 401. + """ 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} + client = get_gauth_client() + token = auth.removeprefix("Bearer ") + user_id: str | None = None + try: + user = client.fetch_user(token) # токен ещё жив — узнаём, чей выход + user_id = str(user.user_id) + except Exception: + pass # токен уже мёртв — просто чистим локально по index'у + revoked = revoke_logins_for_user(user_id, client) if user_id else 0 + # текущий (не учтён в store_login, если вход был до этой функции) — добиваем явно + try: + client.revoke_token(token, "access_token") + except Exception: + pass + return {"revoked": True, "logins_killed": revoked} @router.post("/webhooks") async def webhooks(request: Request) -> dict: - """Приём вебхуков gnexus-auth (user.*, role.*, client.*, webhook.test). + """Приём вебхуков gnexus-auth (user.*, role.*, client.*, session.*, auth.*, webhook.test). Подпись HMAC (`t=...,v1=...`) проверяется через gnexus-gauth с секретом - из env; уведомления о смене пользователя/ролей нужны для мгновенной - инвалидации (TODO: кэш userinfo в задаче #30-#31). + из env. + + Ключевое правило single sign-out: gnexus-auth при выходе НЕ отзывает + OAuth-токены, поэтому выход из auth должен долететь до нас через + auth.logout / auth.global_logout / session.revoked — по user_id мы + отзываем все выданные этому пользователю access+refresh. """ secret = get_settings().gauth_webhook_secret if not secret: @@ -130,5 +157,19 @@ except Exception: raise HTTPException(status_code=400, detail="Некорректная подпись вебхука") from None - logger.info("gauth webhook: %s id=%s target=%s", event.event_type, event.event_id, event.target_identifiers) + if event.event_type in ("auth.logout", "auth.global_logout", "session.revoked"): + payload = event.raw_payload or {} + user = payload.get("user") or {} + target_user = user.get("id") or event.target_identifiers.get("user_id") + killed = revoke_logins_for_user(target_user, get_gauth_client()) if target_user else 0 + if target_user: + logger.info( + "synapse logout по вебхуку: user=%s type=%s revoked_logins=%d", + target_user, event.event_type, killed, + ) + else: + logger.warning("logout-событие %s без user.id — нечем отзвать токены", event.event_type) + else: + logger.info("gauth webhook: %s id=%s target=%s", event.event_type, event.event_id, event.target_identifiers) + return {"received": True, "event_type": event.event_type} \ No newline at end of file diff --git a/app/auth/stores.py b/app/auth/stores.py index 47dbb11..4c3d48a 100644 --- a/app/auth/stores.py +++ b/app/auth/stores.py @@ -55,4 +55,40 @@ 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 + get_redis().delete(PKCE_PREFIX + state) + + +# --- Учёт выданных токенов: чтобы webhook-выход мог их отозвать --- + +TOKENS_PREFIX = "synapse:auth:tokens:" # :user_id -> hash {login_id: {access, refresh}} +TOKENS_TTL = 60 * 60 * 24 * 30 # TTL = жизни refresh-токена (30 дней на стороне auth) + + +def store_login(user_id: str, login_id: str, access_token: str, refresh_token: str | None) -> None: + """Запоминаем выдачy токена при SSO-входе: на logout-webhook по user_id отдадим revoke.""" + key = TOKENS_PREFIX + str(user_id) + entry = {"access": access_token, "refresh": refresh_token or ""} + get_redis().hset(key, login_id, json.dumps(entry)) + get_redis().expire(key, TOKENS_TTL) + + +def revoke_logins_for_user(user_id: str, client) -> int: + """Все сохранённые входы пользователя: revoke access+refresh на gnexus-auth, очистка. + + Возвращает, сколько входов отзывает (0 — этот пользователь к нам не входил). + """ + key = TOKENS_PREFIX + str(user_id) + redis = get_redis() + stored = redis.hgetall(key) + if not stored: + return 0 + for login_id, raw in stored.items(): + entry = json.loads(raw) + for token, hint in ((entry.get("access"), "access_token"), (entry.get("refresh"), "refresh_token")): + if token: + try: + client.revoke_token(token, hint) + except Exception: # noqa: BLE001 — токен может быть уже отозван на стороне auth + pass + redis.hdel(key, login_id) + return len(stored) \ No newline at end of file diff --git a/app/config.py b/app/config.py index 18798c8..031fea7 100644 --- a/app/config.py +++ b/app/config.py @@ -22,7 +22,7 @@ gauth_client_id: str = "set-me" gauth_client_secret: str = "set-me" # Куда gnexus-auth возвращает пользователя — наш API-эндпоинт callback'а. - gauth_redirect_uri: str = "http://localhost:8001/auth/callback" + gauth_redirect_uri: str = "http://localhost:8012/auth/callback" # gnexus-auth в LAN может сидеть за self-signed TLS (по умолчанию не проверяем), # в проде с нормальным сертификатом выставить GAUTH_VERIFY_TLS=true. gauth_verify_tls: bool = False diff --git a/frontend/src/api.js b/frontend/src/api.js index 4423af2..19d90dc 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -109,10 +109,10 @@ return apiFetch("/api/v1/admin/me"); } -/** Логаут: revoke access-токена на gnexus-auth. */ +/** Логаут: revoke всех входов пользователя (наш + на gnexus-auth). */ export async function logout() { try { - await apiFetch("/auth/revoke", { method: "POST" }); + await apiFetch("/auth/logout", { method: "POST" }); } finally { clearToken(); } diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index a5360bb..8994130 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -1,8 +1,8 @@