2025-09-22 00:56:36 +03:00
|
|
|
|
# OAuth Integration Guide
|
|
|
|
|
|
|
|
|
|
|
|
## 🎯 Обзор
|
|
|
|
|
|
|
|
|
|
|
|
Система OAuth интеграции с поддержкой популярных провайдеров. Токены хранятся в Redis с автоматическим TTL и поддержкой refresh.
|
|
|
|
|
|
|
|
|
|
|
|
## 🚀 Быстрый старт
|
|
|
|
|
|
|
|
|
|
|
|
### Поддерживаемые провайдеры
|
|
|
|
|
|
- **Google** - OpenID Connect
|
|
|
|
|
|
- **GitHub** - OAuth 2.0
|
|
|
|
|
|
- **Facebook** - Facebook Login
|
|
|
|
|
|
- **VK** - VK OAuth
|
|
|
|
|
|
- **Yandex** - Yandex OAuth
|
|
|
|
|
|
- **X (Twitter)** - OAuth 2.0
|
|
|
|
|
|
- **Telegram** - Telegram Login
|
|
|
|
|
|
|
|
|
|
|
|
### Redis структура
|
|
|
|
|
|
```bash
|
|
|
|
|
|
oauth_access:{user_id}:{provider} # Access токены
|
|
|
|
|
|
oauth_refresh:{user_id}:{provider} # Refresh токены
|
|
|
|
|
|
oauth_state:{state} # OAuth state с TTL 10 минут
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### Основные операции
|
|
|
|
|
|
```python
|
|
|
|
|
|
from auth.tokens.oauth import OAuthTokenManager
|
|
|
|
|
|
|
|
|
|
|
|
oauth = OAuthTokenManager()
|
|
|
|
|
|
|
|
|
|
|
|
# Сохранение токенов
|
|
|
|
|
|
await oauth.store_oauth_tokens(
|
|
|
|
|
|
user_id="123",
|
|
|
|
|
|
provider="google",
|
|
|
|
|
|
access_token="ya29.a0AfH6SM...",
|
|
|
|
|
|
refresh_token="1//04...",
|
|
|
|
|
|
expires_in=3600
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Получение токена
|
|
|
|
|
|
access_data = await oauth.get_token(user_id, "google", "oauth_access")
|
|
|
|
|
|
|
|
|
|
|
|
# Отзыв токенов
|
|
|
|
|
|
await oauth.revoke_oauth_tokens(user_id, "google")
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 🔧 OAuth Flow
|
|
|
|
|
|
|
|
|
|
|
|
### 1. Инициация OAuth
|
|
|
|
|
|
```python
|
|
|
|
|
|
# Frontend
|
|
|
|
|
|
const oauth = (provider: string) => {
|
|
|
|
|
|
const state = crypto.randomUUID()
|
|
|
|
|
|
localStorage.setItem('oauth_state', state)
|
|
|
|
|
|
|
|
|
|
|
|
const oauthUrl = `${coreApiUrl}/auth/oauth/${provider}?state=${state}&redirect_uri=${encodeURIComponent(window.location.origin)}`
|
|
|
|
|
|
window.location.href = oauthUrl
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 2. Backend Endpoints
|
|
|
|
|
|
|
|
|
|
|
|
#### GET `/auth/oauth/{provider}`
|
|
|
|
|
|
```python
|
|
|
|
|
|
@router.get("/auth/oauth/{provider}")
|
|
|
|
|
|
async def oauth_redirect(
|
|
|
|
|
|
provider: str,
|
|
|
|
|
|
state: str,
|
|
|
|
|
|
redirect_uri: str,
|
|
|
|
|
|
request: Request
|
|
|
|
|
|
):
|
|
|
|
|
|
# Валидация провайдера
|
|
|
|
|
|
if provider not in SUPPORTED_PROVIDERS:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Unsupported OAuth provider")
|
|
|
|
|
|
|
|
|
|
|
|
# Сохранение state в Redis
|
|
|
|
|
|
await store_oauth_state(state, redirect_uri)
|
|
|
|
|
|
|
|
|
|
|
|
# Генерация URL провайдера
|
|
|
|
|
|
oauth_url = generate_provider_url(provider, state, redirect_uri)
|
|
|
|
|
|
|
|
|
|
|
|
return RedirectResponse(url=oauth_url)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
#### GET `/auth/oauth/{provider}/callback`
|
|
|
|
|
|
```python
|
|
|
|
|
|
@router.get("/auth/oauth/{provider}/callback")
|
|
|
|
|
|
async def oauth_callback(
|
|
|
|
|
|
provider: str,
|
|
|
|
|
|
code: str,
|
|
|
|
|
|
state: str,
|
|
|
|
|
|
request: Request
|
|
|
|
|
|
):
|
|
|
|
|
|
# Проверка state
|
|
|
|
|
|
stored_data = await get_oauth_state(state)
|
|
|
|
|
|
if not stored_data:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Invalid or expired state")
|
|
|
|
|
|
|
|
|
|
|
|
# Обмен code на access_token
|
|
|
|
|
|
try:
|
|
|
|
|
|
user_data = await exchange_code_for_user_data(provider, code)
|
|
|
|
|
|
except OAuthException as e:
|
|
|
|
|
|
logger.error(f"OAuth error for {provider}: {e}")
|
|
|
|
|
|
return RedirectResponse(url=f"{stored_data['redirect_uri']}?error=oauth_failed")
|
|
|
|
|
|
|
|
|
|
|
|
# Поиск/создание пользователя
|
|
|
|
|
|
user = await get_or_create_user_from_oauth(provider, user_data)
|
|
|
|
|
|
|
|
|
|
|
|
# Генерация JWT токена
|
|
|
|
|
|
access_token = generate_jwt_token(user.id)
|
|
|
|
|
|
|
|
|
|
|
|
# Редирект обратно на фронтенд
|
|
|
|
|
|
redirect_url = f"{stored_data['redirect_uri']}?state={state}&access_token={access_token}"
|
|
|
|
|
|
return RedirectResponse(url=redirect_url)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 3. OAuth State Management
|
|
|
|
|
|
```python
|
|
|
|
|
|
import redis
|
|
|
|
|
|
from datetime import timedelta
|
|
|
|
|
|
|
|
|
|
|
|
redis_client = redis.Redis()
|
|
|
|
|
|
|
|
|
|
|
|
async def store_oauth_state(
|
|
|
|
|
|
state: str,
|
|
|
|
|
|
redirect_uri: str,
|
|
|
|
|
|
ttl: timedelta = timedelta(minutes=10)
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Сохранение OAuth state с TTL"""
|
|
|
|
|
|
key = f"oauth_state:{state}"
|
|
|
|
|
|
data = {
|
|
|
|
|
|
"redirect_uri": redirect_uri,
|
|
|
|
|
|
"created_at": datetime.utcnow().isoformat()
|
|
|
|
|
|
}
|
|
|
|
|
|
await redis_client.setex(key, ttl, json.dumps(data))
|
|
|
|
|
|
|
|
|
|
|
|
async def get_oauth_state(state: str) -> Optional[dict]:
|
|
|
|
|
|
"""Получение и удаление OAuth state"""
|
|
|
|
|
|
key = f"oauth_state:{state}"
|
|
|
|
|
|
data = await redis_client.get(key)
|
|
|
|
|
|
if data:
|
|
|
|
|
|
await redis_client.delete(key) # One-time use
|
|
|
|
|
|
return json.loads(data)
|
|
|
|
|
|
return None
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 🔐 Провайдеры
|
|
|
|
|
|
|
|
|
|
|
|
### Google OAuth
|
|
|
|
|
|
```python
|
|
|
|
|
|
GOOGLE_OAUTH_CONFIG = {
|
|
|
|
|
|
"client_id": os.getenv("GOOGLE_CLIENT_ID"),
|
|
|
|
|
|
"client_secret": os.getenv("GOOGLE_CLIENT_SECRET"),
|
2025-09-22 23:56:04 +03:00
|
|
|
|
"server_metadata_url": "https://accounts.google.com/.well-known/openid-configuration",
|
2025-09-22 00:56:36 +03:00
|
|
|
|
"scope": "openid email profile"
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2025-09-22 23:56:04 +03:00
|
|
|
|
**✅ Преимущества OpenID Connect:**
|
|
|
|
|
|
- Автоматическое обнаружение endpoints через `.well-known/openid-configuration`
|
|
|
|
|
|
- Поддержка актуальных стандартов безопасности
|
|
|
|
|
|
- Автоматические обновления при изменениях Google API
|
|
|
|
|
|
|
2025-09-22 00:56:36 +03:00
|
|
|
|
### GitHub OAuth
|
|
|
|
|
|
```python
|
|
|
|
|
|
GITHUB_OAUTH_CONFIG = {
|
|
|
|
|
|
"client_id": os.getenv("GITHUB_CLIENT_ID"),
|
|
|
|
|
|
"client_secret": os.getenv("GITHUB_CLIENT_SECRET"),
|
|
|
|
|
|
"auth_url": "https://github.com/login/oauth/authorize",
|
|
|
|
|
|
"token_url": "https://github.com/login/oauth/access_token",
|
|
|
|
|
|
"user_info_url": "https://api.github.com/user",
|
|
|
|
|
|
"scope": "read:user user:email"
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2025-09-22 23:56:04 +03:00
|
|
|
|
**⚠️ Важные требования GitHub:**
|
|
|
|
|
|
- Scope `user:email` **обязателен** для получения email адреса
|
|
|
|
|
|
- Проверяйте rate limits (5000 запросов/час для авторизованных пользователей)
|
|
|
|
|
|
- Используйте `User-Agent` header во всех запросах к API
|
|
|
|
|
|
|
2025-09-22 00:56:36 +03:00
|
|
|
|
### Facebook OAuth
|
|
|
|
|
|
```python
|
|
|
|
|
|
FACEBOOK_OAUTH_CONFIG = {
|
|
|
|
|
|
"client_id": os.getenv("FACEBOOK_APP_ID"),
|
|
|
|
|
|
"client_secret": os.getenv("FACEBOOK_APP_SECRET"),
|
|
|
|
|
|
"auth_url": "https://www.facebook.com/v18.0/dialog/oauth",
|
|
|
|
|
|
"token_url": "https://graph.facebook.com/v18.0/oauth/access_token",
|
|
|
|
|
|
"user_info_url": "https://graph.facebook.com/v18.0/me",
|
2025-09-22 23:56:04 +03:00
|
|
|
|
"scope": "email public_profile",
|
|
|
|
|
|
"token_endpoint_auth_method": "client_secret_post" # Требование Facebook
|
2025-09-22 00:56:36 +03:00
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2025-09-22 23:56:04 +03:00
|
|
|
|
**⚠️ Важные требования Facebook:**
|
|
|
|
|
|
- Используйте **минимум API v18.0**
|
|
|
|
|
|
- Обязательно настройте **точные Redirect URIs** в Facebook App
|
|
|
|
|
|
- Приложение должно быть в режиме **"Live"** для работы с реальными пользователями
|
|
|
|
|
|
- **HTTPS обязателен** для production окружения
|
|
|
|
|
|
|
2025-09-22 00:56:36 +03:00
|
|
|
|
### VK OAuth
|
|
|
|
|
|
```python
|
|
|
|
|
|
VK_OAUTH_CONFIG = {
|
|
|
|
|
|
"client_id": os.getenv("VK_APP_ID"),
|
|
|
|
|
|
"client_secret": os.getenv("VK_APP_SECRET"),
|
|
|
|
|
|
"auth_url": "https://oauth.vk.com/authorize",
|
|
|
|
|
|
"token_url": "https://oauth.vk.com/access_token",
|
|
|
|
|
|
"user_info_url": "https://api.vk.com/method/users.get",
|
2025-09-22 23:56:04 +03:00
|
|
|
|
"scope": "email",
|
|
|
|
|
|
"api_version": "5.199" # Актуальная версия API
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**⚠️ Важные требования VK:**
|
|
|
|
|
|
- Используйте **API версию 5.199+** (5.131 устарела)
|
|
|
|
|
|
- Scope `email` необходим для получения email адреса
|
|
|
|
|
|
- Redirect URI должен **точно совпадать** с настройками в приложении VK
|
|
|
|
|
|
- Поддерживаются только HTTPS redirect URI в production
|
2025-09-22 00:56:36 +03:00
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### Yandex OAuth
|
|
|
|
|
|
```python
|
|
|
|
|
|
YANDEX_OAUTH_CONFIG = {
|
|
|
|
|
|
"client_id": os.getenv("YANDEX_CLIENT_ID"),
|
|
|
|
|
|
"client_secret": os.getenv("YANDEX_CLIENT_SECRET"),
|
|
|
|
|
|
"auth_url": "https://oauth.yandex.ru/authorize",
|
|
|
|
|
|
"token_url": "https://oauth.yandex.ru/token",
|
|
|
|
|
|
"user_info_url": "https://login.yandex.ru/info",
|
|
|
|
|
|
"scope": "login:email login:info"
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 🔒 Безопасность
|
|
|
|
|
|
|
|
|
|
|
|
### TTL и истечение токенов
|
|
|
|
|
|
- **Access tokens**: 1 час (настраивается)
|
|
|
|
|
|
- **Refresh tokens**: 30 дней
|
|
|
|
|
|
- **OAuth state**: 10 минут
|
|
|
|
|
|
- **Автоматическая очистка**: Redis удаляет истекшие токены
|
|
|
|
|
|
- **Изоляция провайдеров**: Токены разных провайдеров хранятся отдельно
|
|
|
|
|
|
|
|
|
|
|
|
### CSRF Protection
|
|
|
|
|
|
```python
|
|
|
|
|
|
def validate_oauth_state(stored_state: str, received_state: str) -> bool:
|
|
|
|
|
|
"""Проверка OAuth state для защиты от CSRF"""
|
|
|
|
|
|
return stored_state == received_state
|
|
|
|
|
|
|
|
|
|
|
|
def validate_redirect_uri(uri: str) -> bool:
|
|
|
|
|
|
"""Валидация redirect_uri для предотвращения открытых редиректов"""
|
|
|
|
|
|
allowed_domains = [
|
|
|
|
|
|
"localhost:3000",
|
|
|
|
|
|
"discours.io",
|
|
|
|
|
|
"new.discours.io"
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
parsed = urlparse(uri)
|
|
|
|
|
|
return any(domain in parsed.netloc for domain in allowed_domains)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 💡 Практические примеры
|
|
|
|
|
|
|
|
|
|
|
|
### OAuth Login Flow
|
|
|
|
|
|
```python
|
|
|
|
|
|
from auth.oauth import oauth_login, oauth_callback, _create_or_update_user
|
|
|
|
|
|
from auth.oauth import oauth_login_http, oauth_callback_http
|
|
|
|
|
|
from auth.oauth import store_oauth_state, get_oauth_state
|
|
|
|
|
|
|
|
|
|
|
|
# GraphQL resolver для OAuth login
|
|
|
|
|
|
async def handle_oauth_login(provider: str, callback_data: dict):
|
|
|
|
|
|
"""Инициация OAuth авторизации"""
|
|
|
|
|
|
return await oauth_login(None, info, provider, callback_data)
|
|
|
|
|
|
|
|
|
|
|
|
# HTTP handler для OAuth login
|
|
|
|
|
|
async def handle_oauth_login_http(request):
|
|
|
|
|
|
"""HTTP инициация OAuth авторизации"""
|
|
|
|
|
|
return await oauth_login_http(request)
|
|
|
|
|
|
|
|
|
|
|
|
# HTTP handler для OAuth callback
|
|
|
|
|
|
async def handle_oauth_callback_http(request):
|
|
|
|
|
|
"""HTTP обработка OAuth callback"""
|
|
|
|
|
|
return await oauth_callback_http(request)
|
|
|
|
|
|
|
|
|
|
|
|
# Создание/обновление пользователя
|
|
|
|
|
|
async def create_user_from_oauth(provider: str, profile: dict):
|
|
|
|
|
|
"""Создание пользователя из OAuth профиля"""
|
|
|
|
|
|
return await _create_or_update_user(provider, profile)
|
|
|
|
|
|
|
|
|
|
|
|
# Управление OAuth состоянием
|
|
|
|
|
|
await store_oauth_state(state, oauth_data)
|
|
|
|
|
|
state_data = await get_oauth_state(state)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### API Integration
|
|
|
|
|
|
```python
|
|
|
|
|
|
async def make_oauth_request(user_id: int, provider: str, endpoint: str):
|
|
|
|
|
|
"""Запрос к API провайдера"""
|
|
|
|
|
|
oauth = OAuthTokenManager()
|
|
|
|
|
|
|
|
|
|
|
|
# Получаем access token
|
|
|
|
|
|
token_data = await oauth.get_token(str(user_id), provider, "oauth_access")
|
|
|
|
|
|
if not token_data:
|
|
|
|
|
|
raise OAuthTokenMissing()
|
|
|
|
|
|
|
|
|
|
|
|
# Делаем запрос
|
|
|
|
|
|
headers = {"Authorization": f"Bearer {token_data['token']}"}
|
|
|
|
|
|
response = await httpx.get(endpoint, headers=headers)
|
|
|
|
|
|
|
|
|
|
|
|
if response.status_code == 401:
|
|
|
|
|
|
# Токен истек, требуется повторная авторизация
|
|
|
|
|
|
raise OAuthTokenExpired()
|
|
|
|
|
|
|
|
|
|
|
|
return response.json()
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### Мониторинг токенов
|
|
|
|
|
|
```python
|
|
|
|
|
|
async def check_oauth_health():
|
|
|
|
|
|
"""Проверка здоровья OAuth системы"""
|
|
|
|
|
|
from auth.tokens.monitoring import TokenMonitoring
|
|
|
|
|
|
|
|
|
|
|
|
monitoring = TokenMonitoring()
|
|
|
|
|
|
stats = await monitoring.get_token_statistics()
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"oauth_tokens": stats["oauth_access_tokens"] + stats["oauth_refresh_tokens"],
|
|
|
|
|
|
"memory_usage": stats["memory_usage"]
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 🔧 Настройка и деплой
|
|
|
|
|
|
|
|
|
|
|
|
### Environment Variables
|
|
|
|
|
|
```bash
|
|
|
|
|
|
# Google OAuth
|
|
|
|
|
|
GOOGLE_CLIENT_ID=your_google_client_id
|
|
|
|
|
|
GOOGLE_CLIENT_SECRET=your_google_client_secret
|
|
|
|
|
|
|
|
|
|
|
|
# GitHub OAuth
|
|
|
|
|
|
GITHUB_CLIENT_ID=your_github_client_id
|
|
|
|
|
|
GITHUB_CLIENT_SECRET=your_github_client_secret
|
|
|
|
|
|
|
|
|
|
|
|
# Facebook OAuth
|
|
|
|
|
|
FACEBOOK_APP_ID=your_facebook_app_id
|
|
|
|
|
|
FACEBOOK_APP_SECRET=your_facebook_app_secret
|
|
|
|
|
|
|
|
|
|
|
|
# VK OAuth
|
|
|
|
|
|
VK_APP_ID=your_vk_app_id
|
|
|
|
|
|
VK_APP_SECRET=your_vk_app_secret
|
|
|
|
|
|
|
|
|
|
|
|
# Yandex OAuth
|
|
|
|
|
|
YANDEX_CLIENT_ID=your_yandex_client_id
|
|
|
|
|
|
YANDEX_CLIENT_SECRET=your_yandex_client_secret
|
|
|
|
|
|
|
|
|
|
|
|
# Redis для state management
|
|
|
|
|
|
REDIS_URL=redis://localhost:6379/0
|
|
|
|
|
|
|
|
|
|
|
|
# JWT
|
|
|
|
|
|
JWT_SECRET=your_jwt_secret_key
|
|
|
|
|
|
JWT_EXPIRATION_HOURS=24
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### Настройка провайдеров
|
|
|
|
|
|
|
|
|
|
|
|
#### Google OAuth
|
|
|
|
|
|
1. Перейти в [Google Cloud Console](https://console.cloud.google.com/)
|
|
|
|
|
|
2. Создать новый проект или выбрать существующий
|
|
|
|
|
|
3. Включить Google+ API
|
|
|
|
|
|
4. Настроить OAuth consent screen
|
|
|
|
|
|
5. Создать OAuth 2.0 credentials
|
|
|
|
|
|
6. Добавить redirect URIs:
|
|
|
|
|
|
- `https://your-domain.com/auth/oauth/google/callback`
|
|
|
|
|
|
- `http://localhost:3000/auth/oauth/google/callback` (для разработки)
|
|
|
|
|
|
|
|
|
|
|
|
#### GitHub OAuth
|
|
|
|
|
|
1. Перейти в [GitHub Settings](https://github.com/settings/applications/new)
|
|
|
|
|
|
2. Создать новое OAuth App
|
|
|
|
|
|
3. Настроить Authorization callback URL:
|
|
|
|
|
|
- `https://your-domain.com/auth/oauth/github/callback`
|
|
|
|
|
|
|
|
|
|
|
|
#### Facebook OAuth
|
|
|
|
|
|
1. Перейти в [Facebook Developers](https://developers.facebook.com/)
|
|
|
|
|
|
2. Создать новое приложение
|
|
|
|
|
|
3. Добавить продукт "Facebook Login"
|
|
|
|
|
|
4. Настроить Valid OAuth Redirect URIs:
|
|
|
|
|
|
- `https://your-domain.com/auth/oauth/facebook/callback`
|
|
|
|
|
|
|
|
|
|
|
|
### Redis команды для отладки
|
|
|
|
|
|
```bash
|
|
|
|
|
|
# Поиск OAuth токенов пользователя
|
|
|
|
|
|
redis-cli --scan --pattern "oauth_access:123:*"
|
|
|
|
|
|
redis-cli --scan --pattern "oauth_refresh:123:*"
|
|
|
|
|
|
|
|
|
|
|
|
# Получение данных токена
|
|
|
|
|
|
redis-cli GET "oauth_access:123:google"
|
|
|
|
|
|
|
|
|
|
|
|
# Проверка TTL
|
|
|
|
|
|
redis-cli TTL "oauth_access:123:google"
|
|
|
|
|
|
|
|
|
|
|
|
# Поиск OAuth state
|
|
|
|
|
|
redis-cli --scan --pattern "oauth_state:*"
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 🧪 Тестирование
|
|
|
|
|
|
|
|
|
|
|
|
### Unit Tests
|
|
|
|
|
|
```python
|
|
|
|
|
|
def test_oauth_redirect():
|
|
|
|
|
|
response = client.get("/auth/oauth/google?state=test&redirect_uri=http://localhost:3000")
|
|
|
|
|
|
assert response.status_code == 307
|
|
|
|
|
|
assert "accounts.google.com" in response.headers["location"]
|
|
|
|
|
|
|
|
|
|
|
|
def test_oauth_callback():
|
|
|
|
|
|
# Mock provider response
|
|
|
|
|
|
with mock.patch('oauth.exchange_code_for_user_data') as mock_exchange:
|
|
|
|
|
|
mock_exchange.return_value = OAuthUser(
|
|
|
|
|
|
provider="google",
|
|
|
|
|
|
provider_id="123456",
|
|
|
|
|
|
email="test@example.com",
|
|
|
|
|
|
name="Test User"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
response = client.get("/auth/oauth/google/callback?code=test_code&state=test_state")
|
|
|
|
|
|
assert response.status_code == 307
|
|
|
|
|
|
assert "access_token=" in response.headers["location"]
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### E2E Tests
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
// tests/oauth.spec.ts
|
|
|
|
|
|
test('OAuth flow with Google', async ({ page }) => {
|
|
|
|
|
|
await page.goto('/login')
|
|
|
|
|
|
|
|
|
|
|
|
// Click Google OAuth button
|
|
|
|
|
|
await page.click('[data-testid="oauth-google"]')
|
|
|
|
|
|
|
|
|
|
|
|
// Should redirect to Google
|
|
|
|
|
|
await page.waitForURL(/accounts\.google\.com/)
|
|
|
|
|
|
|
|
|
|
|
|
// Mock successful OAuth (in test environment)
|
|
|
|
|
|
await page.goto('/?state=test&access_token=mock_token')
|
|
|
|
|
|
|
|
|
|
|
|
// Should be logged in
|
|
|
|
|
|
await expect(page.locator('[data-testid="user-menu"]')).toBeVisible()
|
|
|
|
|
|
})
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 🔧 Troubleshooting
|
|
|
|
|
|
|
|
|
|
|
|
### Частые ошибки
|
|
|
|
|
|
|
|
|
|
|
|
1. **"OAuth state mismatch"**
|
|
|
|
|
|
- Проверьте TTL Redis
|
|
|
|
|
|
- Убедитесь, что state генерируется правильно
|
|
|
|
|
|
|
|
|
|
|
|
2. **"Provider authentication failed"**
|
|
|
|
|
|
- Проверьте client_id и client_secret
|
|
|
|
|
|
- Убедитесь, что redirect_uri совпадает с настройками провайдера
|
|
|
|
|
|
|
|
|
|
|
|
3. **"Invalid redirect URI"**
|
|
|
|
|
|
- Добавьте все возможные redirect URIs в настройки приложения
|
|
|
|
|
|
- Проверьте HTTPS/HTTP в production/development
|
|
|
|
|
|
|
|
|
|
|
|
### Логи для отладки
|
|
|
|
|
|
```bash
|
|
|
|
|
|
# Backend логи
|
|
|
|
|
|
tail -f /var/log/app/oauth.log | grep "oauth"
|
|
|
|
|
|
|
|
|
|
|
|
# Frontend логи (browser console)
|
|
|
|
|
|
# Фильтр: "[oauth]" или "[SessionProvider]"
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 📊 Мониторинг
|
|
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
|
# Добавить метрики для мониторинга
|
|
|
|
|
|
from prometheus_client import Counter, Histogram
|
|
|
|
|
|
|
|
|
|
|
|
oauth_requests = Counter('oauth_requests_total', 'OAuth requests', ['provider', 'status'])
|
|
|
|
|
|
oauth_duration = Histogram('oauth_duration_seconds', 'OAuth request duration')
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/{provider}")
|
|
|
|
|
|
async def oauth_redirect(provider: str, state: str, redirect_uri: str):
|
|
|
|
|
|
with oauth_duration.time():
|
|
|
|
|
|
try:
|
|
|
|
|
|
# OAuth logic
|
|
|
|
|
|
oauth_requests.labels(provider=provider, status='success').inc()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
oauth_requests.labels(provider=provider, status='error').inc()
|
|
|
|
|
|
raise
|
|
|
|
|
|
```
|