### 🚨 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:
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
|
||||
|
||||
**Элегантно. Просто. Безопасно.** ✨
|
||||
Reference in New Issue
Block a user