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:
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 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
|
||||
|
||||
@@ -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
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
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"]
|
||||
Reference in New Issue
Block a user