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.1 KiB
9.1 KiB
OAuth Frontend Integration для testing.discours.io
🎯 Схема: JWT в URL + httpOnly Cookie
📋 Полный flow:
- OAuth success → бэкенд генерирует JWT
- Редирект:
/oauth?access_token=JWT&redirect_url=...+ httpOnly cookie - Фронт роут:
localStorage.setItem('auth_token', token) - SessionProvider:
loadSession()→ использует localStorage токен - GraphQL клиент:
credentials: 'include'→ использует httpOnly cookie
🔧 Frontend Implementation
1. OAuth Route Handler (/oauth)
// 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
// 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
// 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
// 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 для прямых вызовов
// 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()
🔒 Безопасность
Преимущества двойной схемы:
- httpOnly Cookie - защита от XSS для GraphQL
- localStorage JWT - быстрый доступ для API calls
- Automatic cleanup - токен удаляется при ошибках 401
- URL cleanup - токен не остается в истории браузера
Обработка ошибок:
// 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
// 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