Files
core/docs/oauth-minimal-flow.md
Untone 05c188df62
Some checks failed
Deploy on push / deploy (push) Failing after 39s
[0.9.29] - 2025-09-26
### 🚨 CRITICAL Security Fixes
- **🔒 Open Redirect Protection**: Добавлена строгая валидация redirect_uri против whitelist доменов
- **🔒 Rate Limiting**: Защита OAuth endpoints от брутфорса (10 попыток за 5 минут на IP)
- **🔒 Logout Endpoint**: Критически важный endpoint для безопасного отзыва httpOnly cookies
- **🔒 Provider Validation**: Усиленная валидация OAuth провайдеров с логированием атак
- **🚨 GlitchTip Alerts**: Автоматические алерты безопасности в GlitchTip при критических событиях

### 🛡️ Security Modules
- **auth/oauth_security.py**: Модуль безопасности OAuth с валидацией и rate limiting + GlitchTip алерты
- **auth/logout.py**: Безопасный logout с поддержкой JSON API и browser redirect
- **tests/test_oauth_security.py**: Комплексные тесты безопасности (11 тестов)
- **tests/test_oauth_glitchtip_alerts.py**: Тесты интеграции с GlitchTip (8 тестов)

### 🔧 OAuth Improvements
- **Minimal Flow**: Упрощен до минимума - только httpOnly cookie, нет JWT в URL
- **Simple Logic**: Нет error параметра = успех, максимальная простота
- **DRY Refactoring**: Устранено дублирование кода в logout и валидации

### 🎯 OAuth Endpoints
- **Старт**: `v3.dscrs.site/oauth/{provider}` - с rate limiting и валидацией
- **Callback**: `v3.dscrs.site/oauth/{provider}/callback` - безопасный redirect_uri
- **Logout**: `v3.dscrs.site/auth/logout` - отзыв httpOnly cookies
- **Финализация**: `testing.discours.io/oauth?redirect_url=...` - минимальная схема

### 📊 Security Test Coverage
-  Open redirect attack prevention
-  Rate limiting protection
-  Provider validation
-  Safe fallback mechanisms
-  Cookie security (httpOnly + Secure + SameSite)
-  GlitchTip integration (8 тестов алертов)

### 📝 Documentation
- Создан `docs/oauth-minimal-flow.md` - полное описание минимального flow
- Обновлена документация OAuth в `docs/auth/oauth.md`
- Добавлены security best practices
2025-09-26 21:03:45 +03:00

9.5 KiB
Raw Blame History

Минимальный OAuth Flow для testing.discours.io

🎯 Философия: Максимальная простота

Принцип: "Нет ошибки = успех"

Никаких лишних параметров, флагов или токенов в URL. Только самое необходимое.

🔧 Backend Implementation

OAuth Callback Handler

@app.route('/oauth/<provider>/callback')
def oauth_callback(provider):
    try:
        # 1. Валидация state (CSRF защита)
        state = request.args.get('state')
        oauth_data = get_oauth_state(state)
        if not oauth_data:
            raise ValueError('Invalid or expired state')
        
        # 2. Обмен code на access_token
        code = request.args.get('code')
        access_token = exchange_code_for_token(provider, code)
        
        # 3. Получение профиля пользователя
        user_data = get_user_profile(provider, access_token)
        
        # 4. Создание/обновление пользователя
        user = create_or_update_user(user_data, provider)
        
        # 5. Генерация JWT
        jwt_token = create_jwt_token(user.id)
        
        # 6. Простой редирект без лишних параметров
        redirect_url = oauth_data.get('redirect_uri', '/')
        response = make_response(redirect(
            f'https://testing.discours.io/oauth?redirect_url={quote(redirect_url)}'
        ))
        
        # 7. JWT только в httpOnly cookie
        response.set_cookie(
            'auth_token',
            jwt_token,
            httponly=True,      # ✅ Защита от XSS
            secure=True,        # ✅ Только HTTPS
            samesite='Lax',     # ✅ CSRF защита
            max_age=7*24*60*60, # 7 дней
            domain='.discours.io'  # ✅ Поддомены
        )
        
        return response
        
    except Exception as e:
        # При ошибке - добавляем error параметр
        logger.error(f'OAuth error: {e}')
        redirect_url = oauth_data.get('redirect_uri', '/') if 'oauth_data' in locals() else '/'
        return redirect(
            f'https://testing.discours.io/oauth?error=auth_failed&redirect_url={quote(redirect_url)}'
        )

🌐 Frontend Implementation

OAuth Route Handler

// routes/oauth.tsx
import { useEffect } from 'solid-js'
import { useNavigate, useSearchParams } from '@solidjs/router'
import { useSession } from '../context/SessionProvider'

export default function OAuthCallback() {
  const navigate = useNavigate()
  const [searchParams] = useSearchParams()
  const { loadSession } = useSession()

  useEffect(async () => {
    const error = searchParams.error
    const redirectUrl = searchParams.redirect_url || '/'

    if (error) {
      // Есть ошибка = неудача
      console.error('OAuth error:', error)
      
      if (error === 'oauth_state_expired') {
        alert('OAuth session expired. Please try logging in again.')
      } else if (error === 'access_denied') {
        alert('Access denied by provider.')
      } else {
        alert('Authentication failed. Please try again.')
      }
      
      navigate('/')
    } else {
      // Нет ошибки = успех! JWT уже в httpOnly cookie
      try {
        await loadSession() // Загружает из httpOnly cookie
        navigate(decodeURIComponent(redirectUrl))
      } catch (error) {
        console.error('Failed to load session:', error)
        navigate('/')
      }
    }
  })

  return (
    <div class="oauth-callback">
      <div class="loading">
        <h2>Completing authentication...</h2>
        <div class="spinner"></div>
      </div>
    </div>
  )
}

Session Provider (httpOnly only)

// context/SessionProvider.tsx
export function SessionProvider(props: { children: any }) {
  const [user, setUser] = createSignal<User | null>(null)
  
  const loadSession = async () => {
    try {
      // Загружаем профиль через GraphQL (httpOnly cookie автоматически)
      const response = await client.query({
        query: GET_CURRENT_USER,
        fetchPolicy: 'network-only'
      })
      
      if (response.data?.currentUser) {
        setUser(response.data.currentUser)
      } else {
        setUser(null)
      }
    } catch (error) {
      console.error('Failed to load session:', error)
      setUser(null)
    }
  }
  
  const logout = async () => {
    setUser(null)
    
    // Очистка httpOnly cookie через logout endpoint
    await fetch('https://v3.dscrs.site/auth/logout', {
      method: 'POST',
      credentials: 'include'
    })
  }
  
  // ... остальная логика
}

🔒 Unified Authentication

// GraphQL Client
const client = new ApolloClient({
  uri: 'https://v3.dscrs.site/graphql',
  credentials: 'include', // ✅ httpOnly cookie
})

// REST API calls
const apiCall = async (endpoint: string, options: RequestInit = {}) => {
  return fetch(`https://v3.dscrs.site${endpoint}`, {
    ...options,
    credentials: 'include', // ✅ httpOnly cookie
    headers: {
      'Content-Type': 'application/json',
      ...options.headers
    }
  })
}

// File uploads
const uploadFile = async (file: File) => {
  const formData = new FormData()
  formData.append('file', file)
  
  return fetch('https://v3.dscrs.site/upload', {
    method: 'POST',
    body: formData,
    credentials: 'include' // ✅ httpOnly cookie
  })
}

🎯 URL Examples

Успешная авторизация

https://testing.discours.io/oauth?redirect_url=%2Fdashboard
  • Нет error параметра = успех
  • JWT в httpOnly cookie
  • Редирект на /dashboard

Ошибка авторизации

https://testing.discours.io/oauth?error=auth_failed&redirect_url=%2F
  • Есть error параметр = неудача
  • Показать ошибку пользователю
  • Редирект на главную

🔒 Истекший state

https://testing.discours.io/oauth?error=oauth_state_expired&redirect_url=%2F
  • CSRF защита сработала
  • Предложить повторить авторизацию

🚀 Преимущества минимального подхода

🔒 Максимальная безопасность

  • Никаких JWT в URL - нет токенов в истории браузера
  • httpOnly cookie - защита от XSS атак
  • SameSite=Lax - защита от CSRF
  • Secure flag - только HTTPS

🧹 Чистота и простота

  • Минимум параметров - только необходимые
  • Логичная схема - отсутствие ошибки = успех
  • Единый источник истины - httpOnly cookie для всего
  • Простой код - меньше условий и проверок

Производительность

  • Меньше парсинга - меньше URL параметров
  • Автоматические cookie - браузер сам отправляет
  • Меньше localStorage операций - нет дублирования
  • Простая логика - быстрее выполнение

🧪 Testing

E2E Test

test('Minimal OAuth flow', async ({ page }) => {
  // 1. Инициация
  await page.goto('https://testing.discours.io')
  await page.click('[data-testid="github-login"]')
  
  // 2. Симуляция успешного callback
  await page.goto('https://testing.discours.io/oauth?redirect_url=%2Fdashboard')
  
  // 3. Проверяем что попали на dashboard (успех)
  await expect(page).toHaveURL('https://testing.discours.io/dashboard')
  
  // 4. Проверяем что cookie установлен
  const cookies = await page.context().cookies()
  const authCookie = cookies.find(c => c.name === 'auth_token')
  expect(authCookie).toBeTruthy()
  expect(authCookie?.httpOnly).toBe(true)
})

test('OAuth error handling', async ({ page }) => {
  // Симуляция ошибки
  await page.goto('https://testing.discours.io/oauth?error=auth_failed&redirect_url=%2F')
  
  // Проверяем что показалась ошибка и редирект на главную
  await expect(page).toHaveURL('https://testing.discours.io/')
})

📊 Comparison

Параметр Старый подход Новый подход
URL параметры success=true&access_token=JWT&redirect_url=... redirect_url=...
Токен в URL Да Нет
localStorage Используется Не нужен
httpOnly cookie Да Да
Логика успеха Проверка success=true Отсутствие error
Безопасность Средняя Максимальная
Простота Средняя Максимальная

🎉 Результат

Самый простой и безопасный OAuth flow:

  1. Нет ошибки = успех
  2. Один источник аутентификации = httpOnly cookie
  3. Минимум параметров = максимум простоты
  4. Максимальная безопасность = никаких токенов в URL

Элегантно. Просто. Безопасно.