diff --git a/backend/app/auth/routes.py b/backend/app/auth/routes.py index b0de6c1..3987db3 100644 --- a/backend/app/auth/routes.py +++ b/backend/app/auth/routes.py @@ -15,7 +15,7 @@ from app.auth.client import get_gauth_client from app.config import get_settings -from app.dependencies import DbDep +from app.dependencies import DbDep, user_from_bearer from app.models import User from app.services.users import claim_global_settings, claim_orphan_data, upsert_user @@ -93,6 +93,10 @@ async def me(request: Request, db: DbDep) -> dict[str, Any]: session_user = request.session.get("user") if not session_user: + # Fallback расширения/агентов: Bearer MCP-токен → минимальный + # {user_id}; профиль ниже дозаполняется из БД. + session_user = user_from_bearer(request, db) + if not session_user: raise HTTPException(status_code=401, detail="Not authenticated") # Профиль берём из БД, а не из cookie-сессии: сессия stateless, и изменения # от webhook SSO (webhooks.py) иначе видны только после следующего логина. diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index dc450d1..a7bc315 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -1,18 +1,39 @@ """Общие зависимости FastAPI: авторизация и сессия БД.""" +import hashlib from typing import Annotated, cast from fastapi import Depends, HTTPException, Request +from sqlalchemy import select from sqlalchemy.orm import Session from app.db import get_db +from app.models import McpToken DbDep = Annotated[Session, Depends(get_db)] -def require_user(request: Request) -> dict[str, str]: - """Защита API: пользователь должен быть залогинен (сессия SSO).""" - user = request.session.get("user") +def user_from_bearer(request: Request, db: Session) -> dict[str, str] | None: + """Fallback-аутентификация API-клиентов без сессии (расширение, агенты): + заголовок `Authorization: Bearer ` → user_id владельца. + + Токен в БД хранится только как sha256-хэш (как в McpAuthMiddleware для /mcp). + """ + auth = request.headers.get("authorization", "") + token = auth.removeprefix("Bearer ").strip() + if not token: + return None + token_hash = hashlib.sha256(token.encode()).hexdigest() + user_id = db.scalar(select(McpToken.user_id).where(McpToken.token_hash == token_hash)) + if not user_id: + return None + # Минимальный пользователь: API/сервисы читают только user_id (ТЗ 1.2). + return {"user_id": user_id} + + +def require_user(request: Request, db: DbDep) -> dict[str, str]: + """Защита API: сессия SSO или Bearer MCP-токен (расширение, ИИ-агенты).""" + user = request.session.get("user") or user_from_bearer(request, db) if not user: raise HTTPException(status_code=401, detail="Not authenticated") return cast(dict[str, str], user) @@ -24,7 +45,7 @@ def require_user_id(user: UserDep) -> str: """user_id текущего пользователя — ключ изоляции всех данных (ТЗ 1.2). - Один слой чтения сессии: require_user уже проверил 401. + Один слой чтения: require_user уже проверил 401. """ return str(user["user_id"]) diff --git a/backend/tests/test_bearer_auth.py b/backend/tests/test_bearer_auth.py new file mode 100644 index 0000000..ea9ad8d --- /dev/null +++ b/backend/tests/test_bearer_auth.py @@ -0,0 +1,78 @@ +"""Bearer MCP-токен на REST /api/*: аутентификация расширения и агентов. + +Сессии SSO из браузера не видны запросам из extension-контекста (SameSite=Lax +режет cross-site куки), поэтому расширение ходит с Bearer MCP-токеном +(Настройки → «Токены MCP») — тем же, что у ИИ-агентов на /mcp. +""" + +from fastapi.testclient import TestClient + +from app.dependencies import require_user + + +def _create_token(client: TestClient, label: str = "расширение") -> str: + res = client.post("/api/mcp-tokens", json={"label": label}) + assert res.status_code == 200, res.text + return res.json()["token"] + + +def test_bearer_grants_api_access(client: TestClient): + token = _create_token(client) + # Оверрайд require_user снят: настоящая зависимость читает Bearer. + saved = client.app.dependency_overrides.pop(require_user, None) # type: ignore[attr-defined] + try: + res = client.get("/api/tasks", headers={"Authorization": f"Bearer {token}"}) + assert res.status_code == 200, res.text + res = client.get("/api/projects", headers={"Authorization": f"Bearer {token}"}) + assert res.status_code == 200 + finally: + if saved is not None: + client.app.dependency_overrides[require_user] = saved + + +def test_bearer_wrong_token_401(client: TestClient) -> None: + saved = client.app.dependency_overrides.pop(require_user, None) # type: ignore[attr-defined] + try: + res = client.get("/api/tasks", headers={"Authorization": "Bearer gnt_nope"}) + assert res.status_code == 401 + finally: + if saved is not None: + client.app.dependency_overrides[require_user] = saved + + +def test_bearer_revoked_token_401(client: TestClient) -> None: + res = client.post("/api/mcp-tokens", json={"label": "отзываем"}) + token, token_id = res.json()["token"], res.json()["id"] + assert client.delete(f"/api/mcp-tokens/{token_id}").status_code == 200 + saved = client.app.dependency_overrides.pop(require_user, None) # type: ignore[attr-defined] + try: + res = client.get("/api/tasks", headers={"Authorization": f"Bearer {token}"}) + assert res.status_code == 401 + finally: + if saved is not None: + client.app.dependency_overrides[require_user] = saved + + +def test_bearer_auth_me_returns_profile(client: TestClient) -> None: + token = _create_token(client) + saved = client.app.dependency_overrides.pop(require_user, None) # type: ignore[attr-defined] + try: + res = client.get("/auth/me", headers={"Authorization": f"Bearer {token}"}) + assert res.status_code == 200, res.text + assert res.json()["user"]["user_id"] == "1" + finally: + if saved is not None: + client.app.dependency_overrides[require_user] = saved + + +def test_bearer_scoped_to_owner(client: TestClient) -> None: + """Задача сессии видна через bearer того же пользователя (скоуп по user_id).""" + token = _create_token(client) + created = client.post("/api/tasks", json={"title": "скоуп"}).json() + saved = client.app.dependency_overrides.pop(require_user, None) # type: ignore[attr-defined] + try: + res = client.get("/api/tasks", headers={"Authorization": f"Bearer {token}"}) + assert [t["id"] for t in res.json()] == [created["id"]] + finally: + if saved is not None: + client.app.dependency_overrides[require_user] = saved