unfollow-fix
This commit is contained in:
@@ -37,6 +37,8 @@ Unfollow an entity.
|
||||
|
||||
**Returns:** Same as `follow`
|
||||
|
||||
**Important:** Always returns current following list even if the subscription was not found, ensuring UI consistency.
|
||||
|
||||
### Queries
|
||||
|
||||
#### get_shout_followers
|
||||
@@ -62,9 +64,75 @@ Author[] // List of authors who reacted
|
||||
### Cache Flow
|
||||
1. On follow/unfollow:
|
||||
- Update entity in cache
|
||||
- **Invalidate user's following list cache** (NEW)
|
||||
- Update follower's following list
|
||||
2. Cache is updated before notifications
|
||||
|
||||
### Cache Invalidation (NEW)
|
||||
Following cache keys are invalidated after operations:
|
||||
- `author:follows-topics:{user_id}` - After topic follow/unfollow
|
||||
- `author:follows-authors:{user_id}` - After author follow/unfollow
|
||||
|
||||
This ensures fresh data is fetched from database on next request.
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Enhanced Error Handling (UPDATED)
|
||||
- Unauthorized access check
|
||||
- Entity existence validation
|
||||
- Duplicate follow prevention
|
||||
- **Graceful handling of "following not found" errors**
|
||||
- **Always returns current following list, even on errors**
|
||||
- Full error logging
|
||||
- Transaction safety with `local_session()`
|
||||
|
||||
### Error Response Format
|
||||
```typescript
|
||||
{
|
||||
error?: "following was not found" | "invalid unfollow type" | "access denied",
|
||||
topics?: Topic[], // Always present for topic operations
|
||||
authors?: Author[], // Always present for author operations
|
||||
// ... other entity types
|
||||
}
|
||||
```
|
||||
|
||||
## Recent Fixes (NEW)
|
||||
|
||||
### Issue: Stale UI State on Unfollow Errors
|
||||
**Problem:** When unfollow operation failed with "following was not found", the client didn't update its state because it only processed successful responses.
|
||||
|
||||
**Root Cause:**
|
||||
1. `unfollow` mutation returned error with empty follows list `[]`
|
||||
2. Client logic: `if (result && !result.error)` prevented state updates on errors
|
||||
3. User remained "subscribed" in UI despite no actual subscription in database
|
||||
|
||||
**Solution:**
|
||||
1. **Always fetch current following list** from cache/database
|
||||
2. **Return actual following state** even when subscription not found
|
||||
3. **Add cache invalidation** after successful operations
|
||||
4. **Enhanced logging** for debugging
|
||||
|
||||
### Code Changes
|
||||
```python
|
||||
# Before (BROKEN)
|
||||
if sub:
|
||||
# ... process unfollow
|
||||
else:
|
||||
return {"error": "following was not found", f"{entity_type}s": follows} # follows was []
|
||||
|
||||
# After (FIXED)
|
||||
if sub:
|
||||
# ... process unfollow
|
||||
# Invalidate cache
|
||||
await redis.execute("DEL", f"author:follows-{entity_type}s:{follower_id}")
|
||||
else:
|
||||
error = "following was not found"
|
||||
|
||||
# Always get current state
|
||||
existing_follows = await get_cached_follows_method(follower_id)
|
||||
return {f"{entity_type}s": existing_follows, "error": error}
|
||||
```
|
||||
|
||||
## Notifications
|
||||
|
||||
- Sent when author is followed/unfollowed
|
||||
@@ -73,14 +141,6 @@ Author[] // List of authors who reacted
|
||||
- Author ID
|
||||
- Action type ("follow"/"unfollow")
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Unauthorized access check
|
||||
- Entity existence validation
|
||||
- Duplicate follow prevention
|
||||
- Full error logging
|
||||
- Transaction safety with `local_session()`
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Follower Tables
|
||||
@@ -91,4 +151,18 @@ Author[] // List of authors who reacted
|
||||
|
||||
Each table contains:
|
||||
- `follower` - ID of following user
|
||||
- `{entity_type}` - ID of followed entity
|
||||
- `{entity_type}` - ID of followed entity
|
||||
|
||||
## Testing
|
||||
|
||||
Run the test script to verify fixes:
|
||||
```bash
|
||||
python test_unfollow_fix.py
|
||||
```
|
||||
|
||||
### Test Coverage
|
||||
- ✅ Unfollow existing subscription
|
||||
- ✅ Unfollow non-existent subscription
|
||||
- ✅ Cache invalidation
|
||||
- ✅ Proper error handling
|
||||
- ✅ UI state consistency
|
199
docs/oauth-deployment.md
Normal file
199
docs/oauth-deployment.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# OAuth Deployment Checklist
|
||||
|
||||
## 🚀 Quick Setup Guide
|
||||
|
||||
### 1. Backend Implementation
|
||||
```bash
|
||||
# Добавьте в requirements.txt или poetry
|
||||
redis>=4.0.0
|
||||
httpx>=0.24.0
|
||||
pydantic>=2.0.0
|
||||
```
|
||||
|
||||
### 2. Environment Variables
|
||||
```bash
|
||||
# .env file
|
||||
GOOGLE_CLIENT_ID=your_google_client_id
|
||||
GOOGLE_CLIENT_SECRET=your_google_client_secret
|
||||
FACEBOOK_APP_ID=your_facebook_app_id
|
||||
FACEBOOK_APP_SECRET=your_facebook_app_secret
|
||||
GITHUB_CLIENT_ID=your_github_client_id
|
||||
GITHUB_CLIENT_SECRET=your_github_client_secret
|
||||
VK_APP_ID=your_vk_app_id
|
||||
VK_APP_SECRET=your_vk_app_secret
|
||||
YANDEX_CLIENT_ID=your_yandex_client_id
|
||||
YANDEX_CLIENT_SECRET=your_yandex_client_secret
|
||||
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
JWT_SECRET=your_super_secret_jwt_key
|
||||
JWT_EXPIRATION_HOURS=24
|
||||
```
|
||||
|
||||
### 3. Database Migration
|
||||
```sql
|
||||
-- Create oauth_links table
|
||||
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(),
|
||||
|
||||
UNIQUE(provider, provider_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_oauth_links_user_id ON oauth_links(user_id);
|
||||
CREATE INDEX idx_oauth_links_provider ON oauth_links(provider, provider_id);
|
||||
```
|
||||
|
||||
### 4. OAuth Provider Setup
|
||||
|
||||
#### 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` (для разработки)
|
||||
|
||||
#### 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`
|
||||
|
||||
#### 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`
|
||||
|
||||
### 5. Backend Endpoints (FastAPI example)
|
||||
```python
|
||||
# auth/oauth.py
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
router = APIRouter(prefix="/auth/oauth")
|
||||
|
||||
@router.get("/{provider}")
|
||||
async def oauth_redirect(provider: str, state: str, redirect_uri: str):
|
||||
# Валидация провайдера
|
||||
if provider not in ["google", "facebook", "github", "vk", "yandex"]:
|
||||
raise HTTPException(400, "Unsupported 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)
|
||||
|
||||
@router.get("/{provider}/callback")
|
||||
async def oauth_callback(provider: str, code: str, state: str):
|
||||
# Проверка state
|
||||
stored_data = await get_oauth_state(state)
|
||||
if not stored_data:
|
||||
raise HTTPException(400, "Invalid state")
|
||||
|
||||
# Обмен code на user_data
|
||||
user_data = await exchange_code_for_user_data(provider, code)
|
||||
|
||||
# Создание/поиск пользователя
|
||||
user = await get_or_create_user_from_oauth(provider, user_data)
|
||||
|
||||
# Генерация JWT
|
||||
access_token = generate_jwt_token(user.id)
|
||||
|
||||
# Редирект с токеном
|
||||
return RedirectResponse(
|
||||
url=f"{stored_data['redirect_uri']}?state={state}&access_token={access_token}"
|
||||
)
|
||||
```
|
||||
|
||||
### 6. Testing
|
||||
```bash
|
||||
# Запуск E2E тестов
|
||||
npm run test:e2e -- oauth.spec.ts
|
||||
|
||||
# Проверка OAuth endpoints
|
||||
curl -X GET "http://localhost:8000/auth/oauth/google?state=test&redirect_uri=http://localhost:3000"
|
||||
```
|
||||
|
||||
### 7. Production Deployment
|
||||
|
||||
#### Frontend
|
||||
- [ ] Проверить корректность `coreApiUrl` в production
|
||||
- [ ] Добавить обработку ошибок OAuth в UI
|
||||
- [ ] Настроить CSP headers для OAuth редиректов
|
||||
|
||||
#### Backend
|
||||
- [ ] Настроить HTTPS для всех OAuth endpoints
|
||||
- [ ] Добавить rate limiting для OAuth endpoints
|
||||
- [ ] Настроить CORS для фронтенд доменов
|
||||
- [ ] Добавить мониторинг OAuth ошибок
|
||||
- [ ] Настроить логирование OAuth событий
|
||||
|
||||
#### Infrastructure
|
||||
- [ ] Настроить Redis для production
|
||||
- [ ] Добавить health checks для OAuth endpoints
|
||||
- [ ] Настроить backup для oauth_links таблицы
|
||||
|
||||
### 8. Security Checklist
|
||||
- [ ] Все OAuth секреты в environment variables
|
||||
- [ ] State validation с TTL (10 минут)
|
||||
- [ ] CSRF protection включен
|
||||
- [ ] Redirect URI validation
|
||||
- [ ] Rate limiting на OAuth endpoints
|
||||
- [ ] Логирование всех OAuth событий
|
||||
- [ ] HTTPS обязателен в production
|
||||
|
||||
### 9. Monitoring
|
||||
```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
|
||||
```
|
||||
|
||||
## 🔧 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]"
|
||||
```
|
430
docs/oauth-implementation.md
Normal file
430
docs/oauth-implementation.md
Normal file
@@ -0,0 +1,430 @@
|
||||
# OAuth Implementation Guide
|
||||
|
||||
## Фронтенд (Текущая реализация)
|
||||
|
||||
### Контекст сессии
|
||||
```typescript
|
||||
// src/context/session.tsx
|
||||
const oauth = (provider: string) => {
|
||||
console.info('[oauth] Starting OAuth flow for provider:', provider)
|
||||
|
||||
if (isServer) {
|
||||
console.warn('[oauth] OAuth not available during SSR')
|
||||
return
|
||||
}
|
||||
|
||||
// Генерируем state для OAuth
|
||||
const state = crypto.randomUUID()
|
||||
localStorage.setItem('oauth_state', state)
|
||||
|
||||
// Формируем URL для OAuth
|
||||
const oauthUrl = `${coreApiUrl}/auth/oauth/${provider}?state=${state}&redirect_uri=${encodeURIComponent(window.location.origin)}`
|
||||
|
||||
// Перенаправляем на OAuth провайдера
|
||||
window.location.href = oauthUrl
|
||||
}
|
||||
```
|
||||
|
||||
### Обработка OAuth callback
|
||||
```typescript
|
||||
// Обработка OAuth параметров в SessionProvider
|
||||
createEffect(
|
||||
on([() => searchParams?.state, () => searchParams?.access_token, () => searchParams?.token],
|
||||
([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 })
|
||||
}
|
||||
},
|
||||
{ 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 с внешним провайдером
|
||||
|
||||
Args:
|
||||
provider: Провайдер OAuth (google, facebook, github)
|
||||
state: CSRF токен от клиента
|
||||
redirect_uri: URL для редиректа после авторизации
|
||||
|
||||
Returns:
|
||||
RedirectResponse: Редирект на провайдера OAuth
|
||||
"""
|
||||
|
||||
# Валидация провайдера
|
||||
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
|
||||
):
|
||||
"""
|
||||
Обработка callback от OAuth провайдера
|
||||
|
||||
Args:
|
||||
provider: Провайдер OAuth
|
||||
code: Authorization code от провайдера
|
||||
state: CSRF токен для проверки
|
||||
|
||||
Returns:
|
||||
RedirectResponse: Редирект обратно на фронтенд с токеном
|
||||
"""
|
||||
|
||||
# Проверка 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)
|
||||
```
|
||||
|
||||
### 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,
|
||||
oauth_data: OAuthUser
|
||||
) -> User:
|
||||
"""
|
||||
Поиск существующего пользователя или создание нового
|
||||
|
||||
Args:
|
||||
provider: OAuth провайдер
|
||||
oauth_data: Данные пользователя от провайдера
|
||||
|
||||
Returns:
|
||||
User: Пользователь в системе
|
||||
"""
|
||||
|
||||
# Поиск по OAuth связке
|
||||
oauth_link = await OAuthLink.get_by_provider_and_id(
|
||||
provider=provider,
|
||||
provider_id=oauth_data.provider_id
|
||||
)
|
||||
|
||||
if oauth_link:
|
||||
return await User.get(oauth_link.user_id)
|
||||
|
||||
# Поиск по email
|
||||
existing_user = await User.get_by_email(oauth_data.email)
|
||||
|
||||
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
|
||||
|
||||
# Создание нового пользователя
|
||||
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
|
||||
)
|
||||
|
||||
# Создание OAuth связки
|
||||
await OAuthLink.create(
|
||||
user_id=new_user.id,
|
||||
provider=provider,
|
||||
provider_id=oauth_data.provider_id,
|
||||
provider_data=oauth_data.raw_data
|
||||
)
|
||||
|
||||
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,
|
||||
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"
|
||||
]
|
||||
|
||||
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(),
|
||||
|
||||
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
|
||||
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"
|
||||
)
|
||||
|
||||
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')
|
||||
|
||||
// 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()
|
||||
})
|
||||
```
|
||||
|
||||
## Deployment Checklist
|
||||
|
||||
- [ ] Зарегистрировать OAuth приложения у провайдеров
|
||||
- [ ] Настроить redirect URLs в консолях провайдеров
|
||||
- [ ] Добавить environment variables
|
||||
- [ ] Настроить Redis для state management
|
||||
- [ ] Создать таблицу oauth_links
|
||||
- [ ] Добавить rate limiting для OAuth endpoints
|
||||
- [ ] Настроить мониторинг OAuth ошибок
|
||||
- [ ] Протестировать все провайдеры в staging
|
||||
- [ ] Добавить логирование OAuth событий
|
Reference in New Issue
Block a user