core/docs/oauth-implementation.md

431 lines
12 KiB
Markdown
Raw Normal View History

2025-05-31 14:18:31 +00:00
# OAuth Implementation Guide
## Фронтенд (Текущая реализация)
### Контекст сессии
```typescript
// src/context/session.tsx
const oauth = (provider: string) => {
console.info('[oauth] Starting OAuth flow for provider:', provider)
2025-05-31 14:18:31 +00:00
if (isServer) {
console.warn('[oauth] OAuth not available during SSR')
return
}
// Генерируем state для OAuth
const state = crypto.randomUUID()
localStorage.setItem('oauth_state', state)
2025-05-31 14:18:31 +00:00
// Формируем URL для OAuth
const oauthUrl = `${coreApiUrl}/auth/oauth/${provider}?state=${state}&redirect_uri=${encodeURIComponent(window.location.origin)}`
2025-05-31 14:18:31 +00:00
// Перенаправляем на OAuth провайдера
window.location.href = oauthUrl
}
```
### Обработка OAuth callback
```typescript
// Обработка OAuth параметров в SessionProvider
createEffect(
on([() => searchParams?.state, () => searchParams?.access_token, () => searchParams?.token],
2025-05-31 14:18:31 +00:00
([state, access_token, token]) => {
// OAuth обработка
if (state && access_token) {
console.info('[SessionProvider] Processing OAuth callback')
const storedState = !isServer ? localStorage.getItem('oauth_state') : null
if (storedState === state) {
console.info('[SessionProvider] OAuth state verified')
batch(() => {
changeSearchParams({ mode: 'confirm-email', m: 'auth', access_token }, { replace: true })
if (!isServer) localStorage.removeItem('oauth_state')
})
} else {
console.warn('[SessionProvider] OAuth state mismatch')
setAuthError('OAuth state mismatch')
}
return
}
// Обработка токена сброса пароля
if (token) {
console.info('[SessionProvider] Processing password reset token')
changeSearchParams({ mode: 'change-password', m: 'auth', token }, { replace: true })
}
},
2025-05-31 14:18:31 +00:00
{ defer: true }
)
)
```
## Бекенд Requirements
### 1. OAuth Endpoints
#### GET `/auth/oauth/{provider}`
```python
@router.get("/auth/oauth/{provider}")
async def oauth_redirect(
provider: str,
state: str,
redirect_uri: str,
request: Request
):
"""
Инициация OAuth flow с внешним провайдером
2025-05-31 14:18:31 +00:00
Args:
provider: Провайдер OAuth (google, facebook, github)
state: CSRF токен от клиента
redirect_uri: URL для редиректа после авторизации
2025-05-31 14:18:31 +00:00
Returns:
RedirectResponse: Редирект на провайдера OAuth
"""
2025-05-31 14:18:31 +00:00
# Валидация провайдера
if provider not in SUPPORTED_PROVIDERS:
raise HTTPException(status_code=400, detail="Unsupported OAuth provider")
2025-05-31 14:18:31 +00:00
# Сохранение state в сессии/Redis для проверки
await store_oauth_state(state, redirect_uri)
2025-05-31 14:18:31 +00:00
# Генерация URL провайдера
oauth_url = generate_provider_url(provider, state, redirect_uri)
2025-05-31 14:18:31 +00:00
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
):
"""
Обработка callback от OAuth провайдера
2025-05-31 14:18:31 +00:00
Args:
provider: Провайдер OAuth
code: Authorization code от провайдера
state: CSRF токен для проверки
2025-05-31 14:18:31 +00:00
Returns:
RedirectResponse: Редирект обратно на фронтенд с токеном
"""
2025-05-31 14:18:31 +00:00
# Проверка state
stored_data = await get_oauth_state(state)
if not stored_data:
raise HTTPException(status_code=400, detail="Invalid or expired state")
2025-05-31 14:18:31 +00:00
# Обмен 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")
2025-05-31 14:18:31 +00:00
# Поиск/создание пользователя
user = await get_or_create_user_from_oauth(provider, user_data)
2025-05-31 14:18:31 +00:00
# Генерация JWT токена
access_token = generate_jwt_token(user.id)
2025-05-31 14:18:31 +00:00
# Редирект обратно на фронтенд
redirect_url = f"{stored_data['redirect_uri']}?state={state}&access_token={access_token}"
return RedirectResponse(url=redirect_url)
```
### 2. Provider Configuration
#### Google OAuth
```python
GOOGLE_OAUTH_CONFIG = {
"client_id": os.getenv("GOOGLE_CLIENT_ID"),
"client_secret": os.getenv("GOOGLE_CLIENT_SECRET"),
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token",
"user_info_url": "https://www.googleapis.com/oauth2/v2/userinfo",
"scope": "openid email profile"
}
```
#### 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",
"scope": "email public_profile"
}
```
#### 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"
}
```
### 3. User Management
#### OAuth User Model
```python
class OAuthUser(BaseModel):
provider: str
provider_id: str
email: str
name: str
avatar_url: Optional[str] = None
raw_data: dict
```
#### User Creation/Linking
```python
async def get_or_create_user_from_oauth(
provider: str,
2025-05-31 14:18:31 +00:00
oauth_data: OAuthUser
) -> User:
"""
Поиск существующего пользователя или создание нового
2025-05-31 14:18:31 +00:00
Args:
provider: OAuth провайдер
oauth_data: Данные пользователя от провайдера
2025-05-31 14:18:31 +00:00
Returns:
User: Пользователь в системе
"""
2025-05-31 14:18:31 +00:00
# Поиск по OAuth связке
oauth_link = await OAuthLink.get_by_provider_and_id(
provider=provider,
provider_id=oauth_data.provider_id
)
2025-05-31 14:18:31 +00:00
if oauth_link:
return await User.get(oauth_link.user_id)
2025-05-31 14:18:31 +00:00
# Поиск по email
existing_user = await User.get_by_email(oauth_data.email)
2025-05-31 14:18:31 +00:00
if existing_user:
# Привязка OAuth к существующему пользователю
await OAuthLink.create(
user_id=existing_user.id,
provider=provider,
provider_id=oauth_data.provider_id,
provider_data=oauth_data.raw_data
)
return existing_user
2025-05-31 14:18:31 +00:00
# Создание нового пользователя
new_user = await User.create(
email=oauth_data.email,
name=oauth_data.name,
pic=oauth_data.avatar_url,
is_verified=True, # OAuth email считается верифицированным
registration_method='oauth',
registration_provider=provider
)
2025-05-31 14:18:31 +00:00
# Создание OAuth связки
await OAuthLink.create(
user_id=new_user.id,
provider=provider,
provider_id=oauth_data.provider_id,
provider_data=oauth_data.raw_data
)
2025-05-31 14:18:31 +00:00
return new_user
```
### 4. Security
#### State Management
```python
import redis
from datetime import timedelta
redis_client = redis.Redis()
async def store_oauth_state(
state: str,
redirect_uri: str,
2025-05-31 14:18:31 +00:00
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
```
#### 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"
]
2025-05-31 14:18:31 +00:00
parsed = urlparse(uri)
return any(domain in parsed.netloc for domain in allowed_domains)
```
### 5. Database Schema
#### OAuth Links Table
```sql
CREATE TABLE oauth_links (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(50) NOT NULL,
provider_id VARCHAR(255) NOT NULL,
provider_data JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
2025-05-31 14:18:31 +00:00
UNIQUE(provider, provider_id),
INDEX(user_id),
INDEX(provider, provider_id)
);
```
### 6. Environment Variables
#### Required Config
```bash
# Google OAuth
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
# Facebook OAuth
2025-05-31 14:18:31 +00:00
FACEBOOK_APP_ID=your_facebook_app_id
FACEBOOK_APP_SECRET=your_facebook_app_secret
# GitHub OAuth
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
# Redis для state management
REDIS_URL=redis://localhost:6379/0
# JWT
JWT_SECRET=your_jwt_secret_key
JWT_EXPIRATION_HOURS=24
```
### 7. Error Handling
#### OAuth Exceptions
```python
class OAuthException(Exception):
pass
class InvalidProviderException(OAuthException):
pass
class StateValidationException(OAuthException):
pass
class ProviderAPIException(OAuthException):
pass
# Error responses
@app.exception_handler(OAuthException)
async def oauth_exception_handler(request: Request, exc: OAuthException):
logger.error(f"OAuth error: {exc}")
return RedirectResponse(
url=f"{request.base_url}?error=oauth_failed&message={str(exc)}"
)
```
### 8. Testing
#### 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"
)
2025-05-31 14:18:31 +00:00
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"]
```
## Frontend Testing
### E2E Tests
```typescript
// tests/oauth.spec.ts
test('OAuth flow with Google', async ({ page }) => {
await page.goto('/login')
2025-05-31 14:18:31 +00:00
// Click Google OAuth button
await page.click('[data-testid="oauth-google"]')
2025-05-31 14:18:31 +00:00
// Should redirect to Google
await page.waitForURL(/accounts\.google\.com/)
2025-05-31 14:18:31 +00:00
// Mock successful OAuth (in test environment)
await page.goto('/?state=test&access_token=mock_token')
2025-05-31 14:18:31 +00:00
// Should be logged in
await expect(page.locator('[data-testid="user-menu"]')).toBeVisible()
})
```
## Deployment Checklist
- [ ] Зарегистрировать OAuth приложения у провайдеров
- [ ] Настроить redirect URLs в консолях провайдеров
- [ ] Добавить environment variables
- [ ] Настроить Redis для state management
- [ ] Создать таблицу oauth_links
- [ ] Добавить rate limiting для OAuth endpoints
- [ ] Настроить мониторинг OAuth ошибок
- [ ] Протестировать все провайдеры в staging
- [ ] Добавить логирование OAuth событий