Реализовано без 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>
137 lines
4.9 KiB
Python
137 lines
4.9 KiB
Python
"""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, # Яндекс отдаёт только подтверждённые адреса
|
||
)
|