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
326 lines
9.1 KiB
Markdown
326 lines
9.1 KiB
Markdown
# 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
|