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,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"]