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