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:
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