Files
core/docs/auth/oauth.md
Untone 9d4e24732e
All checks were successful
Deploy on push / deploy (push) Successful in 7m13s
oauth-instruct
2025-09-23 21:34:48 +03:00

19 KiB
Raw Blame History

OAuth Integration Guide

🎯 Обзор

Система OAuth интеграции с поддержкой популярных провайдеров. Токены хранятся в Redis с автоматическим TTL и поддержкой refresh.

🚀 Быстрый старт

Поддерживаемые провайдеры

  • Google - OpenID Connect (актуальные endpoints)
  • GitHub - OAuth 2.0 (scope: read:user user:email)
  • Facebook - Facebook Login API v18.0+ (scope: email public_profile)
  • VK - VK OAuth API v5.199+ (scope: email)
  • X (Twitter) - OAuth 2.0 API v2 (scope: tweet.read users.read)
  • Yandex - Yandex OAuth (scope: login:email login:info login:avatar)
  • Telegram ⚠️ - Telegram Login (специфическая реализация)

Redis структура

oauth_access:{user_id}:{provider}   # Access токены
oauth_refresh:{user_id}:{provider}  # Refresh токены
oauth_state:{state}                 # OAuth state с TTL 10 минут

Основные операции

from auth.tokens.oauth import OAuthTokenManager

oauth = OAuthTokenManager()

# Сохранение токенов
await oauth.store_oauth_tokens(
    user_id="123",
    provider="google",
    access_token="ya29.a0AfH6SM...",
    refresh_token="1//04...",
    expires_in=3600
)

# Получение токена
access_data = await oauth.get_token(user_id, "google", "oauth_access")

# Отзыв токенов
await oauth.revoke_oauth_tokens(user_id, "google")

🔧 OAuth Flow

1. Инициация OAuth

# Frontend
const oauth = (provider: string) => {
  const state = crypto.randomUUID()
  localStorage.setItem('oauth_state', state)
  
  const oauthUrl = `${coreApiUrl}/auth/oauth/${provider}?state=${state}&redirect_uri=${encodeURIComponent(window.location.origin)}`
  window.location.href = oauthUrl
}

2. Backend Endpoints

GET /oauth/{provider}

@router.get("/auth/oauth/{provider}")
async def oauth_redirect(
    provider: str,
    state: str,
    redirect_uri: str,
    request: Request
):
    # Валидация провайдера
    if provider not in SUPPORTED_PROVIDERS:
        raise HTTPException(status_code=400, detail="Unsupported OAuth provider")

    # Сохранение state в Redis
    await store_oauth_state(state, redirect_uri)

    # Генерация URL провайдера
    oauth_url = generate_provider_url(provider, state, redirect_uri)

    return RedirectResponse(url=oauth_url)

GET /oauth/{provider}/callback

@router.get("/oauth/{provider}/callback")
async def oauth_callback(
    provider: str,
    code: str,
    state: str,
    request: Request
):
    # Проверка state
    stored_data = await get_oauth_state(state)
    if not stored_data:
        raise HTTPException(status_code=400, detail="Invalid or expired state")

    # Обмен code на access_token
    try:
        user_data = await exchange_code_for_user_data(provider, code)
    except OAuthException as e:
        logger.error(f"OAuth error for {provider}: {e}")
        return RedirectResponse(url=f"{stored_data['redirect_uri']}?error=oauth_failed")

    # Поиск/создание пользователя
    user = await get_or_create_user_from_oauth(provider, user_data)

    # Генерация JWT токена
    access_token = generate_jwt_token(user.id)

    # Редирект обратно на фронтенд
    redirect_url = f"{stored_data['redirect_uri']}?state={state}&access_token={access_token}"
    return RedirectResponse(url=redirect_url)

3. OAuth State Management

import redis
from datetime import timedelta

redis_client = redis.Redis()

async def store_oauth_state(
    state: str,
    redirect_uri: str,
    ttl: timedelta = timedelta(minutes=10)
):
    """Сохранение OAuth state с TTL"""
    key = f"oauth_state:{state}"
    data = {
        "redirect_uri": redirect_uri,
        "created_at": datetime.utcnow().isoformat()
    }
    await redis_client.setex(key, ttl, json.dumps(data))

async def get_oauth_state(state: str) -> Optional[dict]:
    """Получение и удаление OAuth state"""
    key = f"oauth_state:{state}"
    data = await redis_client.get(key)
    if data:
        await redis_client.delete(key)  # One-time use
        return json.loads(data)
    return None

🔐 Провайдеры

Google OAuth

GOOGLE_OAUTH_CONFIG = {
    "client_id": os.getenv("GOOGLE_CLIENT_ID"),
    "client_secret": os.getenv("GOOGLE_CLIENT_SECRET"),
    "server_metadata_url": "https://accounts.google.com/.well-known/openid-configuration",
    "scope": "openid email profile"
}

Преимущества OpenID Connect:

  • Автоматическое обнаружение endpoints через .well-known/openid-configuration
  • Поддержка актуальных стандартов безопасности
  • Автоматические обновления при изменениях Google API

GitHub OAuth

GITHUB_OAUTH_CONFIG = {
    "client_id": os.getenv("GITHUB_CLIENT_ID"),
    "client_secret": os.getenv("GITHUB_CLIENT_SECRET"),
    "auth_url": "https://github.com/login/oauth/authorize",
    "token_url": "https://github.com/login/oauth/access_token",
    "user_info_url": "https://api.github.com/user",
    "scope": "read:user user:email"
}

⚠️ Важные требования GitHub:

  • Scope user:email обязателен для получения email адреса
  • Проверяйте rate limits (5000 запросов/час для авторизованных пользователей)
  • Используйте User-Agent header во всех запросах к API

Facebook OAuth

FACEBOOK_OAUTH_CONFIG = {
    "client_id": os.getenv("FACEBOOK_APP_ID"),
    "client_secret": os.getenv("FACEBOOK_APP_SECRET"),
    "auth_url": "https://www.facebook.com/v18.0/dialog/oauth",
    "token_url": "https://graph.facebook.com/v18.0/oauth/access_token",
    "user_info_url": "https://graph.facebook.com/v18.0/me",
    "scope": "email public_profile",
    "token_endpoint_auth_method": "client_secret_post"  # Требование Facebook
}

⚠️ Важные требования Facebook:

  • Используйте минимум API v18.0
  • Обязательно настройте точные Redirect URIs в Facebook App
  • Приложение должно быть в режиме "Live" для работы с реальными пользователями
  • HTTPS обязателен для production окружения

VK OAuth

VK_OAUTH_CONFIG = {
    "client_id": os.getenv("VK_APP_ID"),
    "client_secret": os.getenv("VK_APP_SECRET"),
    "auth_url": "https://oauth.vk.com/authorize",
    "token_url": "https://oauth.vk.com/access_token",
    "user_info_url": "https://api.vk.com/method/users.get",
    "scope": "email",
    "api_version": "5.199"  # Актуальная версия API
}

⚠️ Важные требования VK:

  • Используйте API версию 5.199+ (5.131 устарела)
  • Scope email необходим для получения email адреса
  • Redirect URI должен точно совпадать с настройками в приложении VK
  • Поддерживаются только HTTPS redirect URI в production

X (Twitter) OAuth

X_OAUTH_CONFIG = {
    "client_id": os.getenv("X_CLIENT_ID"),
    "client_secret": os.getenv("X_CLIENT_SECRET"),
    "auth_url": "https://twitter.com/i/oauth2/authorize",
    "token_url": "https://api.twitter.com/2/oauth2/token",
    "user_info_url": "https://api.twitter.com/2/users/me",
    "scope": "tweet.read users.read"
}

⚠️ Важные требования X:

  • Используйте API v2 endpoints
  • Scope users.read обязателен для получения профиля
  • Email недоступен через публичное API
  • Требуется верификация приложения для production

Yandex OAuth

YANDEX_OAUTH_CONFIG = {
    "client_id": os.getenv("YANDEX_CLIENT_ID"),
    "client_secret": os.getenv("YANDEX_CLIENT_SECRET"),
    "auth_url": "https://oauth.yandex.ru/authorize",
    "token_url": "https://oauth.yandex.ru/token",
    "user_info_url": "https://login.yandex.ru/info",
    "scope": "login:email login:info login:avatar"
}

⚠️ Важные требования Yandex:

  • Scope login:email для получения email
  • Scope login:info для базовой информации профиля
  • Scope login:avatar для получения аватара
  • Поддержка только HTTPS redirect URI

Telegram OAuth

TELEGRAM_OAUTH_CONFIG = {
    "client_id": os.getenv("TELEGRAM_CLIENT_ID"),
    "client_secret": os.getenv("TELEGRAM_CLIENT_SECRET"),
    "auth_url": "https://oauth.telegram.org/auth",
    "token_url": "https://oauth.telegram.org/auth/request",
    "scope": "read"
}

⚠️ Важные требования Telegram:

  • Специальная настройка через @BotFather
  • Email недоступен - используется временный email
  • Получение номера телефона требует дополнительных разрешений

🔒 Безопасность

TTL и истечение токенов

  • Access tokens: 1 час (настраивается)
  • Refresh tokens: 30 дней
  • OAuth state: 10 минут
  • Автоматическая очистка: Redis удаляет истекшие токены
  • Изоляция провайдеров: Токены разных провайдеров хранятся отдельно

CSRF Protection

def validate_oauth_state(stored_state: str, received_state: str) -> bool:
    """Проверка OAuth state для защиты от CSRF"""
    return stored_state == received_state

def validate_redirect_uri(uri: str) -> bool:
    """Валидация redirect_uri для предотвращения открытых редиректов"""
    allowed_domains = [
        "localhost:3000",
        "discours.io",
        "new.discours.io"
    ]

    parsed = urlparse(uri)
    return any(domain in parsed.netloc for domain in allowed_domains)

💡 Практические примеры

OAuth Login Flow

from auth.oauth import oauth_login, oauth_callback, _create_or_update_user
from auth.oauth import oauth_login_http, oauth_callback_http
from auth.oauth import store_oauth_state, get_oauth_state

# GraphQL resolver для OAuth login
async def handle_oauth_login(provider: str, callback_data: dict):
    """Инициация OAuth авторизации"""
    return await oauth_login(None, info, provider, callback_data)

# HTTP handler для OAuth login
async def handle_oauth_login_http(request):
    """HTTP инициация OAuth авторизации"""
    return await oauth_login_http(request)

# HTTP handler для OAuth callback
async def handle_oauth_callback_http(request):
    """HTTP обработка OAuth callback"""
    return await oauth_callback_http(request)

# Создание/обновление пользователя
async def create_user_from_oauth(provider: str, profile: dict):
    """Создание пользователя из OAuth профиля"""
    return await _create_or_update_user(provider, profile)

# Управление OAuth состоянием
await store_oauth_state(state, oauth_data)
state_data = await get_oauth_state(state)

API Integration

async def make_oauth_request(user_id: int, provider: str, endpoint: str):
    """Запрос к API провайдера"""
    oauth = OAuthTokenManager()
    
    # Получаем access token
    token_data = await oauth.get_token(str(user_id), provider, "oauth_access")
    if not token_data:
        raise OAuthTokenMissing()
    
    # Делаем запрос
    headers = {"Authorization": f"Bearer {token_data['token']}"}
    response = await httpx.get(endpoint, headers=headers)
    
    if response.status_code == 401:
        # Токен истек, требуется повторная авторизация
        raise OAuthTokenExpired()
    
    return response.json()

Мониторинг токенов

async def check_oauth_health():
    """Проверка здоровья OAuth системы"""
    from auth.tokens.monitoring import TokenMonitoring
    
    monitoring = TokenMonitoring()
    stats = await monitoring.get_token_statistics()
    
    return {
        "oauth_tokens": stats["oauth_access_tokens"] + stats["oauth_refresh_tokens"],
        "memory_usage": stats["memory_usage"]
    }

🔧 Настройка и деплой

Environment Variables

# Google OAuth
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret

# GitHub OAuth
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret

# Facebook OAuth
FACEBOOK_APP_ID=your_facebook_app_id
FACEBOOK_APP_SECRET=your_facebook_app_secret

# VK OAuth
VK_APP_ID=your_vk_app_id
VK_APP_SECRET=your_vk_app_secret

# X (Twitter) OAuth
X_CLIENT_ID=your_x_client_id
X_CLIENT_SECRET=your_x_client_secret

# Yandex OAuth
YANDEX_CLIENT_ID=your_yandex_client_id
YANDEX_CLIENT_SECRET=your_yandex_client_secret

# Telegram OAuth
TELEGRAM_CLIENT_ID=your_telegram_client_id
TELEGRAM_CLIENT_SECRET=your_telegram_client_secret

# HTTPS настройки
HTTPS_ENABLED=true  # false для разработки

# Redis для state management
REDIS_URL=redis://localhost:6379/0

# JWT
JWT_SECRET=your_jwt_secret_key
JWT_EXPIRATION_HOURS=24

Настройка провайдеров

Google OAuth

  1. Перейти в Google Cloud Console
  2. Создать новый проект или выбрать существующий
  3. Включить Google+ API
  4. Настроить OAuth consent screen
  5. Создать OAuth 2.0 credentials
  6. Добавить redirect URIs:
    • https://your-domain.com/auth/oauth/google/callback
    • http://localhost:3000/auth/oauth/google/callback (для разработки)

GitHub OAuth

  1. Перейти в GitHub Settings
  2. Создать новое OAuth App
  3. Настроить Authorization callback URL:
    • https://your-domain.com/auth/oauth/github/callback

Facebook OAuth

  1. Перейти в Facebook Developers
  2. Создать новое приложение
  3. Добавить продукт "Facebook Login"
  4. Настроить Valid OAuth Redirect URIs:
    • https://your-domain.com/oauth/facebook/callback
  5. Переключить приложение в режим "Live"

X (Twitter) OAuth

  1. Перейти в Twitter Developer Portal
  2. Создать новое приложение
  3. Настроить OAuth 2.0 settings
  4. Добавить Callback URLs:
    • https://your-domain.com/oauth/x/callback
  5. Получить Client ID и Client Secret

VK OAuth

  1. Перейти в VK Developers
  2. Создать новое приложение типа "Веб-сайт"
  3. Настроить "Доверенный redirect URI":
    • https://your-domain.com/oauth/vk/callback
  4. Получить ID приложения и Защищённый ключ

Yandex OAuth

  1. Перейти в Yandex OAuth
  2. Создать новое приложение
  3. Настроить Callback URL:
    • https://your-domain.com/oauth/yandex/callback
  4. Выбрать необходимые права доступа
  5. Получить ID и пароль приложения

Telegram OAuth

  1. Создать бота через @BotFather
  2. Получить Bot Token
  3. Настроить OAuth через Telegram API
  4. Внимание: Telegram OAuth имеет специфическую реализацию

Redis команды для отладки

# Поиск OAuth токенов пользователя
redis-cli --scan --pattern "oauth_access:123:*"
redis-cli --scan --pattern "oauth_refresh:123:*"

# Получение данных токена
redis-cli GET "oauth_access:123:google"

# Проверка TTL
redis-cli TTL "oauth_access:123:google"

# Поиск OAuth state
redis-cli --scan --pattern "oauth_state:*"

🧪 Тестирование

Unit Tests

def test_oauth_redirect():
    response = client.get("/auth/oauth/google?state=test&redirect_uri=http://localhost:3000")
    assert response.status_code == 307
    assert "accounts.google.com" in response.headers["location"]

def test_oauth_callback():
    # Mock provider response
    with mock.patch('oauth.exchange_code_for_user_data') as mock_exchange:
        mock_exchange.return_value = OAuthUser(
            provider="google",
            provider_id="123456",
            email="test@example.com",
            name="Test User"
        )

        response = client.get("/auth/oauth/google/callback?code=test_code&state=test_state")
        assert response.status_code == 307
        assert "access_token=" in response.headers["location"]

E2E Tests

// tests/oauth.spec.ts
test('OAuth flow with Google', async ({ page }) => {
  await page.goto('/login')

  // Click Google OAuth button
  await page.click('[data-testid="oauth-google"]')

  // Should redirect to Google
  await page.waitForURL(/accounts\.google\.com/)

  // Mock successful OAuth (in test environment)
  await page.goto('/?state=test&access_token=mock_token')

  // Should be logged in
  await expect(page.locator('[data-testid="user-menu"]')).toBeVisible()
})

🔧 Troubleshooting

Частые ошибки

  1. "OAuth state mismatch"

    • Проверьте TTL Redis
    • Убедитесь, что state генерируется правильно
  2. "Provider authentication failed"

    • Проверьте client_id и client_secret
    • Убедитесь, что redirect_uri совпадает с настройками провайдера
  3. "Invalid redirect URI"

    • Добавьте все возможные redirect URIs в настройки приложения
    • Проверьте HTTPS/HTTP в production/development

Логи для отладки

# Backend логи
tail -f /var/log/app/oauth.log | grep "oauth"

# Frontend логи (browser console)
# Фильтр: "[oauth]" или "[SessionProvider]"

📊 Мониторинг

# Добавить метрики для мониторинга
from prometheus_client import Counter, Histogram

oauth_requests = Counter('oauth_requests_total', 'OAuth requests', ['provider', 'status'])
oauth_duration = Histogram('oauth_duration_seconds', 'OAuth request duration')

@router.get("/{provider}")
async def oauth_redirect(provider: str, state: str, redirect_uri: str):
    with oauth_duration.time():
        try:
            # OAuth logic
            oauth_requests.labels(provider=provider, status='success').inc()
        except Exception as e:
            oauth_requests.labels(provider=provider, status='error').inc()
            raise