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
|
|||
|
|
|
|||
|
|
**Элегантно. Просто. Безопасно.** ✨
|