### 🚨 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
This commit is contained in:
@@ -46,23 +46,74 @@ await oauth.revoke_oauth_tokens(user_id, "google")
|
||||
|
||||
## 🔧 OAuth Flow
|
||||
|
||||
### 1. Инициация OAuth
|
||||
```python
|
||||
# Frontend
|
||||
### 1. Инициация OAuth (Фронтенд)
|
||||
```javascript
|
||||
// Простой вызов без параметров - backend получит redirect_uri из Referer header
|
||||
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
|
||||
window.location.href = `https://v3.dscrs.site/oauth/${provider}`
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Backend Endpoints
|
||||
|
||||
#### GET `/oauth/{provider}`
|
||||
#### GET `/oauth/{provider}` - Старт OAuth
|
||||
```python
|
||||
@router.get("/auth/oauth/{provider}")
|
||||
# v3.dscrs.site/oauth/github
|
||||
# 1. Сохраняет redirect_uri из Referer header в Redis state
|
||||
# 2. Редиректит на провайдера с PKCE challenge
|
||||
```
|
||||
|
||||
#### GET `/oauth/{provider}/callback` - Callback
|
||||
```python
|
||||
# GitHub → v3.dscrs.site/oauth/github/callback?code=xxx&state=yyy
|
||||
# 1. Обменивает code на access_token
|
||||
# 2. Получает профиль пользователя
|
||||
# 3. Создает/обновляет пользователя
|
||||
# 4. Создает JWT сессию
|
||||
# 5. Устанавливает httpOnly cookie (для GraphQL)
|
||||
# 6. Редиректит на https://testing.discours.io/oauth?redirect_url=... (JWT в httpOnly cookie)
|
||||
```
|
||||
|
||||
### 3. Фронтенд финализация
|
||||
```javascript
|
||||
// https://testing.discours.io/oauth роут
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const error = urlParams.get('error')
|
||||
const redirectUrl = urlParams.get('redirect_url') || '/'
|
||||
|
||||
if (error) {
|
||||
// Обработка ошибок OAuth
|
||||
console.error('OAuth error:', error)
|
||||
alert('Authentication failed. Please try again.')
|
||||
window.location.href = '/'
|
||||
} else {
|
||||
// Нет ошибки = успех! JWT уже в httpOnly cookie
|
||||
// SessionProvider загружает сессию из cookie
|
||||
await sessionProvider.loadSession()
|
||||
|
||||
// Редиректим на исходную страницу
|
||||
window.location.href = redirectUrl
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Единая аутентификация через httpOnly cookie
|
||||
```javascript
|
||||
// GraphQL клиент использует httpOnly cookie
|
||||
const client = new ApolloClient({
|
||||
uri: 'https://v3.dscrs.site/graphql',
|
||||
credentials: 'include', // ✅ Отправляет httpOnly cookie
|
||||
})
|
||||
|
||||
// Все API вызовы также используют httpOnly cookie
|
||||
fetch('/api/endpoint', {
|
||||
credentials: 'include' // ✅ Отправляет httpOnly cookie
|
||||
})
|
||||
```
|
||||
|
||||
### 4. Настройки провайдеров (админки)
|
||||
- **GitHub**: `https://v3.dscrs.site/oauth/github/callback`
|
||||
- **Google**: `https://v3.dscrs.site/oauth/google/callback`
|
||||
- **Twitter**: `https://v3.dscrs.site/oauth/twitter/callback`
|
||||
async def oauth_redirect(
|
||||
provider: str,
|
||||
state: str,
|
||||
|
||||
325
docs/oauth-frontend-integration.md
Normal file
325
docs/oauth-frontend-integration.md
Normal file
@@ -0,0 +1,325 @@
|
||||
# OAuth Frontend Integration для testing.discours.io
|
||||
|
||||
## 🎯 Схема: JWT в URL + httpOnly Cookie
|
||||
|
||||
### 📋 Полный flow:
|
||||
1. **OAuth success** → бэкенд генерирует JWT
|
||||
2. **Редирект**: `/oauth?access_token=JWT&redirect_url=...` + httpOnly cookie
|
||||
3. **Фронт роут**: `localStorage.setItem('auth_token', token)`
|
||||
4. **SessionProvider**: `loadSession()` → использует localStorage токен
|
||||
5. **GraphQL клиент**: `credentials: 'include'` → использует httpOnly cookie
|
||||
|
||||
## 🔧 Frontend Implementation
|
||||
|
||||
### 1. OAuth Route Handler (`/oauth`)
|
||||
```typescript
|
||||
// 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 accessToken = searchParams.access_token
|
||||
const redirectUrl = searchParams.redirect_url || '/'
|
||||
|
||||
if (error) {
|
||||
// Обработка ошибок OAuth
|
||||
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('/')
|
||||
return
|
||||
}
|
||||
|
||||
if (accessToken) {
|
||||
try {
|
||||
// 1. Сохраняем JWT в localStorage для быстрого доступа
|
||||
localStorage.setItem('auth_token', accessToken)
|
||||
|
||||
// 2. SessionProvider загружает сессию (использует localStorage токен)
|
||||
await loadSession()
|
||||
|
||||
// 3. Очищаем URL от токена (безопасность)
|
||||
window.history.replaceState({}, document.title, '/oauth-success')
|
||||
|
||||
// 4. Редиректим на исходную страницу через 1 секунду
|
||||
setTimeout(() => {
|
||||
navigate(decodeURIComponent(redirectUrl))
|
||||
}, 1000)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load session:', error)
|
||||
localStorage.removeItem('auth_token')
|
||||
navigate('/')
|
||||
}
|
||||
} else {
|
||||
// Неожиданный случай
|
||||
navigate('/')
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="oauth-callback">
|
||||
<div class="loading">
|
||||
<h2>Completing authentication...</h2>
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. OAuth Initiation
|
||||
```typescript
|
||||
// utils/auth.ts
|
||||
export const oauth = (provider: string) => {
|
||||
// Простой редирект - backend получит redirect_uri из Referer header
|
||||
window.location.href = `https://v3.dscrs.site/oauth/${provider}`
|
||||
}
|
||||
|
||||
// Использование в компонентах
|
||||
import { oauth } from '../utils/auth'
|
||||
|
||||
const LoginButton = () => (
|
||||
<button onClick={() => oauth('github')}>
|
||||
Login with GitHub
|
||||
</button>
|
||||
)
|
||||
```
|
||||
|
||||
### 3. Session Provider
|
||||
```typescript
|
||||
// context/SessionProvider.tsx
|
||||
import { createContext, useContext, createSignal, onMount } from 'solid-js'
|
||||
|
||||
interface SessionContextType {
|
||||
user: () => User | null
|
||||
isAuthenticated: () => boolean
|
||||
loadSession: () => Promise<void>
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
const SessionContext = createContext<SessionContextType>()
|
||||
|
||||
export function SessionProvider(props: { children: any }) {
|
||||
const [user, setUser] = createSignal<User | null>(null)
|
||||
|
||||
const isAuthenticated = () => !!user()
|
||||
|
||||
const loadSession = async () => {
|
||||
try {
|
||||
// Проверяем localStorage токен
|
||||
const token = localStorage.getItem('auth_token')
|
||||
if (!token) {
|
||||
setUser(null)
|
||||
return
|
||||
}
|
||||
|
||||
// Загружаем профиль пользователя через GraphQL (использует httpOnly cookie)
|
||||
const response = await client.query({
|
||||
query: GET_CURRENT_USER,
|
||||
fetchPolicy: 'network-only' // Всегда свежие данные
|
||||
})
|
||||
|
||||
if (response.data?.currentUser) {
|
||||
setUser(response.data.currentUser)
|
||||
} else {
|
||||
// Токен невалидный, очищаем
|
||||
localStorage.removeItem('auth_token')
|
||||
setUser(null)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load session:', error)
|
||||
localStorage.removeItem('auth_token')
|
||||
setUser(null)
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('auth_token')
|
||||
setUser(null)
|
||||
|
||||
// Опционально: вызов logout endpoint для очистки httpOnly cookie
|
||||
fetch('https://v3.dscrs.site/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
})
|
||||
}
|
||||
|
||||
// Загружаем сессию при инициализации
|
||||
onMount(() => {
|
||||
loadSession()
|
||||
})
|
||||
|
||||
return (
|
||||
<SessionContext.Provider value={{
|
||||
user,
|
||||
isAuthenticated,
|
||||
loadSession,
|
||||
logout
|
||||
}}>
|
||||
{props.children}
|
||||
</SessionContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useSession = () => {
|
||||
const context = useContext(SessionContext)
|
||||
if (!context) {
|
||||
throw new Error('useSession must be used within SessionProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
```
|
||||
|
||||
### 4. GraphQL Client Setup
|
||||
```typescript
|
||||
// graphql/client.ts
|
||||
import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client'
|
||||
|
||||
const httpLink = createHttpLink({
|
||||
uri: 'https://v3.dscrs.site/graphql',
|
||||
credentials: 'include', // ✅ КРИТИЧНО: отправляет httpOnly cookie
|
||||
})
|
||||
|
||||
export const client = new ApolloClient({
|
||||
link: httpLink,
|
||||
cache: new InMemoryCache(),
|
||||
defaultOptions: {
|
||||
watchQuery: {
|
||||
errorPolicy: 'all'
|
||||
},
|
||||
query: {
|
||||
errorPolicy: 'all'
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 5. API Client для прямых вызовов
|
||||
```typescript
|
||||
// utils/api.ts
|
||||
class ApiClient {
|
||||
private baseUrl = 'https://v3.dscrs.site'
|
||||
|
||||
private getAuthHeaders() {
|
||||
const token = localStorage.getItem('auth_token')
|
||||
return token ? { 'Authorization': `Bearer ${token}` } : {}
|
||||
}
|
||||
|
||||
async request(endpoint: string, options: RequestInit = {}) {
|
||||
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...this.getAuthHeaders(),
|
||||
...options.headers
|
||||
},
|
||||
credentials: 'include' // Для httpOnly cookie
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
// Токен истек, очищаем localStorage
|
||||
localStorage.removeItem('auth_token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
throw new Error(`API Error: ${response.status}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// Методы для различных API calls
|
||||
async uploadFile(file: File) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
return this.request('/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: this.getAuthHeaders() // Только Authorization header, без Content-Type
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient()
|
||||
```
|
||||
|
||||
## 🔒 Безопасность
|
||||
|
||||
### Преимущества двойной схемы:
|
||||
1. **httpOnly Cookie** - защита от XSS для GraphQL
|
||||
2. **localStorage JWT** - быстрый доступ для API calls
|
||||
3. **Automatic cleanup** - токен удаляется при ошибках 401
|
||||
4. **URL cleanup** - токен не остается в истории браузера
|
||||
|
||||
### Обработка ошибок:
|
||||
```typescript
|
||||
// utils/errorHandler.ts
|
||||
export const handleAuthError = (error: any) => {
|
||||
if (error.networkError?.statusCode === 401) {
|
||||
// Токен истек
|
||||
localStorage.removeItem('auth_token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
|
||||
// В Apollo Client
|
||||
import { onError } from '@apollo/client/link/error'
|
||||
|
||||
const errorLink = onError(({ graphQLErrors, networkError }) => {
|
||||
if (networkError?.statusCode === 401) {
|
||||
handleAuthError(networkError)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### E2E Test
|
||||
```typescript
|
||||
// tests/oauth.spec.ts
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test('OAuth flow works correctly', async ({ page }) => {
|
||||
// 1. Инициация OAuth
|
||||
await page.goto('https://testing.discours.io')
|
||||
await page.click('[data-testid="github-login"]')
|
||||
|
||||
// 2. Проверяем редирект на GitHub
|
||||
await expect(page).toHaveURL(/github\.com\/login\/oauth\/authorize/)
|
||||
|
||||
// 3. Симулируем успешный callback
|
||||
await page.goto('https://testing.discours.io/oauth?access_token=test_jwt&redirect_url=%2Fdashboard')
|
||||
|
||||
// 4. Проверяем что токен сохранился
|
||||
const token = await page.evaluate(() => localStorage.getItem('auth_token'))
|
||||
expect(token).toBe('test_jwt')
|
||||
|
||||
// 5. Проверяем редирект на dashboard
|
||||
await expect(page).toHaveURL('https://testing.discours.io/dashboard')
|
||||
})
|
||||
```
|
||||
|
||||
## 📊 Monitoring
|
||||
|
||||
### Метрики для отслеживания:
|
||||
- OAuth success rate
|
||||
- Token validation errors
|
||||
- Session load time
|
||||
- Cookie/localStorage sync issues
|
||||
172
docs/oauth-glitchtip-integration.md
Normal file
172
docs/oauth-glitchtip-integration.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# GlitchTip Security Alerts Integration
|
||||
|
||||
## 🚨 Автоматические алерты безопасности OAuth
|
||||
|
||||
Система OAuth теперь автоматически отправляет алерты в GlitchTip при обнаружении подозрительной активности.
|
||||
|
||||
## 🎯 Типы алертов
|
||||
|
||||
### 🔴 Критические события (ERROR level)
|
||||
- **`open_redirect_attempt`** - Попытка open redirect атаки
|
||||
- **`rate_limit_exceeded`** - Превышение лимита запросов (брутфорс)
|
||||
- **`invalid_provider`** - Попытка использования несуществующего провайдера
|
||||
- **`suspicious_redirect_uri`** - Подозрительный redirect URI
|
||||
- **`brute_force_detected`** - Обнаружена брутфорс атака
|
||||
|
||||
### 🟡 Обычные события (WARNING level)
|
||||
- **`oauth_login_attempt`** - Обычная попытка входа
|
||||
- **`provider_validation`** - Валидация провайдера
|
||||
- **`redirect_uri_validation`** - Валидация redirect URI
|
||||
|
||||
## 🏷️ Теги для фильтрации в GlitchTip
|
||||
|
||||
Каждый алерт содержит теги для удобной фильтрации:
|
||||
|
||||
```python
|
||||
{
|
||||
"security_event": "rate_limit_exceeded",
|
||||
"component": "oauth",
|
||||
"client_ip": "192.168.1.100",
|
||||
"oauth_provider": "github",
|
||||
"has_redirect_uri": "true"
|
||||
}
|
||||
```
|
||||
|
||||
## 📊 Контекст события
|
||||
|
||||
Детальная информация в контексте `security_details`:
|
||||
|
||||
```python
|
||||
{
|
||||
"ip": "192.168.1.100",
|
||||
"provider": "github",
|
||||
"attempts": 15,
|
||||
"limit": 10,
|
||||
"window_seconds": 300,
|
||||
"severity": "high",
|
||||
"malicious_uri": "https://evil.com/steal",
|
||||
"attack_type": "open_redirect"
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 Интеграция в коде
|
||||
|
||||
### Автоматические алерты
|
||||
|
||||
```python
|
||||
# При превышении rate limit
|
||||
if len(requests) >= OAUTH_RATE_LIMIT:
|
||||
send_rate_limit_alert(client_ip, len(requests))
|
||||
return False
|
||||
|
||||
# При попытке open redirect
|
||||
if not is_allowed:
|
||||
send_open_redirect_alert(redirect_uri)
|
||||
return False
|
||||
```
|
||||
|
||||
### Ручные алерты
|
||||
|
||||
```python
|
||||
from auth.oauth_security import log_oauth_security_event
|
||||
|
||||
# Отправка кастомного алерта
|
||||
log_oauth_security_event("suspicious_activity", {
|
||||
"ip": client_ip,
|
||||
"details": "Custom security event",
|
||||
"severity": "medium"
|
||||
})
|
||||
```
|
||||
|
||||
## 🛡️ Обработка ошибок
|
||||
|
||||
Система устойчива к сбоям GlitchTip:
|
||||
|
||||
```python
|
||||
try:
|
||||
# Отправка алерта в GlitchTip
|
||||
sentry_sdk.capture_message(message, level=level)
|
||||
except Exception as e:
|
||||
# Не ломаем основную логику
|
||||
logger.error(f"Failed to send alert to GlitchTip: {e}")
|
||||
```
|
||||
|
||||
## 📈 Мониторинг в GlitchTip
|
||||
|
||||
### Фильтры для критических событий:
|
||||
```
|
||||
tag:security_event AND level:error
|
||||
```
|
||||
|
||||
### Фильтры по компонентам:
|
||||
```
|
||||
tag:component:oauth
|
||||
```
|
||||
|
||||
### Фильтры по IP адресам:
|
||||
```
|
||||
tag:client_ip:192.168.1.100
|
||||
```
|
||||
|
||||
## 🚨 Алерты по типам атак
|
||||
|
||||
### Open Redirect атаки:
|
||||
```
|
||||
tag:security_event:open_redirect_attempt
|
||||
```
|
||||
|
||||
### Брутфорс атаки:
|
||||
```
|
||||
tag:security_event:rate_limit_exceeded
|
||||
```
|
||||
|
||||
### Невалидные провайдеры:
|
||||
```
|
||||
tag:security_event:invalid_provider
|
||||
```
|
||||
|
||||
## 📊 Статистика безопасности
|
||||
|
||||
GlitchTip позволяет отслеживать:
|
||||
- Количество атак по времени
|
||||
- Топ атакующих IP адресов
|
||||
- Самые частые типы атак
|
||||
- Географическое распределение атак
|
||||
|
||||
## 🔄 Настройка алертов
|
||||
|
||||
В GlitchTip можно настроить:
|
||||
- Email уведомления при критических событиях
|
||||
- Slack/Discord интеграции
|
||||
- Webhook для автоматической блокировки IP
|
||||
- Дашборды для мониторинга безопасности
|
||||
|
||||
## ✅ Тестирование
|
||||
|
||||
Система покрыта тестами:
|
||||
|
||||
```bash
|
||||
# Запуск тестов GlitchTip интеграции
|
||||
uv run python -m pytest tests/test_oauth_glitchtip_alerts.py -v
|
||||
|
||||
# Результат: 8/8 тестов прошли
|
||||
✅ Critical events sent as ERROR
|
||||
✅ Normal events sent as WARNING
|
||||
✅ Open redirect alert integration
|
||||
✅ Rate limit alert integration
|
||||
✅ Failure handling (graceful degradation)
|
||||
✅ Security context tags
|
||||
✅ Event logging integration
|
||||
✅ Critical events list validation
|
||||
```
|
||||
|
||||
## 🎯 Преимущества
|
||||
|
||||
1. **Реальное время** - мгновенные алерты при атаках
|
||||
2. **Контекст** - полная информация о событии
|
||||
3. **Фильтрация** - удобные теги для поиска
|
||||
4. **Устойчивость** - не ломает основную логику при сбоях
|
||||
5. **Тестируемость** - полное покрытие тестами
|
||||
6. **Масштабируемость** - готово для высоких нагрузок
|
||||
|
||||
**Система безопасности OAuth теперь имеет полноценный мониторинг!** 🔒✨
|
||||
287
docs/oauth-minimal-flow.md
Normal file
287
docs/oauth-minimal-flow.md
Normal file
@@ -0,0 +1,287 @@
|
||||
# Минимальный OAuth Flow для testing.discours.io
|
||||
|
||||
## 🎯 Философия: Максимальная простота
|
||||
|
||||
### ✨ **Принцип: "Нет ошибки = успех"**
|
||||
|
||||
Никаких лишних параметров, флагов или токенов в URL. Только самое необходимое.
|
||||
|
||||
## 🔧 Backend Implementation
|
||||
|
||||
### OAuth Callback Handler
|
||||
```python
|
||||
@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
|
||||
```typescript
|
||||
// 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)
|
||||
```typescript
|
||||
// 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
|
||||
|
||||
### Все запросы используют httpOnly cookie
|
||||
```typescript
|
||||
// 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
|
||||
```typescript
|
||||
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
|
||||
|
||||
**Элегантно. Просто. Безопасно.** ✨
|
||||
174
docs/oauth-test-scenarios.md
Normal file
174
docs/oauth-test-scenarios.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# OAuth Test Scenarios для testing.discours.io
|
||||
|
||||
## 🧪 Тестовые сценарии для проверки OAuth flow
|
||||
|
||||
### 1. ✅ Успешная авторизация GitHub
|
||||
```bash
|
||||
# Шаг 1: Инициация OAuth
|
||||
curl -v "https://v3.dscrs.site/oauth/github" \
|
||||
-H "Referer: https://testing.discours.io/some-page" \
|
||||
-H "User-Agent: Mozilla/5.0"
|
||||
|
||||
# Ожидаемый результат:
|
||||
# - Редирект 302 на GitHub с правильными параметрами
|
||||
# - state сохранен в Redis с TTL 10 минут
|
||||
# - redirect_uri взят из Referer header
|
||||
|
||||
# Шаг 2: Callback от GitHub (симуляция)
|
||||
curl -v "https://v3.dscrs.site/oauth/github/callback?code=test_code&state=valid_state" \
|
||||
-H "User-Agent: Mozilla/5.0"
|
||||
|
||||
# Ожидаемый результат:
|
||||
# - Обмен code на access_token
|
||||
# - Получение профиля пользователя
|
||||
# - Создание JWT токена
|
||||
# - Установка httpOnly cookie с domain=".discours.io"
|
||||
# - Редирект на https://testing.discours.io/oauth?success=true
|
||||
```
|
||||
|
||||
### 2. 🚨 Обработка ошибок провайдера
|
||||
```bash
|
||||
# GitHub отклонил доступ
|
||||
curl -v "https://v3.dscrs.site/oauth/github/callback?error=access_denied&state=valid_state"
|
||||
|
||||
# Ожидаемый результат:
|
||||
# - Редирект на https://testing.discours.io/oauth?error=access_denied
|
||||
```
|
||||
|
||||
### 3. 🛡️ CSRF защита (state validation)
|
||||
```bash
|
||||
# Неправильный state
|
||||
curl -v "https://v3.dscrs.site/oauth/github/callback?code=test_code&state=invalid_state"
|
||||
|
||||
# Ожидаемый результат:
|
||||
# - Редирект на https://testing.discours.io/oauth?error=oauth_state_expired
|
||||
```
|
||||
|
||||
### 4. 🔍 Валидация провайдера
|
||||
```bash
|
||||
# Несуществующий провайдер
|
||||
curl -v "https://v3.dscrs.site/oauth/invalid_provider"
|
||||
|
||||
# Ожидаемый результат:
|
||||
# - JSON ответ с ошибкой {"error": "Invalid provider"}
|
||||
```
|
||||
|
||||
### 5. 🍪 Проверка cookie установки
|
||||
```bash
|
||||
# Проверка что cookie устанавливается правильно
|
||||
curl -v "https://v3.dscrs.site/oauth/github/callback?code=valid_code&state=valid_state" \
|
||||
-c cookies.txt
|
||||
|
||||
# Проверить в cookies.txt:
|
||||
# - session_token cookie
|
||||
# - HttpOnly=true
|
||||
# - Secure=true
|
||||
# - SameSite=Lax
|
||||
# - Domain=.discours.io
|
||||
```
|
||||
|
||||
### 6. 🌐 CORS проверка
|
||||
```bash
|
||||
# Preflight запрос
|
||||
curl -v "https://v3.dscrs.site/oauth/github" \
|
||||
-X OPTIONS \
|
||||
-H "Origin: https://testing.discours.io" \
|
||||
-H "Access-Control-Request-Method: GET"
|
||||
|
||||
# Ожидаемый результат:
|
||||
# - Access-Control-Allow-Origin: https://testing.discours.io
|
||||
# - Access-Control-Allow-Credentials: true
|
||||
```
|
||||
|
||||
### 7. 🔄 Полный E2E тест
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Полный тест OAuth flow
|
||||
|
||||
echo "🔄 Тестируем полный OAuth flow..."
|
||||
|
||||
# 1. Инициация
|
||||
INIT_RESPONSE=$(curl -s -D headers1.txt "https://v3.dscrs.site/oauth/github" \
|
||||
-H "Referer: https://testing.discours.io/test-page")
|
||||
|
||||
# Извлекаем Location header для получения state
|
||||
GITHUB_URL=$(grep -i "location:" headers1.txt | cut -d' ' -f2 | tr -d '\r')
|
||||
STATE=$(echo "$GITHUB_URL" | grep -o 'state=[^&]*' | cut -d'=' -f2)
|
||||
|
||||
echo "✅ State получен: $STATE"
|
||||
|
||||
# 2. Симуляция callback
|
||||
CALLBACK_RESPONSE=$(curl -s -D headers2.txt \
|
||||
"https://v3.dscrs.site/oauth/github/callback?code=test_code&state=$STATE")
|
||||
|
||||
# Проверяем редирект
|
||||
REDIRECT_URL=$(grep -i "location:" headers2.txt | cut -d' ' -f2 | tr -d '\r')
|
||||
echo "✅ Redirect URL: $REDIRECT_URL"
|
||||
|
||||
# Проверяем cookie
|
||||
COOKIE=$(grep -i "set-cookie:" headers2.txt | grep "session_token")
|
||||
echo "✅ Cookie установлен: $COOKIE"
|
||||
|
||||
if [[ "$REDIRECT_URL" == *"testing.discours.io/oauth?success=true"* ]]; then
|
||||
echo "🎉 OAuth flow работает корректно!"
|
||||
else
|
||||
echo "❌ OAuth flow не работает"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
## 🔧 Настройки провайдеров для тестирования
|
||||
|
||||
### GitHub OAuth App
|
||||
```
|
||||
Application name: Discours Testing
|
||||
Homepage URL: https://testing.discours.io
|
||||
Authorization callback URL: https://v3.dscrs.site/oauth/github/callback
|
||||
```
|
||||
|
||||
### Google OAuth Client
|
||||
```
|
||||
Authorized JavaScript origins: https://testing.discours.io
|
||||
Authorized redirect URIs: https://v3.dscrs.site/oauth/google/callback
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
```bash
|
||||
# Для тестирования нужны эти переменные:
|
||||
GITHUB_CLIENT_ID=your_github_client_id
|
||||
GITHUB_CLIENT_SECRET=your_github_client_secret
|
||||
GOOGLE_CLIENT_ID=your_google_client_id
|
||||
GOOGLE_CLIENT_SECRET=your_google_client_secret
|
||||
|
||||
# Redis для state storage
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Frontend URL
|
||||
FRONTEND_URL=https://testing.discours.io
|
||||
```
|
||||
|
||||
## 🐛 Возможные проблемы и решения
|
||||
|
||||
### 1. Cookie не устанавливается
|
||||
**Проблема**: Domain mismatch между v3.dscrs.site и testing.discours.io
|
||||
**Решение**: Используется domain=".discours.io" для поддержки поддоменов
|
||||
|
||||
### 2. CORS ошибки
|
||||
**Проблема**: Браузер блокирует запросы между доменами
|
||||
**Решение**: allow_credentials=True в CORS настройках
|
||||
|
||||
### 3. State expired
|
||||
**Проблема**: Redis state истекает через 10 минут
|
||||
**Решение**: Увеличить TTL или оптимизировать flow
|
||||
|
||||
### 4. Provider not configured
|
||||
**Проблема**: Отсутствуют CLIENT_ID/CLIENT_SECRET
|
||||
**Решение**: Проверить environment variables
|
||||
|
||||
## 📊 Метрики успешности
|
||||
|
||||
- ✅ Успешная авторизация: > 95%
|
||||
- ✅ CSRF защита: 100% блокировка invalid state
|
||||
- ✅ Cookie безопасность: HttpOnly + Secure + SameSite
|
||||
- ✅ Error handling: Все ошибки редиректят на фронт
|
||||
- ✅ Performance: < 2 секунд на полный flow
|
||||
Reference in New Issue
Block a user