All checks were successful
Deploy on push / deploy (push) Successful in 2m46s
### 🍪 CRITICAL Cross-Origin Auth - **🔧 SESSION_COOKIE_DOMAIN**: Добавлена поддержка поддоменов `.discours.io` для cross-origin cookies - **🌐 Cross-Origin SSE**: Исправлена работа Server-Sent Events с httpOnly cookies между поддоменами - **🔐 Unified Auth**: Унифицированы настройки cookies для OAuth, login, refresh, logout операций - **📝 MyPy Compliance**: Исправлена типизация `SESSION_COOKIE_SAMESITE` с использованием `cast()` ### 🛠️ Technical Changes - **settings.py**: Добавлен `SESSION_COOKIE_DOMAIN` с типобезопасной настройкой SameSite - **auth/oauth.py**: Обновлены все `set_cookie` вызовы с `domain` параметром - **auth/middleware.py**: Добавлена поддержка `SESSION_COOKIE_DOMAIN` в logout операциях - **resolvers/auth.py**: Унифицированы cookie настройки в login/refresh/logout resolvers - **auth/__init__.py**: Обновлены cookie операции с domain поддержкой ### 📚 Documentation - **docs/auth/sse-httponly-integration.md**: Новая документация по SSE + httpOnly cookies интеграции - **docs/auth/architecture.md**: Обновлены диаграммы для unified httpOnly cookie архитектуры ### 🎯 Impact - ✅ **GraphQL API** (`v3.discours.io`) теперь работает с httpOnly cookies cross-origin - ✅ **SSE сервер** (`connect.discours.io`) работает с теми же cookies - ✅ **Безопасность**: httpOnly cookies защищают от XSS атак - ✅ **UX**: Автоматическая аутентификация без управления токенами в JavaScript
381 lines
12 KiB
Markdown
381 lines
12 KiB
Markdown
# 🔐 OAuth Integration Guide
|
||
|
||
## 🎯 Обзор
|
||
|
||
Система OAuth интеграции с **Bearer токенами** для основного сайта. Поддержка популярных провайдеров с cross-origin совместимостью.
|
||
|
||
**Важно:** OAuth доступен только для основного сайта. Админка использует только email/password аутентификацию.
|
||
|
||
### 🔄 **Архитектура: стандартный подход**
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant U as User
|
||
participant F as Frontend
|
||
participant B as Backend
|
||
participant P as OAuth Provider
|
||
|
||
U->>F: Click "Login with Provider"
|
||
F->>B: GET /oauth/{provider}/login
|
||
B->>P: Redirect to Provider
|
||
P->>U: Show authorization page
|
||
U->>P: Grant permission
|
||
P->>B: GET /oauth/{provider}/callback?code={code}
|
||
B->>P: Exchange code for token
|
||
P->>B: Return access token + user data
|
||
B->>B: Create/update user + JWT session
|
||
B->>F: Redirect with token in URL
|
||
Note over B,F: URL: /?access_token=JWT_TOKEN
|
||
F->>F: Save token to localStorage
|
||
F->>F: Clear token from URL
|
||
F->>U: User logged in
|
||
|
||
Note over F,B: All subsequent requests
|
||
F->>B: GraphQL with Authorization: Bearer
|
||
```
|
||
|
||
## 🚀 Поддерживаемые провайдеры
|
||
|
||
| Провайдер | Статус | Особенности |
|
||
|-----------|--------|-------------|
|
||
| **Google** | ✅ | OpenID Connect, актуальные endpoints |
|
||
| **GitHub** | ✅ | OAuth 2.0, scope: `read:user user:email` |
|
||
| **Yandex** | ✅ | OAuth, scope: `login:email login:info` |
|
||
| **VK** | ✅ | OAuth API v5.199+, scope: `email` |
|
||
| **Facebook** | ✅ | Facebook Login API v18.0+ |
|
||
| **X (Twitter)** | ✅ | OAuth 2.0 API v2 |
|
||
|
||
## 🔧 OAuth Flow
|
||
|
||
### 1. 🚀 Инициация OAuth (Фронтенд)
|
||
|
||
```typescript
|
||
// Простой редирект - backend получит redirect_uri из Referer header
|
||
const handleOAuthLogin = (provider: string) => {
|
||
// Сохраняем текущую страницу для возврата
|
||
localStorage.setItem('oauth_return_url', window.location.pathname);
|
||
|
||
// Редиректим на OAuth endpoint
|
||
window.location.href = `/oauth/${provider}/login`;
|
||
};
|
||
|
||
// Использование
|
||
<button onClick={() => handleOAuthLogin('google')}>
|
||
🔐 Войти через Google
|
||
</button>
|
||
```
|
||
|
||
### 2. 🔄 Backend Endpoints
|
||
|
||
#### GET `/oauth/{provider}/login` - Старт OAuth
|
||
```python
|
||
# /oauth/github/login
|
||
# 1. Сохраняет redirect_uri из Referer header в Redis state
|
||
# 2. Генерирует PKCE challenge для безопасности
|
||
# 3. Редиректит на провайдера с параметрами авторизации
|
||
```
|
||
|
||
#### GET `/oauth/{provider}/callback` - Callback
|
||
```python
|
||
# GitHub → /oauth/github/callback?code=xxx&state=yyy
|
||
# 1. Валидирует state (CSRF защита)
|
||
# 2. Обменивает code на access_token
|
||
# 3. Получает профиль пользователя
|
||
# 4. Создает/обновляет пользователя в БД
|
||
# 5. Создает JWT сессию
|
||
# 6. Устанавливает httpOnly cookie
|
||
# 7. Редиректит на фронтенд БЕЗ токена в URL
|
||
```
|
||
|
||
### 3. 🌐 Фронтенд финализация
|
||
|
||
```typescript
|
||
// OAuth callback route
|
||
export default function OAuthCallback() {
|
||
const navigate = useNavigate();
|
||
const auth = useAuth();
|
||
|
||
onMount(async () => {
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const token = urlParams.get('access_token');
|
||
const error = urlParams.get('error');
|
||
|
||
if (error) {
|
||
// ❌ Ошибка OAuth
|
||
console.error('OAuth error:', error);
|
||
navigate('/login?error=' + error);
|
||
} else if (token) {
|
||
// ✅ Успех! Сохраняем токен в localStorage
|
||
localStorage.setItem('access_token', token);
|
||
|
||
// Очищаем URL от токена
|
||
window.history.replaceState({}, '', window.location.pathname);
|
||
|
||
// Возвращаемся на сохраненную страницу
|
||
const returnUrl = localStorage.getItem('oauth_return_url') || '/';
|
||
localStorage.removeItem('oauth_return_url');
|
||
navigate(returnUrl);
|
||
} else {
|
||
navigate('/login?error=no_token');
|
||
}
|
||
});
|
||
|
||
return (
|
||
<div class="oauth-callback">
|
||
<h2>Завершение авторизации...</h2>
|
||
<p>Пожалуйста, подождите...</p>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 4. 🔑 Использование Bearer токенов
|
||
|
||
```typescript
|
||
// GraphQL клиент использует Bearer токены из localStorage
|
||
const graphqlRequest = async (query: string, variables?: any) => {
|
||
const token = localStorage.getItem('access_token');
|
||
|
||
const response = await fetch('/graphql', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${token}` // ✅ Bearer токен из localStorage
|
||
},
|
||
body: JSON.stringify({ query, variables })
|
||
});
|
||
|
||
return response.json();
|
||
};
|
||
|
||
// Auth Context
|
||
export const AuthProvider = (props: { children: JSX.Element }) => {
|
||
const [user, setUser] = createSignal<User | null>(null);
|
||
|
||
const checkSession = async () => {
|
||
try {
|
||
const response = await graphqlRequest(`
|
||
query GetSession {
|
||
getSession {
|
||
success
|
||
author { id slug email name }
|
||
}
|
||
}
|
||
`);
|
||
|
||
if (response.data?.getSession?.success) {
|
||
setUser(response.data.getSession.author);
|
||
} else {
|
||
setUser(null);
|
||
}
|
||
} catch (error) {
|
||
console.error('Session check failed:', error);
|
||
setUser(null);
|
||
}
|
||
};
|
||
|
||
const logout = async () => {
|
||
try {
|
||
// Удаляем httpOnly cookie на бэкенде
|
||
await graphqlRequest(`mutation { logout { success } }`);
|
||
} catch (error) {
|
||
console.error('Logout error:', error);
|
||
}
|
||
|
||
setUser(null);
|
||
window.location.href = '/';
|
||
};
|
||
|
||
// Проверяем сессию при загрузке
|
||
onMount(() => checkSession());
|
||
|
||
return (
|
||
<AuthContext.Provider value={{
|
||
user,
|
||
isAuthenticated: () => !!user(),
|
||
checkSession,
|
||
logout,
|
||
}}>
|
||
{props.children}
|
||
</AuthContext.Provider>
|
||
);
|
||
};
|
||
```
|
||
|
||
## 🔐 Настройка провайдеров
|
||
|
||
### Google OAuth
|
||
1. [Google Cloud Console](https://console.cloud.google.com/)
|
||
2. **APIs & Services** → **Credentials** → **OAuth 2.0 Client ID**
|
||
3. **Authorized redirect URIs**: `https://your-domain.com/oauth/google/callback`
|
||
|
||
```bash
|
||
GOOGLE_CLIENT_ID=your_google_client_id
|
||
GOOGLE_CLIENT_SECRET=your_google_client_secret
|
||
```
|
||
|
||
### GitHub OAuth
|
||
1. [GitHub Developer Settings](https://github.com/settings/developers)
|
||
2. **New OAuth App**
|
||
3. **Authorization callback URL**: `https://your-domain.com/oauth/github/callback`
|
||
|
||
```bash
|
||
GITHUB_CLIENT_ID=your_github_client_id
|
||
GITHUB_CLIENT_SECRET=your_github_client_secret
|
||
```
|
||
|
||
### Yandex OAuth
|
||
1. [Yandex OAuth](https://oauth.yandex.ru/)
|
||
2. **Создать новое приложение**
|
||
3. **Callback URI**: `https://your-domain.com/oauth/yandex/callback`
|
||
4. **Права**: `login:info`, `login:email`, `login:avatar`
|
||
|
||
```bash
|
||
YANDEX_CLIENT_ID=your_yandex_client_id
|
||
YANDEX_CLIENT_SECRET=your_yandex_client_secret
|
||
```
|
||
|
||
### VK OAuth
|
||
1. [VK Developers](https://dev.vk.com/apps)
|
||
2. **Создать приложение** → **Веб-сайт**
|
||
3. **Redirect URI**: `https://your-domain.com/oauth/vk/callback`
|
||
|
||
```bash
|
||
VK_CLIENT_ID=your_vk_app_id
|
||
VK_CLIENT_SECRET=your_vk_secure_key
|
||
```
|
||
|
||
## 🛡️ Безопасность
|
||
|
||
### httpOnly Cookie настройки
|
||
```python
|
||
# settings.py
|
||
SESSION_COOKIE_NAME = "session_token"
|
||
SESSION_COOKIE_HTTPONLY = True # Защита от XSS
|
||
SESSION_COOKIE_SECURE = True # Только HTTPS
|
||
SESSION_COOKIE_SAMESITE = "lax" # CSRF защита
|
||
SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 дней
|
||
```
|
||
|
||
### CSRF Protection
|
||
- **State parameter**: Криптографически стойкий state для каждого запроса
|
||
- **PKCE**: Code challenge для дополнительной защиты
|
||
- **Redirect URI validation**: Проверка разрешенных доменов
|
||
|
||
### TTL и истечение
|
||
- **OAuth state**: 10 минут (одноразовое использование)
|
||
- **Session tokens**: 30 дней (настраивается)
|
||
- **Автоматическая очистка**: Redis удаляет истекшие токены
|
||
|
||
## 🔧 API для разработчиков
|
||
|
||
### Проверка OAuth токенов
|
||
```python
|
||
from auth.tokens.oauth import OAuthTokenManager
|
||
|
||
oauth = OAuthTokenManager()
|
||
|
||
# Сохранение OAuth токенов (для API интеграций)
|
||
await oauth.store_oauth_tokens(
|
||
user_id="123",
|
||
provider="google",
|
||
access_token="ya29.a0AfH6SM...",
|
||
refresh_token="1//04...",
|
||
expires_in=3600
|
||
)
|
||
|
||
# Получение токена для API вызовов
|
||
token_data = await oauth.get_token("123", "google", "oauth_access")
|
||
if token_data:
|
||
# Используем токен для вызовов Google API
|
||
headers = {"Authorization": f"Bearer {token_data['token']}"}
|
||
```
|
||
|
||
### Redis структура
|
||
```bash
|
||
# OAuth токены для API интеграций
|
||
oauth_access:{user_id}:{provider} # Access токен
|
||
oauth_refresh:{user_id}:{provider} # Refresh токен
|
||
|
||
# OAuth state (временный)
|
||
oauth_state:{state} # Данные авторизации (TTL: 10 мин)
|
||
|
||
# Сессии пользователей (основные)
|
||
session:{user_id}:{token} # JWT сессия (TTL: 30 дней)
|
||
```
|
||
|
||
## 🧪 Тестирование
|
||
|
||
### E2E Test
|
||
```typescript
|
||
test('OAuth flow with httpOnly cookies', async ({ page }) => {
|
||
// 1. Инициация OAuth
|
||
await page.goto('/login');
|
||
await page.click('[data-testid="google-login"]');
|
||
|
||
// 2. Проверяем редирект на Google
|
||
await expect(page).toHaveURL(/accounts\.google\.com/);
|
||
|
||
// 3. Симулируем успешный callback (в тестовой среде)
|
||
await page.goto('/oauth/callback');
|
||
|
||
// 4. Проверяем что cookie установлен
|
||
const cookies = await page.context().cookies();
|
||
const authCookie = cookies.find(c => c.name === 'session_token');
|
||
expect(authCookie).toBeTruthy();
|
||
expect(authCookie?.httpOnly).toBe(true);
|
||
|
||
// 5. Проверяем что пользователь авторизован
|
||
await expect(page.locator('[data-testid="user-menu"]')).toBeVisible();
|
||
});
|
||
```
|
||
|
||
### Отладка
|
||
```bash
|
||
# Проверка OAuth провайдеров
|
||
curl -v "https://your-domain.com/oauth/google/login"
|
||
|
||
# Проверка callback
|
||
curl -v "https://your-domain.com/oauth/google/callback?code=test&state=test"
|
||
|
||
# Проверка сессии с cookie
|
||
curl -b "session_token=your_token" "https://your-domain.com/graphql" \
|
||
-d '{"query":"query { getSession { success author { id } } }"}'
|
||
```
|
||
|
||
## 📊 Мониторинг
|
||
|
||
```python
|
||
from auth.tokens.monitoring import TokenMonitoring
|
||
|
||
monitoring = TokenMonitoring()
|
||
|
||
# Статистика OAuth
|
||
stats = await monitoring.get_token_statistics()
|
||
oauth_tokens = stats.get("oauth_access_tokens", 0) + stats.get("oauth_refresh_tokens", 0)
|
||
print(f"OAuth tokens: {oauth_tokens}")
|
||
|
||
# Health check
|
||
health = await monitoring.health_check()
|
||
if health["status"] == "healthy":
|
||
print("✅ OAuth system is healthy")
|
||
```
|
||
|
||
## 🎯 Преимущества новой архитектуры
|
||
|
||
### 🛡️ Максимальная безопасность:
|
||
- **🚫 Защита от XSS**: Токены недоступны JavaScript
|
||
- **🔒 Защита от CSRF**: SameSite cookies
|
||
- **🛡️ Единообразие**: Все провайдеры используют один механизм
|
||
|
||
### 🚀 Простота использования:
|
||
- **📱 Автоматическая отправка**: Браузер сам включает cookies
|
||
- **🧹 Чистый код**: Нет управления токенами в JavaScript
|
||
- **🔄 Единый API**: Один GraphQL клиент для всех случаев
|
||
|
||
### ⚡ Производительность:
|
||
- **🚀 Быстрее**: Нет localStorage операций
|
||
- **📦 Меньше кода**: Упрощенная логика фронтенда
|
||
- **🔄 Автоматическое управление**: Браузер оптимизирует отправку cookies
|
||
|
||
**Результат: Самая безопасная и простая OAuth интеграция!** 🔐✨ |