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
9.5 KiB
9.5 KiB
Минимальный 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
Все запросы используют httpOnly cookie
// 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:
- Нет ошибки = успех
- Один источник аутентификации = httpOnly cookie
- Минимум параметров = максимум простоты
- Максимальная безопасность = никаких токенов в URL
Элегантно. Просто. Безопасно. ✨