feat(auth): вход через Google и Яндекс (OAuth2 authorization code flow)
All checks were successful
Deploy / test (push) Successful in 2m48s
Deploy / deploy (push) Successful in 23s

Реализовано без 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:
jze9
2026-08-24 17:59:10 +05:00
parent 251be1d77b
commit 9f35ae8de1
19 changed files with 488 additions and 8 deletions

View 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, # Яндекс отдаёт только подтверждённые адреса
)

View File

@@ -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)