Some checks failed
Deploy on push / deploy (push) Failing after 39s
### 🚨 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
288 lines
9.5 KiB
Markdown
288 lines
9.5 KiB
Markdown
# Минимальный 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
|
||
|
||
**Элегантно. Просто. Безопасно.** ✨
|