feat(auth): вход через Google и Яндекс (OAuth2 authorization code flow)
Реализовано без 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: <APP_URL>/api/auth/<provider>/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 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,15 @@ OLLAMA_URL=http://ollama:11434
|
|||||||
SECRET_KEY=change-me-in-production-use-openssl-rand-hex-32
|
SECRET_KEY=change-me-in-production-use-openssl-rand-hex-32
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=10080
|
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 (собственный Postfix+Dovecot, mail.jze9mail.ru, STARTTLS)
|
||||||
SMTP_HOST=mail.jze9mail.ru
|
SMTP_HOST=mail.jze9mail.ru
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
|
|||||||
@@ -208,9 +208,9 @@ docker compose -f docker-compose.prod.yml --profile observability up -d promethe
|
|||||||
|
|
||||||
1. **Линт** — `ruff` (весь Python) + `mypy` (чистая доменная логика).
|
1. **Линт** — `ruff` (весь Python) + `mypy` (чистая доменная логика).
|
||||||
Конфиги: [`ruff.toml`](ruff.toml), [`mypy.ini`](mypy.ini).
|
Конфиги: [`ruff.toml`](ruff.toml), [`mypy.ini`](mypy.ini).
|
||||||
2. **Юнит-тесты** — `pytest` по сервисам: 112 тестов на ядро детекции, скоринга,
|
2. **Юнит-тесты** — `pytest` по сервисам: 118 тестов на ядро детекции, скоринга,
|
||||||
парсеров и форматирования, без внешней инфры (БД/Redis/GPU/Ollama замоканы
|
парсеров, форматирования и OAuth, без внешней инфры (БД/Redis/GPU/Ollama
|
||||||
либо не нужны).
|
замоканы либо не нужны).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make lint # ruff + mypy в изолированном контейнере
|
make lint # ruff + mypy в изолированном контейнере
|
||||||
@@ -238,6 +238,7 @@ make test-one SVC=worker-gost # тесты одного сервиса
|
|||||||
| ГОСТ 7.1 / 7.0.5 | `worker-gost/app/formatters/` | 17 |
|
| ГОСТ 7.1 / 7.0.5 | `worker-gost/app/formatters/` | 17 |
|
||||||
| Список литературы | `worker-gost/app/bibliography.py` | 7 |
|
| Список литературы | `worker-gost/app/bibliography.py` | 7 |
|
||||||
| Парсеры источников (CyberLeninka, PMC) | `scripts/parsers/` | 14 |
|
| Парсеры источников (CyberLeninka, PMC) | `scripts/parsers/` | 14 |
|
||||||
|
| OAuth-ссылки (Google/Яндекс) | `api/app/core/oauth.py` | 6 |
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
|
|||||||
@@ -24,9 +24,10 @@ docker run --rm \
|
|||||||
echo '▶ ruff (services/ scripts/)'
|
echo '▶ ruff (services/ scripts/)'
|
||||||
ruff check 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-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-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/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) пройден"
|
echo "✅ Линт (ruff + mypy) пройден"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ IMAGE="python:3.11-slim"
|
|||||||
|
|
||||||
# относительный-путь:apt-пакеты (нужны faiss-cpu → libgomp1)
|
# относительный-путь:apt-пакеты (нужны faiss-cpu → libgomp1)
|
||||||
SERVICES=(
|
SERVICES=(
|
||||||
|
"services/api:"
|
||||||
"services/worker-indexer:"
|
"services/worker-indexer:"
|
||||||
"services/worker-gost:"
|
"services/worker-gost:"
|
||||||
"services/worker-gpu:libgomp1"
|
"services/worker-gpu:libgomp1"
|
||||||
|
|||||||
33
services/api/alembic/versions/004_oauth.py
Normal file
33
services/api/alembic/versions/004_oauth.py
Normal file
@@ -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)
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
"""Роутер аутентификации: регистрация, вход, верификация email."""
|
"""Роутер аутентификации: регистрация, вход, верификация email, OAuth."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
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 import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
from app.core.celery_app import celery_app
|
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 (
|
from app.core.security import (
|
||||||
create_access_token,
|
create_access_token,
|
||||||
get_current_user,
|
get_current_user,
|
||||||
@@ -30,6 +33,8 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
_OAUTH_PROVIDERS = {"google", "yandex"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def register(data: RegisterRequest, db: AsyncSession = Depends(get_db)) -> TokenResponse:
|
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)
|
await invalidate_user_cache(user.id)
|
||||||
|
|
||||||
return {"message": "Email успешно подтверждён"}
|
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
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ class Settings(BaseSettings):
|
|||||||
ALGORITHM: str = "HS256"
|
ALGORITHM: str = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 10080 # 7 дней
|
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
|
||||||
SMTP_HOST: str = "mail.jze9mail.ru"
|
SMTP_HOST: str = "mail.jze9mail.ru"
|
||||||
SMTP_PORT: int = 587
|
SMTP_PORT: int = 587
|
||||||
|
|||||||
136
services/api/app/core/oauth.py
Normal file
136
services/api/app/core/oauth.py
Normal file
@@ -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, # Яндекс отдаёт только подтверждённые адреса
|
||||||
|
)
|
||||||
@@ -29,7 +29,11 @@ def hash_password(password: str) -> str:
|
|||||||
return pwd_context.hash(password)
|
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)
|
return pwd_context.verify(plain, hashed)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,13 @@ class User(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
|
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)
|
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_verified: Mapped[bool] = mapped_column(default=False)
|
||||||
# Администратор системы (доступ к админ-панели)
|
# Администратор системы (доступ к админ-панели)
|
||||||
is_admin: Mapped[bool] = mapped_column(default=False)
|
is_admin: Mapped[bool] = mapped_column(default=False)
|
||||||
|
|||||||
6
services/api/conftest.py
Normal file
6
services/api/conftest.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Добавляет корень сервиса в sys.path, чтобы тесты импортировали пакет `app`."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
3
services/api/pytest.ini
Normal file
3
services/api/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
addopts = -q
|
||||||
4
services/api/requirements-test.txt
Normal file
4
services/api/requirements-test.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Зависимости для юнит-тестов api (чистая логика OAuth URL-билдера, без сети/БД).
|
||||||
|
pytest==8.2.0
|
||||||
|
pydantic-settings==2.2.1
|
||||||
|
httpx==0.27.0
|
||||||
75
services/api/tests/test_oauth.py
Normal file
75
services/api/tests/test_oauth.py
Normal file
@@ -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"]
|
||||||
27
services/frontend/src/components/OAuthButtons.tsx
Normal file
27
services/frontend/src/components/OAuthButtons.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// Кнопки входа через Google/Яндекс — обычные <a>, не React Router Link:
|
||||||
|
// это реальная навигация браузера на бэкенд (/api/auth/{provider}/login),
|
||||||
|
// который редиректит на экран согласия провайдера, а не SPA-переход.
|
||||||
|
export function OAuthButtons() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-px flex-1 bg-gray-200" />
|
||||||
|
<span className="text-xs text-gray-400">или</span>
|
||||||
|
<div className="h-px flex-1 bg-gray-200" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/api/auth/google/login"
|
||||||
|
className="w-full flex items-center justify-center gap-2 py-2.5 border border-gray-200 rounded-xl text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Продолжить с Google
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/api/auth/yandex/login"
|
||||||
|
className="w-full flex items-center justify-center gap-2 py-2.5 border border-gray-200 rounded-xl text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Продолжить с Яндекс
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { Task } from './pages/Task';
|
|||||||
import { Pricing } from './pages/Pricing';
|
import { Pricing } from './pages/Pricing';
|
||||||
import { Login } from './pages/Login';
|
import { Login } from './pages/Login';
|
||||||
import { Register } from './pages/Register';
|
import { Register } from './pages/Register';
|
||||||
|
import { OAuthCallback } from './pages/OAuthCallback';
|
||||||
import { VerifyEmail } from './pages/VerifyEmail';
|
import { VerifyEmail } from './pages/VerifyEmail';
|
||||||
import { AdminLayout } from './pages/admin/AdminLayout';
|
import { AdminLayout } from './pages/admin/AdminLayout';
|
||||||
import { Dashboard } from './pages/admin/Dashboard';
|
import { Dashboard } from './pages/admin/Dashboard';
|
||||||
@@ -51,6 +52,7 @@ function PublicApp() {
|
|||||||
<Route path="/pricing" element={<Pricing />} />
|
<Route path="/pricing" element={<Pricing />} />
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route path="/oauth/callback" element={<OAuthCallback />} />
|
||||||
<Route path="/verify-email/:token" element={<VerifyEmail />} />
|
<Route path="/verify-email/:token" element={<VerifyEmail />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useMutation } from '@tanstack/react-query';
|
|||||||
import { GraduationCap } from 'lucide-react';
|
import { GraduationCap } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { authApi } from '../api/client';
|
import { authApi } from '../api/client';
|
||||||
|
import { OAuthButtons } from '../components/OAuthButtons';
|
||||||
import { useAuthStore } from '../store/auth';
|
import { useAuthStore } from '../store/auth';
|
||||||
import type { TokenResponse } from '../types';
|
import type { TokenResponse } from '../types';
|
||||||
|
|
||||||
@@ -78,6 +79,10 @@ export function Login() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<OAuthButtons />
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="text-center text-sm text-gray-400 mt-6">
|
<p className="text-center text-sm text-gray-400 mt-6">
|
||||||
Нет аккаунта?{' '}
|
Нет аккаунта?{' '}
|
||||||
<Link to="/register" className="text-brand-600 hover:underline font-medium">
|
<Link to="/register" className="text-brand-600 hover:underline font-medium">
|
||||||
|
|||||||
45
services/frontend/src/pages/OAuthCallback.tsx
Normal file
45
services/frontend/src/pages/OAuthCallback.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="max-w-md mx-auto pt-16 text-center text-gray-500 text-sm">
|
||||||
|
Выполняется вход…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useMutation } from '@tanstack/react-query';
|
|||||||
import { GraduationCap } from 'lucide-react';
|
import { GraduationCap } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { authApi } from '../api/client';
|
import { authApi } from '../api/client';
|
||||||
|
import { OAuthButtons } from '../components/OAuthButtons';
|
||||||
import { useAuthStore } from '../store/auth';
|
import { useAuthStore } from '../store/auth';
|
||||||
import type { TokenResponse } from '../types';
|
import type { TokenResponse } from '../types';
|
||||||
|
|
||||||
@@ -92,6 +93,10 @@ export function Register() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<OAuthButtons />
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="text-center text-sm text-gray-400 mt-6">
|
<p className="text-center text-sm text-gray-400 mt-6">
|
||||||
Уже есть аккаунт?{' '}
|
Уже есть аккаунт?{' '}
|
||||||
<Link to="/login" className="text-brand-600 hover:underline font-medium">
|
<Link to="/login" className="text-brand-600 hover:underline font-medium">
|
||||||
|
|||||||
Reference in New Issue
Block a user