core/docs/oauth-implementation.md

12 KiB
Raw Permalink Blame History

OAuth Implementation Guide

Фронтенд (Текущая реализация)

Контекст сессии

// src/context/session.tsx
const oauth = (provider: string) => {
  console.info('[oauth] Starting OAuth flow for provider:', provider)

  if (isServer) {
    console.warn('[oauth] OAuth not available during SSR')
    return
  }

  // Генерируем state для OAuth
  const state = crypto.randomUUID()
  localStorage.setItem('oauth_state', state)

  // Формируем URL для OAuth
  const oauthUrl = `${coreApiUrl}/auth/oauth/${provider}?state=${state}&redirect_uri=${encodeURIComponent(window.location.origin)}`

  // Перенаправляем на OAuth провайдера
  window.location.href = oauthUrl
}

Обработка OAuth callback

// Обработка OAuth параметров в SessionProvider
createEffect(
  on([() => searchParams?.state, () => searchParams?.access_token, () => searchParams?.token],
    ([state, access_token, token]) => {
      // OAuth обработка
      if (state && access_token) {
        console.info('[SessionProvider] Processing OAuth callback')
        const storedState = !isServer ? localStorage.getItem('oauth_state') : null

        if (storedState === state) {
          console.info('[SessionProvider] OAuth state verified')
          batch(() => {
            changeSearchParams({ mode: 'confirm-email', m: 'auth', access_token }, { replace: true })
            if (!isServer) localStorage.removeItem('oauth_state')
          })
        } else {
          console.warn('[SessionProvider] OAuth state mismatch')
          setAuthError('OAuth state mismatch')
        }
        return
      }

      // Обработка токена сброса пароля
      if (token) {
        console.info('[SessionProvider] Processing password reset token')
        changeSearchParams({ mode: 'change-password', m: 'auth', token }, { replace: true })
      }
    },
    { defer: true }
  )
)

Бекенд Requirements

1. OAuth Endpoints

GET /auth/oauth/{provider}

@router.get("/auth/oauth/{provider}")
async def oauth_redirect(
    provider: str,
    state: str,
    redirect_uri: str,
    request: Request
):
    """
    Инициация OAuth flow с внешним провайдером

    Args:
        provider: Провайдер OAuth (google, facebook, github)
        state: CSRF токен от клиента
        redirect_uri: URL для редиректа после авторизации

    Returns:
        RedirectResponse: Редирект на провайдера OAuth
    """

    # Валидация провайдера
    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 /auth/oauth/{provider}/callback

@router.get("/auth/oauth/{provider}/callback")
async def oauth_callback(
    provider: str,
    code: str,
    state: str,
    request: Request
):
    """
    Обработка callback от OAuth провайдера

    Args:
        provider: Провайдер OAuth
        code: Authorization code от провайдера
        state: CSRF токен для проверки

    Returns:
        RedirectResponse: Редирект обратно на фронтенд с токеном
    """

    # Проверка 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)

2. Provider Configuration

Google OAuth

GOOGLE_OAUTH_CONFIG = {
    "client_id": os.getenv("GOOGLE_CLIENT_ID"),
    "client_secret": os.getenv("GOOGLE_CLIENT_SECRET"),
    "auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
    "token_url": "https://oauth2.googleapis.com/token",
    "user_info_url": "https://www.googleapis.com/oauth2/v2/userinfo",
    "scope": "openid email profile"
}

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"
}

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"
}

3. User Management

OAuth User Model

class OAuthUser(BaseModel):
    provider: str
    provider_id: str
    email: str
    name: str
    avatar_url: Optional[str] = None
    raw_data: dict

User Creation/Linking

async def get_or_create_user_from_oauth(
    provider: str,
    oauth_data: OAuthUser
) -> User:
    """
    Поиск существующего пользователя или создание нового

    Args:
        provider: OAuth провайдер
        oauth_data: Данные пользователя от провайдера

    Returns:
        User: Пользователь в системе
    """

    # Поиск по OAuth связке
    oauth_link = await OAuthLink.get_by_provider_and_id(
        provider=provider,
        provider_id=oauth_data.provider_id
    )

    if oauth_link:
        return await User.get(oauth_link.user_id)

    # Поиск по email
    existing_user = await User.get_by_email(oauth_data.email)

    if existing_user:
        # Привязка OAuth к существующему пользователю
        await OAuthLink.create(
            user_id=existing_user.id,
            provider=provider,
            provider_id=oauth_data.provider_id,
            provider_data=oauth_data.raw_data
        )
        return existing_user

    # Создание нового пользователя
    new_user = await User.create(
        email=oauth_data.email,
        name=oauth_data.name,
        pic=oauth_data.avatar_url,
        is_verified=True,  # OAuth email считается верифицированным
        registration_method='oauth',
        registration_provider=provider
    )

    # Создание OAuth связки
    await OAuthLink.create(
        user_id=new_user.id,
        provider=provider,
        provider_id=oauth_data.provider_id,
        provider_data=oauth_data.raw_data
    )

    return new_user

4. Security

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

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)

5. Database Schema

CREATE TABLE oauth_links (
    id SERIAL PRIMARY KEY,
    user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    provider VARCHAR(50) NOT NULL,
    provider_id VARCHAR(255) NOT NULL,
    provider_data JSONB,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),

    UNIQUE(provider, provider_id),
    INDEX(user_id),
    INDEX(provider, provider_id)
);

6. Environment Variables

Required Config

# Google OAuth
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret

# Facebook OAuth
FACEBOOK_APP_ID=your_facebook_app_id
FACEBOOK_APP_SECRET=your_facebook_app_secret

# GitHub OAuth
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret

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

# JWT
JWT_SECRET=your_jwt_secret_key
JWT_EXPIRATION_HOURS=24

7. Error Handling

OAuth Exceptions

class OAuthException(Exception):
    pass

class InvalidProviderException(OAuthException):
    pass

class StateValidationException(OAuthException):
    pass

class ProviderAPIException(OAuthException):
    pass

# Error responses
@app.exception_handler(OAuthException)
async def oauth_exception_handler(request: Request, exc: OAuthException):
    logger.error(f"OAuth error: {exc}")
    return RedirectResponse(
        url=f"{request.base_url}?error=oauth_failed&message={str(exc)}"
    )

8. Testing

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

Frontend Testing

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()
})

Deployment Checklist

  • Зарегистрировать OAuth приложения у провайдеров
  • Настроить redirect URLs в консолях провайдеров
  • Добавить environment variables
  • Настроить Redis для state management
  • Создать таблицу oauth_links
  • Добавить rate limiting для OAuth endpoints
  • Настроить мониторинг OAuth ошибок
  • Протестировать все провайдеры в staging
  • Добавить логирование OAuth событий