diff --git a/.env.example b/.env.example index 0a17107..8175395 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,9 @@ GAUTH_CLIENT_SECRET=change-me GAUTH_REDIRECT_URI=https://tasks.gnexus.space/auth/callback +# Session cookie +SESSION_SECRET=change-me-session-secret + # Database DATABASE_URL=postgresql+psycopg://gntodo:gntodo@localhost:5432/gntodo diff --git a/backend/app/auth/routes.py b/backend/app/auth/routes.py new file mode 100644 index 0000000..ce23f9b --- /dev/null +++ b/backend/app/auth/routes.py @@ -0,0 +1,66 @@ +"""Маршруты OAuth-флоу через gnexus-gauth. + +Поток: /auth/login → redirect на auth.gnexus.space (PKCE) → /auth/callback → +обмен code на токены → сессия (подписанная cookie через SessionMiddleware) → +redirect на return_to. + +InMemory state/pkce store из gnexus-gauth достаточно для одного пользователя; +TODO(M0): заменить на постоянные хранилища до прод-деплоя (см. M0-заметки). +""" + +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import RedirectResponse + +from app.auth.client import get_gauth_client + +router = APIRouter(prefix="/auth", tags=["auth"]) + +SCOPES = ["openid", "email", "profile"] + + +@router.get("/login") +async def login(request: Request, return_to: str = "/") -> RedirectResponse: + client = get_gauth_client() + auth_request = client.build_authorization_request(return_to=return_to, scopes=SCOPES) + # return_to запоминаем в сессии и забираем в callback после обмена code. + request.session["return_to"] = return_to + return RedirectResponse(auth_request.authorization_url) + + +@router.get("/callback") +async def callback(request: Request, code: str, state: str) -> RedirectResponse: + client = get_gauth_client() + + try: + token_set = client.exchange_authorization_code(code, state) + except Exception as exc: # gnexus_gauth.exceptions.* — общий обработчик на M0 + raise HTTPException(status_code=400, detail=f"OAuth callback failed: {exc}") from exc + + user = client.fetch_user(token_set.access_token) + + request.session["access_token"] = token_set.access_token + request.session["refresh_token"] = token_set.refresh_token + request.session["user"] = { + "user_id": user.user_id, + "email": user.email, + "avatar_url": user.avatar_url, + } + + return_to = request.session.pop("return_to", "/") + return RedirectResponse(return_to) + + +@router.get("/logout") +async def logout(request: Request) -> dict[str, bool]: + request.session.clear() + return {"ok": True} + + +@router.get("/me") +async def me(request: Request) -> dict[str, Any]: + user = request.session.get("user") + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + return {"user": user} diff --git a/backend/app/config.py b/backend/app/config.py index 375e1a3..cd45ef3 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -17,6 +17,10 @@ # Database database_url: str = "postgresql+psycopg://gntodo:gntodo@localhost:5432/gntodo" + # Session cookie + session_secret: str = "change-me-session-secret" + session_cookie_name: str = "gntodo_session" + # Ollama (M2) ollama_base_url: str = "http://localhost:11434" ollama_model: str = "qwen2.5:3b" diff --git a/backend/app/main.py b/backend/app/main.py index 5a054a4..8f3db01 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,8 +1,12 @@ -"""Точка входа FastAPI. MVP: health + каркас авторизации (полный флоу — в M0).""" +"""Точка входа FastAPI: health + OAuth-флоу gnexus-gauth + защищённый /api.""" -from fastapi import FastAPI +from typing import Annotated, cast + +from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware +from starlette.middleware.sessions import SessionMiddleware +from app.auth.routes import router as auth_router from app.config import get_settings app = FastAPI(title="gntodo API", version="0.1.0") @@ -16,8 +20,27 @@ allow_methods=["*"], allow_headers=["*"], ) +# Сессия — подписанная cookie (HttpOnly); секрет из окружения. +app.add_middleware(SessionMiddleware, secret_key=settings.session_secret) + +app.include_router(auth_router) + + +def require_user(request: Request) -> dict[str, str]: + """Зависимость-защита API: пользователь должен быть залогинен.""" + user = request.session.get("user") + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + return cast(dict[str, str], user) @app.get("/api/health") async def health() -> dict[str, str]: return {"status": "ok"} + + +@app.get("/api/me") +async def api_me( + user: Annotated[dict[str, str], Depends(require_user)], +) -> dict[str, dict[str, str]]: + return {"user": user} diff --git a/backend/pyproject.toml b/backend/pyproject.toml index d997f85..ec32fa3 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -12,6 +12,7 @@ "alembic>=1.14", "httpx>=0.24", "gnexus-gauth", + "itsdangerous>=2.2.0", ] [dependency-groups] @@ -46,4 +47,4 @@ # gnexus-gauth (alpha) пока без py.typed — игнорируем до появления стабов [[tool.mypy.overrides]] module = "gnexus_gauth.*" -ignore_missing_imports = true \ No newline at end of file +ignore_missing_imports = true diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..449afdb --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,7 @@ +"""Тестовое окружение: фиктивные креды SSO и сессии до импорта приложения.""" + +import os + +os.environ.setdefault("GAUTH_CLIENT_ID", "test-client") +os.environ.setdefault("GAUTH_CLIENT_SECRET", "test-secret") +os.environ.setdefault("SESSION_SECRET", "test-session-secret") diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..deecdba --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,27 @@ +"""Smoke-тесты M0: health, защита API, redirect OAuth-флоу.""" + +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + + +def test_health() -> None: + assert client.get("/api/health").json() == {"status": "ok"} + + +def test_api_me_requires_auth() -> None: + assert client.get("/api/me").status_code == 401 + + +def test_auth_me_requires_auth() -> None: + assert client.get("/auth/me").status_code == 401 + + +def test_login_redirects_to_sso() -> None: + resp = client.get("/auth/login", follow_redirects=False) + assert resp.status_code in (302, 307) + location = resp.headers["location"] + assert "auth.gnexus.space" in location + assert "state=" in location and "code_challenge=" in location diff --git a/backend/uv.lock b/backend/uv.lock index 39219aa..ab38012 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -274,6 +274,7 @@ { name = "fastapi" }, { name = "gnexus-gauth" }, { name = "httpx" }, + { name = "itsdangerous" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic-settings" }, { name = "sqlalchemy" }, @@ -294,6 +295,7 @@ { name = "fastapi", specifier = ">=0.115" }, { name = "gnexus-gauth", git = "https://git.gnexus.space/git/root/gnexus-auth-client-py.git" }, { name = "httpx", specifier = ">=0.24" }, + { name = "itsdangerous", specifier = ">=2.2.0" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "pydantic-settings", specifier = ">=2.6" }, { name = "sqlalchemy", specifier = ">=2.0" }, @@ -455,6 +457,15 @@ ] [[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] name = "librt" version = "0.15.0" source = { registry = "https://pypi.org/simple" } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index e8ae396..3ae9db2 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,4 +1,21 @@