From 9f35ae8de15b02ec138725e896f39a66ce607a2c Mon Sep 17 00:00:00 2001 From: jze9 Date: Mon, 24 Aug 2026 17:59:10 +0500 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=D0=B2=D1=85=D0=BE=D0=B4=20=D1=87?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B7=20Google=20=D0=B8=20=D0=AF=D0=BD=D0=B4?= =?UTF-8?q?=D0=B5=D0=BA=D1=81=20(OAuth2=20authorization=20code=20flow)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализовано без authlib, на голом httpx (AsyncClient — синхронный httpx блокировал бы event loop API на время внешнего запроса), по образцу двух провайдеров: - Миграция 004: hashed_password → nullable (OAuth-юзеры без пароля), oauth_provider/oauth_id + уникальный индекс на пару. - app/core/security.py: verify_password защищён от hashed=None (иначе TypeError при попытке OAuth-юзера войти по паролю — нашёл при ревью, не баг-репорт). - app/core/oauth.py: get_authorize_url()/exchange_code() — единый интерфейс для google/yandex. Redirect URI: /api/auth//callback. - app/api/auth.py: GET /auth/{provider}/login (редирект на согласие, state в httponly-cookie от CSRF) и /callback (обмен code, find-or-create юзера по oauth_id → по email для привязки существующего аккаунта → новый без пароля, is_verified=email_verified от провайдера). Токен фронту — через URL-фрагмент #token=..., не query (не уходит в логи/Referer). - Фронтенд: OAuthButtons (Login/Register), страница /oauth/callback (читает фрагмент → GET /auth/me → setAuth → редирект в кабинет). - 6 юнит-тестов чистой логики сборки ссылок (app/core/oauth.py) — первый тест- контур для api/ в этой сессии (pytest.ini/conftest/requirements-test по образцу остальных сервисов), добавлен в общий run_tests.sh + mypy-гейт. GOOGLE_CLIENT_ID/SECRET уже в .env (юзер создал OAuth-клиент), YANDEX_* пусты — эндпоинты в этом случае отвечают 503, не падают. .env.example документирует обе пары. Тестов всего: 118 (было 112). Co-Authored-By: Claude Opus 4.8 --- .env.example | 9 ++ README.md | 7 +- scripts/run_lint.sh | 3 +- scripts/run_tests.sh | 1 + services/api/alembic/versions/004_oauth.py | 33 +++++ services/api/app/api/auth.py | 115 ++++++++++++++- services/api/app/config.py | 7 + services/api/app/core/oauth.py | 136 ++++++++++++++++++ services/api/app/core/security.py | 6 +- services/api/app/models/user.py | 7 +- services/api/conftest.py | 6 + services/api/pytest.ini | 3 + services/api/requirements-test.txt | 4 + services/api/tests/test_oauth.py | 75 ++++++++++ .../frontend/src/components/OAuthButtons.tsx | 27 ++++ services/frontend/src/main.tsx | 2 + services/frontend/src/pages/Login.tsx | 5 + services/frontend/src/pages/OAuthCallback.tsx | 45 ++++++ services/frontend/src/pages/Register.tsx | 5 + 19 files changed, 488 insertions(+), 8 deletions(-) create mode 100644 services/api/alembic/versions/004_oauth.py create mode 100644 services/api/app/core/oauth.py create mode 100644 services/api/conftest.py create mode 100644 services/api/pytest.ini create mode 100644 services/api/requirements-test.txt create mode 100644 services/api/tests/test_oauth.py create mode 100644 services/frontend/src/components/OAuthButtons.tsx create mode 100644 services/frontend/src/pages/OAuthCallback.tsx diff --git a/.env.example b/.env.example index 2ede58a..f0cdf2c 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,15 @@ OLLAMA_URL=http://ollama:11434 SECRET_KEY=change-me-in-production-use-openssl-rand-hex-32 ACCESS_TOKEN_EXPIRE_MINUTES=10080 +# OAuth — вход через Google/Яндекс (опционально; пусто = кнопка провайдера скрыта). +# Google: console.cloud.google.com → APIs & Services → Credentials → OAuth Client ID +# (Web application), redirect URI: https://<домен>/api/auth/google/callback +# Yandex: oauth.yandex.ru → создать приложение, redirect URI: .../api/auth/yandex/callback +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +YANDEX_CLIENT_ID= +YANDEX_CLIENT_SECRET= + # SMTP (собственный Postfix+Dovecot, mail.jze9mail.ru, STARTTLS) SMTP_HOST=mail.jze9mail.ru SMTP_PORT=587 diff --git a/README.md b/README.md index 415f3a4..15d2edb 100644 --- a/README.md +++ b/README.md @@ -208,9 +208,9 @@ docker compose -f docker-compose.prod.yml --profile observability up -d promethe 1. **Линт** — `ruff` (весь Python) + `mypy` (чистая доменная логика). Конфиги: [`ruff.toml`](ruff.toml), [`mypy.ini`](mypy.ini). -2. **Юнит-тесты** — `pytest` по сервисам: 112 тестов на ядро детекции, скоринга, - парсеров и форматирования, без внешней инфры (БД/Redis/GPU/Ollama замоканы - либо не нужны). +2. **Юнит-тесты** — `pytest` по сервисам: 118 тестов на ядро детекции, скоринга, + парсеров, форматирования и OAuth, без внешней инфры (БД/Redis/GPU/Ollama + замоканы либо не нужны). ```bash make lint # ruff + mypy в изолированном контейнере @@ -238,6 +238,7 @@ make test-one SVC=worker-gost # тесты одного сервиса | ГОСТ 7.1 / 7.0.5 | `worker-gost/app/formatters/` | 17 | | Список литературы | `worker-gost/app/bibliography.py` | 7 | | Парсеры источников (CyberLeninka, PMC) | `scripts/parsers/` | 14 | +| OAuth-ссылки (Google/Яндекс) | `api/app/core/oauth.py` | 6 | ## Лицензия diff --git a/scripts/run_lint.sh b/scripts/run_lint.sh index 56af604..8d64b28 100755 --- a/scripts/run_lint.sh +++ b/scripts/run_lint.sh @@ -24,9 +24,10 @@ docker run --rm \ echo '▶ ruff (services/ scripts/)' ruff check services/ scripts/ - echo '▶ mypy (чистая логика L1/L2 + ГОСТ + скоринг)' + echo '▶ mypy (чистая логика L1/L2 + ГОСТ + скоринг + OAuth)' ( cd services/worker-indexer && mypy --config-file /repo/mypy.ini app/algorithms/ app/fragments.py app/staging.py ) ( cd services/worker-gost && mypy --config-file /repo/mypy.ini app/formatters/ app/bibliography.py ) ( cd services/worker-gpu && mypy --config-file /repo/mypy.ini app/scoring.py ) + ( cd services/api && mypy --config-file /repo/mypy.ini app/core/oauth.py ) " echo "✅ Линт (ruff + mypy) пройден" diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index b9e88db..006d995 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -19,6 +19,7 @@ IMAGE="python:3.11-slim" # относительный-путь:apt-пакеты (нужны faiss-cpu → libgomp1) SERVICES=( + "services/api:" "services/worker-indexer:" "services/worker-gost:" "services/worker-gpu:libgomp1" diff --git a/services/api/alembic/versions/004_oauth.py b/services/api/alembic/versions/004_oauth.py new file mode 100644 index 0000000..5ec0634 --- /dev/null +++ b/services/api/alembic/versions/004_oauth.py @@ -0,0 +1,33 @@ +"""OAuth-вход (Google/Яндекс): oauth_provider/oauth_id, hashed_password nullable. + +Revision ID: 004 +Revises: 003 +Create Date: 2026-08-24 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "004" +down_revision = "003" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # OAuth-пользователи не заводят пароль + op.alter_column("users", "hashed_password", nullable=True) + + op.add_column("users", sa.Column("oauth_provider", sa.String(20), nullable=True)) + op.add_column("users", sa.Column("oauth_id", sa.String(255), nullable=True)) + op.create_index( + "ix_users_oauth_provider_id", "users", ["oauth_provider", "oauth_id"], unique=True + ) + + +def downgrade() -> None: + op.drop_index("ix_users_oauth_provider_id", table_name="users") + op.drop_column("users", "oauth_id") + op.drop_column("users", "oauth_provider") + op.alter_column("users", "hashed_password", nullable=False) diff --git a/services/api/app/api/auth.py b/services/api/app/api/auth.py index e4ada17..fc464db 100644 --- a/services/api/app/api/auth.py +++ b/services/api/app/api/auth.py @@ -1,13 +1,16 @@ -"""Роутер аутентификации: регистрация, вход, верификация email.""" +"""Роутер аутентификации: регистрация, вход, верификация email, OAuth.""" import logging import secrets -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi.responses import RedirectResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.config import settings from app.core.celery_app import celery_app +from app.core.oauth import OAuthNotConfigured, OAuthUserInfo, exchange_code, get_authorize_url from app.core.security import ( create_access_token, get_current_user, @@ -30,6 +33,8 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/auth", tags=["auth"]) +_OAUTH_PROVIDERS = {"google", "yandex"} + @router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED) async def register(data: RegisterRequest, db: AsyncSession = Depends(get_db)) -> TokenResponse: @@ -247,3 +252,109 @@ async def verify_email(token: str, db: AsyncSession = Depends(get_db)) -> dict: await invalidate_user_cache(user.id) return {"message": "Email успешно подтверждён"} + + +async def _get_or_create_oauth_user( + db: AsyncSession, provider: str, info: OAuthUserInfo +) -> User: + """Найти пользователя по (provider, oauth_id), иначе по email (привязка + существующего аккаунта), иначе создать нового без пароля.""" + result = await db.execute( + select(User).where(User.oauth_provider == provider, User.oauth_id == info.provider_id) + ) + user = result.scalar_one_or_none() + if user: + return user + + email = info.email.lower() + result = await db.execute(select(User).where(User.email == email)) + user = result.scalar_one_or_none() + if user: + if not user.oauth_provider: + user.oauth_provider = provider + user.oauth_id = info.provider_id + if info.email_verified: + user.is_verified = True + await db.commit() + await db.refresh(user) + return user + + user = User( + email=email, + hashed_password=None, + name=info.name, + is_verified=info.email_verified, + plan="free", + oauth_provider=provider, + oauth_id=info.provider_id, + ) + db.add(user) + await db.commit() + await db.refresh(user) + return user + + +@router.get("/{provider}/login", include_in_schema=False) +async def oauth_login(provider: str) -> RedirectResponse: + """Редирект на экран согласия Google/Яндекс.""" + if provider not in _OAUTH_PROVIDERS: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Неизвестный провайдер") + + state = secrets.token_urlsafe(24) + try: + url = get_authorize_url(provider, state) + except OAuthNotConfigured as e: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Вход через {provider} временно недоступен", + ) from e + + response = RedirectResponse(url) + response.set_cookie( + f"oauth_state_{provider}", state, + httponly=True, secure=True, samesite="lax", max_age=600, + ) + return response + + +@router.get("/{provider}/callback", include_in_schema=False) +async def oauth_callback( + provider: str, + request: Request, + code: str | None = None, + state: str | None = None, + db: AsyncSession = Depends(get_db), +) -> RedirectResponse: + """Колбэк провайдера: обменять code на профиль, найти/создать юзера, выдать JWT. + + Токен передаётся фронту через URL-фрагмент (#token=...) — он не уходит на + сервер при последующих запросах и не попадает в логи/Referer, в отличие от + query-параметра. Фронт (страница /oauth/callback) читает его и вызывает + setAuth, как после обычного /login. + """ + if provider not in _OAUTH_PROVIDERS: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Неизвестный провайдер") + + expected_state = request.cookies.get(f"oauth_state_{provider}") + if not code or not state or not expected_state or state != expected_state: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Невалидный OAuth-колбэк") + + try: + info = await exchange_code(provider, code) + except OAuthNotConfigured as e: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Вход через {provider} временно недоступен", + ) from e + except Exception as e: + logger.warning(f"OAuth {provider}: обмен кода не удался: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Не удалось войти через провайдера" + ) from e + + user = await _get_or_create_oauth_user(db, provider, info) + access_token = create_access_token({"sub": str(user.id)}) + + response = RedirectResponse(f"{settings.APP_URL}/oauth/callback#token={access_token}") + response.delete_cookie(f"oauth_state_{provider}") + return response diff --git a/services/api/app/config.py b/services/api/app/config.py index 0cd313f..884504b 100644 --- a/services/api/app/config.py +++ b/services/api/app/config.py @@ -44,6 +44,13 @@ class Settings(BaseSettings): ALGORITHM: str = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = 10080 # 7 дней + # OAuth — вход через Google/Яндекс. Пусто = провайдер выключен (эндпоинты + # отвечают 503, кнопка на фронте не показывается). + GOOGLE_CLIENT_ID: str = "" + GOOGLE_CLIENT_SECRET: str = "" + YANDEX_CLIENT_ID: str = "" + YANDEX_CLIENT_SECRET: str = "" + # SMTP SMTP_HOST: str = "mail.jze9mail.ru" SMTP_PORT: int = 587 diff --git a/services/api/app/core/oauth.py b/services/api/app/core/oauth.py new file mode 100644 index 0000000..bfd5c92 --- /dev/null +++ b/services/api/app/core/oauth.py @@ -0,0 +1,136 @@ +"""OAuth2 authorization-code flow для Google и Яндекс — без authlib, только httpx. + +Единый интерфейс для обоих провайдеров: get_authorize_url() формирует ссылку на +экран согласия, exchange_code() меняет code на профиль пользователя. Асинхронно +(httpx.AsyncClient) — синхронные вызовы блокировали бы event loop всего API на +время внешнего запроса к Google/Яндекс. +""" + +from dataclasses import dataclass +from urllib.parse import urlencode + +import httpx + +from app.config import settings + +_TIMEOUT = 10.0 + + +class OAuthNotConfigured(Exception): + """Провайдер не настроен (пустой client_id/secret в конфиге).""" + + +@dataclass +class OAuthUserInfo: + provider_id: str + email: str + name: str + email_verified: bool + + +def _redirect_uri(provider: str) -> str: + return f"{settings.APP_URL}/api/auth/{provider}/callback" + + +def _credentials(provider: str) -> tuple[str, str]: + if provider == "google": + client_id, secret = settings.GOOGLE_CLIENT_ID, settings.GOOGLE_CLIENT_SECRET + elif provider == "yandex": + client_id, secret = settings.YANDEX_CLIENT_ID, settings.YANDEX_CLIENT_SECRET + else: + raise ValueError(f"неизвестный OAuth-провайдер: {provider!r}") + if not client_id or not secret: + raise OAuthNotConfigured(provider) + return client_id, secret + + +def get_authorize_url(provider: str, state: str) -> str: + """Собрать ссылку на экран согласия провайдера.""" + client_id, _ = _credentials(provider) + redirect_uri = _redirect_uri(provider) + + if provider == "google": + params = { + "client_id": client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": "openid email profile", + "state": state, + "prompt": "select_account", + } + return f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(params)}" + + params = { + "response_type": "code", + "client_id": client_id, + "redirect_uri": redirect_uri, + "state": state, + } + return f"https://oauth.yandex.ru/authorize?{urlencode(params)}" + + +async def exchange_code(provider: str, code: str) -> OAuthUserInfo: + """Обменять authorization code на профиль пользователя у провайдера.""" + client_id, client_secret = _credentials(provider) + redirect_uri = _redirect_uri(provider) + + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + if provider == "google": + token_resp = await client.post( + "https://oauth2.googleapis.com/token", + data={ + "code": code, + "client_id": client_id, + "client_secret": client_secret, + "redirect_uri": redirect_uri, + "grant_type": "authorization_code", + }, + ) + token_resp.raise_for_status() + access_token = token_resp.json()["access_token"] + + info_resp = await client.get( + "https://www.googleapis.com/oauth2/v3/userinfo", + headers={"Authorization": f"Bearer {access_token}"}, + ) + info_resp.raise_for_status() + info = info_resp.json() + email = info["email"] + return OAuthUserInfo( + provider_id=info["sub"], + email=email, + name=info.get("name") or email.split("@")[0], + email_verified=bool(info.get("email_verified", False)), + ) + + # yandex + token_resp = await client.post( + "https://oauth.yandex.ru/token", + data={ + "grant_type": "authorization_code", + "code": code, + "client_id": client_id, + "client_secret": client_secret, + }, + ) + token_resp.raise_for_status() + access_token = token_resp.json()["access_token"] + + info_resp = await client.get( + "https://login.yandex.ru/info", + params={"format": "json"}, + headers={"Authorization": f"OAuth {access_token}"}, + ) + info_resp.raise_for_status() + info = info_resp.json() + + email = info.get("default_email") or next(iter(info.get("emails") or []), None) + if not email: + raise ValueError("Яндекс не вернул email — нужно разрешение на доступ к почте") + + return OAuthUserInfo( + provider_id=str(info["id"]), + email=email, + name=info.get("real_name") or info.get("display_name") or email.split("@")[0], + email_verified=True, # Яндекс отдаёт только подтверждённые адреса + ) diff --git a/services/api/app/core/security.py b/services/api/app/core/security.py index bf836ef..e063c42 100644 --- a/services/api/app/core/security.py +++ b/services/api/app/core/security.py @@ -29,7 +29,11 @@ def hash_password(password: str) -> str: return pwd_context.hash(password) -def verify_password(plain: str, hashed: str) -> bool: +def verify_password(plain: str, hashed: str | None) -> bool: + # OAuth-пользователи не имеют пароля (hashed_password=None) — попытка + # войти паролем должна отвечать "неверный пароль", а не падать 500-й. + if hashed is None: + return False return pwd_context.verify(plain, hashed) diff --git a/services/api/app/models/user.py b/services/api/app/models/user.py index 2628469..8a126af 100644 --- a/services/api/app/models/user.py +++ b/services/api/app/models/user.py @@ -20,8 +20,13 @@ class User(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) - hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) + # Nullable: пользователи, вошедшие через OAuth, пароля не заводят + hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True) name: Mapped[str] = mapped_column(String(255), nullable=False) + # OAuth-провайдер ("google" / "yandex") и id пользователя у провайдера. + # Пара уникальна (см. миграцию 004) — один аккаунт провайдера не привязать дважды. + oauth_provider: Mapped[str | None] = mapped_column(String(20), nullable=True) + oauth_id: Mapped[str | None] = mapped_column(String(255), nullable=True) is_verified: Mapped[bool] = mapped_column(default=False) # Администратор системы (доступ к админ-панели) is_admin: Mapped[bool] = mapped_column(default=False) diff --git a/services/api/conftest.py b/services/api/conftest.py new file mode 100644 index 0000000..ac185d2 --- /dev/null +++ b/services/api/conftest.py @@ -0,0 +1,6 @@ +"""Добавляет корень сервиса в sys.path, чтобы тесты импортировали пакет `app`.""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) diff --git a/services/api/pytest.ini b/services/api/pytest.ini new file mode 100644 index 0000000..eeb9d41 --- /dev/null +++ b/services/api/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +addopts = -q diff --git a/services/api/requirements-test.txt b/services/api/requirements-test.txt new file mode 100644 index 0000000..3db8def --- /dev/null +++ b/services/api/requirements-test.txt @@ -0,0 +1,4 @@ +# Зависимости для юнит-тестов api (чистая логика OAuth URL-билдера, без сети/БД). +pytest==8.2.0 +pydantic-settings==2.2.1 +httpx==0.27.0 diff --git a/services/api/tests/test_oauth.py b/services/api/tests/test_oauth.py new file mode 100644 index 0000000..7967b5b --- /dev/null +++ b/services/api/tests/test_oauth.py @@ -0,0 +1,75 @@ +"""Юнит-тесты сборки OAuth-ссылок (app.core.oauth) — чистая логика, без сети.""" + +from urllib.parse import parse_qs, urlparse + +import pytest +from app.config import settings +from app.core.oauth import OAuthNotConfigured, _redirect_uri, get_authorize_url + + +@pytest.fixture(autouse=True) +def _clear_oauth_settings(monkeypatch): + """По умолчанию оба провайдера не настроены — как на чистой инсталляции.""" + monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "") + monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "") + monkeypatch.setattr(settings, "YANDEX_CLIENT_ID", "") + monkeypatch.setattr(settings, "YANDEX_CLIENT_SECRET", "") + + +def test_redirect_uri_matches_registered_pattern(): + assert _redirect_uri("google") == f"{settings.APP_URL}/api/auth/google/callback" + assert _redirect_uri("yandex") == f"{settings.APP_URL}/api/auth/yandex/callback" + + +def test_not_configured_raises(monkeypatch): + with pytest.raises(OAuthNotConfigured): + get_authorize_url("google", "state123") + with pytest.raises(OAuthNotConfigured): + get_authorize_url("yandex", "state123") + + +def test_unknown_provider_raises_value_error(monkeypatch): + monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "id") + monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "secret") + with pytest.raises(ValueError): + get_authorize_url("facebook", "state123") + + +def test_google_authorize_url_structure(monkeypatch): + monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "my-client-id") + monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "my-secret") + + url = get_authorize_url("google", "the-state") + parsed = urlparse(url) + qs = parse_qs(parsed.query) + + assert parsed.netloc == "accounts.google.com" + assert qs["client_id"] == ["my-client-id"] + assert qs["redirect_uri"] == [f"{settings.APP_URL}/api/auth/google/callback"] + assert qs["response_type"] == ["code"] + assert qs["state"] == ["the-state"] + assert "email" in qs["scope"][0] + + +def test_yandex_authorize_url_structure(monkeypatch): + monkeypatch.setattr(settings, "YANDEX_CLIENT_ID", "my-yandex-id") + monkeypatch.setattr(settings, "YANDEX_CLIENT_SECRET", "my-yandex-secret") + + url = get_authorize_url("yandex", "the-state") + parsed = urlparse(url) + qs = parse_qs(parsed.query) + + assert parsed.netloc == "oauth.yandex.ru" + assert qs["client_id"] == ["my-yandex-id"] + assert qs["redirect_uri"] == [f"{settings.APP_URL}/api/auth/yandex/callback"] + assert qs["state"] == ["the-state"] + + +def test_state_is_url_encoded_safely(monkeypatch): + monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "id") + monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "secret") + + # state со спецсимволами не должен ломать ссылку + url = get_authorize_url("google", "abc&def=1") + qs = parse_qs(urlparse(url).query) + assert qs["state"] == ["abc&def=1"] diff --git a/services/frontend/src/components/OAuthButtons.tsx b/services/frontend/src/components/OAuthButtons.tsx new file mode 100644 index 0000000..c7702d6 --- /dev/null +++ b/services/frontend/src/components/OAuthButtons.tsx @@ -0,0 +1,27 @@ +// Кнопки входа через Google/Яндекс — обычные , не React Router Link: +// это реальная навигация браузера на бэкенд (/api/auth/{provider}/login), +// который редиректит на экран согласия провайдера, а не SPA-переход. +export function OAuthButtons() { + return ( +
+
+ + ); +} diff --git a/services/frontend/src/main.tsx b/services/frontend/src/main.tsx index 16b7134..85fa21f 100644 --- a/services/frontend/src/main.tsx +++ b/services/frontend/src/main.tsx @@ -15,6 +15,7 @@ import { Task } from './pages/Task'; import { Pricing } from './pages/Pricing'; import { Login } from './pages/Login'; import { Register } from './pages/Register'; +import { OAuthCallback } from './pages/OAuthCallback'; import { VerifyEmail } from './pages/VerifyEmail'; import { AdminLayout } from './pages/admin/AdminLayout'; import { Dashboard } from './pages/admin/Dashboard'; @@ -51,6 +52,7 @@ function PublicApp() { } /> } /> } /> + } /> } /> diff --git a/services/frontend/src/pages/Login.tsx b/services/frontend/src/pages/Login.tsx index db6be22..e327099 100644 --- a/services/frontend/src/pages/Login.tsx +++ b/services/frontend/src/pages/Login.tsx @@ -4,6 +4,7 @@ import { useMutation } from '@tanstack/react-query'; import { GraduationCap } from 'lucide-react'; import toast from 'react-hot-toast'; import { authApi } from '../api/client'; +import { OAuthButtons } from '../components/OAuthButtons'; import { useAuthStore } from '../store/auth'; import type { TokenResponse } from '../types'; @@ -78,6 +79,10 @@ export function Login() { +
+ +
+

Нет аккаунта?{' '} diff --git a/services/frontend/src/pages/OAuthCallback.tsx b/services/frontend/src/pages/OAuthCallback.tsx new file mode 100644 index 0000000..35eb25c --- /dev/null +++ b/services/frontend/src/pages/OAuthCallback.tsx @@ -0,0 +1,45 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import toast from 'react-hot-toast'; +import { api } from '../api/client'; +import { useAuthStore } from '../store/auth'; +import type { User } from '../types'; + +// Токен приходит в URL-фрагменте (#token=...) после редиректа с бэкенда +// (см. app/api/auth.py::oauth_callback) — фрагмент не уходит на сервер и не +// попадает в логи/Referer, в отличие от query-параметра. +export function OAuthCallback() { + const navigate = useNavigate(); + const { setAuth } = useAuthStore(); + + useEffect(() => { + const params = new URLSearchParams(window.location.hash.slice(1)); + const token = params.get('token'); + + if (!token) { + toast.error('Не удалось войти — токен не получен'); + navigate('/login'); + return; + } + + // Токен ещё не в сторе — передаём явным заголовком в обход interceptor'а + api + .get('/auth/me', { headers: { Authorization: `Bearer ${token}` } }) + .then((response) => { + const user: User = response.data; + setAuth(user, token); + toast.success(`Добро пожаловать, ${user.name}!`); + navigate('/cabinet'); + }) + .catch(() => { + toast.error('Не удалось войти — попробуйте ещё раз'); + navigate('/login'); + }); + }, [navigate, setAuth]); + + return ( +

+ Выполняется вход… +
+ ); +} diff --git a/services/frontend/src/pages/Register.tsx b/services/frontend/src/pages/Register.tsx index 8d9bf37..1286844 100644 --- a/services/frontend/src/pages/Register.tsx +++ b/services/frontend/src/pages/Register.tsx @@ -4,6 +4,7 @@ import { useMutation } from '@tanstack/react-query'; import { GraduationCap } from 'lucide-react'; import toast from 'react-hot-toast'; import { authApi } from '../api/client'; +import { OAuthButtons } from '../components/OAuthButtons'; import { useAuthStore } from '../store/auth'; import type { TokenResponse } from '../types'; @@ -92,6 +93,10 @@ export function Register() { +
+ +
+

Уже есть аккаунт?{' '}