Improve topic sorting: add popular sorting by publications and authors count

This commit is contained in:
2025-06-02 02:56:11 +03:00
parent baca19a4d5
commit 3327976586
113 changed files with 7238 additions and 3739 deletions

View File

@@ -22,6 +22,11 @@ JWT_SECRET_KEY = "your-secret-key" # секретный ключ для JWT т
SESSION_TOKEN_LIFE_SPAN = 60 * 60 * 24 * 30 # время жизни сессии (30 дней)
```
### Authentication & Security
- [Security System](security.md) - Password and email management
- [OAuth Token Management](oauth.md) - OAuth provider token storage in Redis
- [Following System](follower.md) - User subscription system
### Реакции и комментарии
Модуль обработки пользовательских реакций и комментариев.
@@ -51,7 +56,7 @@ SESSION_TOKEN_LIFE_SPAN = 60 * 60 * 24 * 30 # время жизни сесси
- Проверка доступа по email или правам в системе RBAC
Маршруты:
- `/admin` - административная панель с проверкой прав доступа
- `/admin` - административная панель с проверкой прав доступа
## Запуск сервера
@@ -93,4 +98,4 @@ python run.py --https --domain "localhost.localdomain"
**Преимущества mkcert:**
- Сертификаты распознаются браузером как доверенные (нет предупреждений)
- Работает на всех платформах (macOS, Linux, Windows)
- Простая установка и настройка
- Простая установка и настройка

40
docs/api.md Normal file
View File

@@ -0,0 +1,40 @@
## API Documentation
### GraphQL Schema
- Mutations: Authentication, content management, security
- Queries: Content retrieval, user data
- Types: Author, Topic, Shout, Community
### Key Features
#### Security Management
- Password change with validation
- Email change with confirmation
- Two-factor authentication flow
- Protected fields for user privacy
#### Content Management
- Publication system with drafts
- Topic and community organization
- Author collaboration tools
- Real-time notifications
#### Following System
- Subscribe to authors and topics
- Cache-optimized operations
- Consistent UI state management
## Database
### Models
- `Author` - User accounts with RBAC
- `Shout` - Publications and articles
- `Topic` - Content categorization
- `Community` - User groups
### Cache System
- Redis-based caching
- Automatic cache invalidation
- Optimized for real-time updates

View File

@@ -247,12 +247,12 @@ import { useAuthContext } from '../auth'
export const AdminPanel: Component = () => {
const auth = useAuthContext()
// Проверяем наличие роли админа
if (!auth.hasRole('admin')) {
return <div>Доступ запрещен</div>
}
return (
<div>
<h1>Панель администратора</h1>
@@ -349,25 +349,25 @@ from auth.decorators import login_required
from auth.models import Author
@login_required
async def update_article(_, info, article_id: int, data: dict):
async def update_article(_: None,info, article_id: int, data: dict):
"""
Обновление статьи с проверкой прав
"""
user: Author = info.context.user
# Получаем статью
article = db.query(Article).get(article_id)
if not article:
raise GraphQLError('Статья не найдена')
# Проверяем права на редактирование
if not user.has_permission('articles', 'edit'):
raise GraphQLError('Недостаточно прав')
# Обновляем поля
article.title = data.get('title', article.title)
article.content = data.get('content', article.content)
db.commit()
return article
```
@@ -381,25 +381,24 @@ from auth.password import hash_password
def create_admin(email: str, password: str):
"""Создание администратора"""
# Получаем роль админа
admin_role = db.query(Role).filter(Role.id == 'admin').first()
# Создаем пользователя
admin = Author(
email=email,
password=hash_password(password),
is_active=True,
email_verified=True
)
# Назначаем роль
admin.roles.append(admin_role)
# Сохраняем
db.add(admin)
db.commit()
return admin
```
@@ -554,19 +553,19 @@ export const ProfileForm: Component = () => {
// validators/auth.ts
export const validatePassword = (password: string): string[] => {
const errors: string[] = []
if (password.length < 8) {
errors.push('Пароль должен быть не менее 8 символов')
}
if (!/[A-Z]/.test(password)) {
errors.push('Пароль должен содержать заглавную букву')
}
if (!/[0-9]/.test(password)) {
errors.push('Пароль должен содержать цифру')
}
return errors
}
@@ -575,24 +574,24 @@ import { validatePassword } from '../validators/auth'
export const RegisterForm: Component = () => {
const [errors, setErrors] = createSignal<string[]>([])
const handleSubmit = async (e: Event) => {
e.preventDefault()
const form = e.target as HTMLFormElement
const data = new FormData(form)
// Валидация пароля
const password = data.get('password') as string
const passwordErrors = validatePassword(password)
if (passwordErrors.length > 0) {
setErrors(passwordErrors)
return
}
// Отправка формы...
}
return (
<form onSubmit={handleSubmit}>
<input name="password" type="password" />
@@ -613,7 +612,7 @@ from auth.models import Author
async def notify_login(user: Author, ip: str, device: str):
"""Отправка уведомления о новом входе"""
# Формируем текст
text = f"""
Новый вход в аккаунт:
@@ -621,14 +620,14 @@ async def notify_login(user: Author, ip: str, device: str):
Устройство: {device}
Время: {datetime.now()}
"""
# Отправляем email
await send_email(
to=user.email,
subject='Новый вход в аккаунт',
text=text
)
# Логируем
logger.info(f'New login for user {user.id} from {ip}')
```
@@ -647,14 +646,14 @@ async def test_google_oauth_success(client, mock_google):
'email': 'test@gmail.com',
'name': 'Test User'
}
# Запрос на авторизацию
response = await client.get('/auth/login/google')
assert response.status_code == 302
# Проверяем редирект
assert 'accounts.google.com' in response.headers['location']
# Проверяем сессию
assert 'state' in client.session
assert 'code_verifier' in client.session
@@ -673,10 +672,10 @@ def test_user_permissions():
operation='edit'
)
role.permissions.append(permission)
user = Author(email='test@test.com')
user.roles.append(role)
# Проверяем разрешения
assert user.has_permission('articles', 'edit')
assert not user.has_permission('articles', 'delete')
@@ -696,23 +695,23 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
# Получаем IP
ip = request.client.host
# Проверяем лимиты в Redis
redis = Redis()
key = f'rate_limit:{ip}'
# Увеличиваем счетчик
count = redis.incr(key)
if count == 1:
redis.expire(key, 60) # TTL 60 секунд
# Проверяем лимит
if count > 100: # 100 запросов в минуту
return JSONResponse(
{'error': 'Too many requests'},
status_code=429
)
return await call_next(request)
```
@@ -722,11 +721,11 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
# auth/login.py
async def handle_login_attempt(user: Author, success: bool):
"""Обработка попытки входа"""
if not success:
# Увеличиваем счетчик неудачных попыток
user.increment_failed_login()
if user.is_locked():
# Аккаунт заблокирован
raise AuthError(
@@ -756,7 +755,7 @@ def log_auth_event(
):
"""
Логирование событий авторизации
Args:
event_type: Тип события (login, logout, etc)
user_id: ID пользователя
@@ -796,4 +795,4 @@ login_duration = Histogram(
'auth_login_duration_seconds',
'Time spent processing login'
)
```
```

View File

@@ -150,15 +150,15 @@ class CacheRevalidationManager:
def __init__(self, interval=CACHE_REVALIDATION_INTERVAL):
# ...
self._redis = redis # Прямая ссылка на сервис Redis
async def start(self):
# Проверка и установка соединения с Redis
# ...
async def process_revalidation(self):
# Обработка элементов для ревалидации
# ...
def mark_for_revalidation(self, entity_id, entity_type):
# Добавляет сущность в очередь на ревалидацию
# ...
@@ -213,14 +213,14 @@ async def precache_data():
async def get_topics_with_stats(limit=10, offset=0, by="title"):
# Формирование ключа кеша по конвенции
cache_key = f"topics:stats:limit={limit}:offset={offset}:sort={by}"
cached_data = await get_cached_data(cache_key)
if cached_data:
return cached_data
# Выполнение запроса к базе данных
result = ... # логика получения данных
await cache_data(cache_key, result, ttl=300)
return result
```
@@ -232,16 +232,16 @@ async def get_topics_with_stats(limit=10, offset=0, by="title"):
async def fetch_data(limit, offset, by):
# Логика получения данных
return result
# Формирование ключа кеша по конвенции
cache_key = f"topics:stats:limit={limit}:offset={offset}:sort={by}"
return await cached_query(
cache_key,
fetch_data,
ttl=300,
limit=limit,
offset=offset,
cache_key,
fetch_data,
ttl=300,
limit=limit,
offset=offset,
by=by
)
```
@@ -252,10 +252,10 @@ async def get_topics_with_stats(limit=10, offset=0, by="title"):
async def update_author(author_id, data):
# Обновление данных в базе
# ...
# Инвалидация только кеша этого автора
await invalidate_authors_cache(author_id)
return result
```

View File

@@ -150,7 +150,7 @@ const { data } = await client.query({
1. Для эффективной работы со сложными ветками обсуждений рекомендуется:
- Сначала загружать только корневые комментарии с первыми N ответами
- При наличии дополнительных ответов (когда `stat.comments_count > first_replies.length`)
- При наличии дополнительных ответов (когда `stat.comments_count > first_replies.length`)
добавить кнопку "Показать все ответы"
- При нажатии на кнопку загружать дополнительные ответы с помощью запроса с указанным `parentId`
@@ -162,4 +162,4 @@ const { data } = await client.query({
3. Для улучшения производительности:
- Кешировать результаты запросов на клиенте
- Использовать оптимистичные обновления при добавлении/редактировании комментариев
- При необходимости загружать комментарии порциями (ленивая загрузка)
- При необходимости загружать комментарии порциями (ленивая загрузка)

View File

@@ -2,7 +2,7 @@
- Интеграция с Google Analytics для отслеживания просмотров публикаций
- Подсчет уникальных пользователей и общего количества просмотров
- Автоматическое обновление статистики при запросе данных публикации
- Автоматическое обновление статистики при запросе данных публикации
## Мультидоменная авторизация
@@ -36,4 +36,4 @@
- Использование поля `stat.comments_count` для отображения количества ответов на комментарий
- Добавление специального поля `first_replies` для хранения первых ответов на комментарий
- Поддержка различных методов сортировки (новые, старые, популярные)
- Оптимизированные SQL запросы для минимизации нагрузки на базу данных
- Оптимизированные SQL запросы для минимизации нагрузки на базу данных

View File

@@ -137,7 +137,7 @@ if sub:
else:
return {"error": "following was not found", f"{entity_type}s": follows} # follows was []
# UNFOLLOW - After (FIXED)
# UNFOLLOW - After (FIXED)
if sub:
# ... process unfollow
# Invalidate cache
@@ -166,7 +166,7 @@ if existing_sub:
else:
# ... create subscription
# Always invalidate cache and get current state
# Always invalidate cache and get current state
await redis.execute("DEL", f"author:follows-{entity_type}s:{follower_id}")
existing_follows = await get_cached_follows_method(follower_id)
return {f"{entity_type}s": existing_follows, "error": error}
@@ -213,7 +213,7 @@ python test_unfollow_fix.py
### Test Coverage
- ✅ Unfollow existing subscription
- ✅ Unfollow non-existent subscription
- ✅ Unfollow non-existent subscription
- ✅ Cache invalidation
- ✅ Proper error handling
- ✅ UI state consistency
- ✅ UI state consistency

View File

@@ -77,4 +77,4 @@
- Проверка прав доступа
- Фильтрация удаленного контента
- Защита от SQL-инъекций
- Валидация входных данных
- Валидация входных данных

View File

@@ -40,7 +40,7 @@ CREATE TABLE oauth_links (
provider_data JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(provider, provider_id)
);
@@ -86,13 +86,13 @@ 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")
@@ -101,16 +101,16 @@ async def oauth_callback(provider: str, code: str, state: str):
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}"
@@ -196,4 +196,4 @@ tail -f /var/log/app/oauth.log | grep "oauth"
# Frontend логи (browser console)
# Фильтр: "[oauth]" или "[SessionProvider]"
```
```

View File

@@ -7,7 +7,7 @@
// 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
@@ -16,10 +16,10 @@ const oauth = (provider: string) => {
// Генерируем 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
}
@@ -29,7 +29,7 @@ const oauth = (provider: string) => {
```typescript
// Обработка OAuth параметров в SessionProvider
createEffect(
on([() => searchParams?.state, () => searchParams?.access_token, () => searchParams?.token],
on([() => searchParams?.state, () => searchParams?.access_token, () => searchParams?.token],
([state, access_token, token]) => {
// OAuth обработка
if (state && access_token) {
@@ -54,7 +54,7 @@ createEffect(
console.info('[SessionProvider] Processing password reset token')
changeSearchParams({ mode: 'change-password', m: 'auth', token }, { replace: true })
}
},
},
{ defer: true }
)
)
@@ -75,26 +75,26 @@ async def oauth_redirect(
):
"""
Инициация 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)
```
@@ -109,34 +109,34 @@ async def oauth_callback(
):
"""
Обработка 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)
@@ -196,32 +196,32 @@ class OAuthUser(BaseModel):
#### User Creation/Linking
```python
async def get_or_create_user_from_oauth(
provider: str,
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(
@@ -231,7 +231,7 @@ async def get_or_create_user_from_oauth(
provider_data=oauth_data.raw_data
)
return existing_user
# Создание нового пользователя
new_user = await User.create(
email=oauth_data.email,
@@ -241,7 +241,7 @@ async def get_or_create_user_from_oauth(
registration_method='oauth',
registration_provider=provider
)
# Создание OAuth связки
await OAuthLink.create(
user_id=new_user.id,
@@ -249,7 +249,7 @@ async def get_or_create_user_from_oauth(
provider_id=oauth_data.provider_id,
provider_data=oauth_data.raw_data
)
return new_user
```
@@ -263,8 +263,8 @@ from datetime import timedelta
redis_client = redis.Redis()
async def store_oauth_state(
state: str,
redirect_uri: str,
state: str,
redirect_uri: str,
ttl: timedelta = timedelta(minutes=10)
):
"""Сохранение OAuth state с TTL"""
@@ -298,7 +298,7 @@ def validate_redirect_uri(uri: str) -> bool:
"discours.io",
"new.discours.io"
]
parsed = urlparse(uri)
return any(domain in parsed.netloc for domain in allowed_domains)
```
@@ -315,7 +315,7 @@ CREATE TABLE oauth_links (
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)
@@ -330,7 +330,7 @@ CREATE TABLE oauth_links (
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
# Facebook OAuth
# Facebook OAuth
FACEBOOK_APP_ID=your_facebook_app_id
FACEBOOK_APP_SECRET=your_facebook_app_secret
@@ -389,7 +389,7 @@ def test_oauth_callback():
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"]
@@ -402,16 +402,16 @@ def test_oauth_callback():
// 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()
})
@@ -427,4 +427,4 @@ test('OAuth flow with Google', async ({ page }) => {
- [ ] Добавить rate limiting для OAuth endpoints
- [ ] Настроить мониторинг OAuth ошибок
- [ ] Протестировать все провайдеры в staging
- [ ] Добавить логирование OAuth событий
- [ ] Добавить логирование OAuth событий

123
docs/oauth-setup.md Normal file
View File

@@ -0,0 +1,123 @@
# OAuth Providers Setup Guide
This guide explains how to set up OAuth authentication for various social platforms.
## Supported Providers
The platform supports the following OAuth providers:
- Google
- GitHub
- Facebook
- X (Twitter)
- Telegram
- VK (VKontakte)
- Yandex
## Environment Variables
Add the following environment variables to your `.env` file:
```bash
# Google OAuth
OAUTH_CLIENTS_GOOGLE_ID=your_google_client_id
OAUTH_CLIENTS_GOOGLE_KEY=your_google_client_secret
# GitHub OAuth
OAUTH_CLIENTS_GITHUB_ID=your_github_client_id
OAUTH_CLIENTS_GITHUB_KEY=your_github_client_secret
# Facebook OAuth
OAUTH_CLIENTS_FACEBOOK_ID=your_facebook_app_id
OAUTH_CLIENTS_FACEBOOK_KEY=your_facebook_app_secret
# X (Twitter) OAuth
OAUTH_CLIENTS_X_ID=your_x_client_id
OAUTH_CLIENTS_X_KEY=your_x_client_secret
# Telegram OAuth
OAUTH_CLIENTS_TELEGRAM_ID=your_telegram_bot_token
OAUTH_CLIENTS_TELEGRAM_KEY=your_telegram_bot_secret
# VK OAuth
OAUTH_CLIENTS_VK_ID=your_vk_app_id
OAUTH_CLIENTS_VK_KEY=your_vk_secure_key
# Yandex OAuth
OAUTH_CLIENTS_YANDEX_ID=your_yandex_client_id
OAUTH_CLIENTS_YANDEX_KEY=your_yandex_client_secret
```
## Provider Setup Instructions
### Google
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new project or select existing
3. Enable Google+ API and OAuth 2.0
4. Create OAuth 2.0 Client ID credentials
5. Add your callback URLs: `https://yourdomain.com/oauth/google/callback`
### GitHub
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
2. Create a new OAuth App
3. Set Authorization callback URL: `https://yourdomain.com/oauth/github/callback`
### Facebook
1. Go to [Facebook Developers](https://developers.facebook.com/)
2. Create a new app
3. Add Facebook Login product
4. Configure Valid OAuth redirect URIs: `https://yourdomain.com/oauth/facebook/callback`
### X (Twitter)
1. Go to [Twitter Developer Portal](https://developer.twitter.com/)
2. Create a new app
3. Enable OAuth 2.0 authentication
4. Set Callback URLs: `https://yourdomain.com/oauth/x/callback`
5. **Note**: X doesn't provide email addresses through their API
### Telegram
1. Create a bot with [@BotFather](https://t.me/botfather)
2. Use `/newbot` command and follow instructions
3. Get your bot token
4. Configure domain settings with `/setdomain` command
5. **Note**: Telegram doesn't provide email addresses
### VK (VKontakte)
1. Go to [VK for Developers](https://vk.com/dev)
2. Create a new application
3. Set Authorized redirect URI: `https://yourdomain.com/oauth/vk/callback`
4. **Note**: Email access requires special permissions from VK
### Yandex
1. Go to [Yandex OAuth](https://oauth.yandex.com/)
2. Create a new application
3. Set Callback URI: `https://yourdomain.com/oauth/yandex/callback`
4. Select required permissions: `login:email login:info`
## Email Handling
Some providers (X, Telegram) don't provide email addresses. In these cases:
- A temporary email is generated: `{provider}_{user_id}@oauth.local`
- Users can update their email in profile settings later
- `email_verified` is set to `false` for generated emails
## Usage in Frontend
OAuth URLs:
```
/oauth/google
/oauth/github
/oauth/facebook
/oauth/x
/oauth/telegram
/oauth/vk
/oauth/yandex
```
Each provider accepts a `state` parameter for CSRF protection and a `redirect_uri` for post-authentication redirects.
## Security Notes
- All OAuth flows use PKCE (Proof Key for Code Exchange) for additional security
- State parameters are stored in Redis with 10-minute TTL
- OAuth sessions are one-time use only
- Failed authentications are logged for monitoring

329
docs/oauth.md Normal file
View File

@@ -0,0 +1,329 @@
# OAuth Token Management
## Overview
Система управления OAuth токенами с использованием Redis для безопасного и производительного хранения токенов доступа и обновления от различных провайдеров.
## Архитектура
### Redis Storage
OAuth токены хранятся в Redis с автоматическим истечением (TTL):
- `oauth_access:{user_id}:{provider}` - access tokens
- `oauth_refresh:{user_id}:{provider}` - refresh tokens
### Поддерживаемые провайдеры
- Google OAuth 2.0
- Facebook Login
- GitHub OAuth
## API Documentation
### OAuthTokenStorage Class
#### store_access_token()
Сохраняет access token в Redis с автоматическим TTL.
```python
await OAuthTokenStorage.store_access_token(
user_id=123,
provider="google",
access_token="ya29.a0AfH6SM...",
expires_in=3600,
additional_data={"scope": "profile email"}
)
```
#### store_refresh_token()
Сохраняет refresh token с длительным TTL (30 дней по умолчанию).
```python
await OAuthTokenStorage.store_refresh_token(
user_id=123,
provider="google",
refresh_token="1//04...",
ttl=2592000 # 30 дней
)
```
#### get_access_token()
Получает действующий access token из Redis.
```python
token_data = await OAuthTokenStorage.get_access_token(123, "google")
if token_data:
access_token = token_data["token"]
expires_in = token_data["expires_in"]
```
#### refresh_access_token()
Обновляет access token (и опционально refresh token).
```python
success = await OAuthTokenStorage.refresh_access_token(
user_id=123,
provider="google",
new_access_token="ya29.new_token...",
expires_in=3600,
new_refresh_token="1//04new..." # опционально
)
```
#### delete_tokens()
Удаляет все токены пользователя для провайдера.
```python
await OAuthTokenStorage.delete_tokens(123, "google")
```
#### get_user_providers()
Получает список OAuth провайдеров для пользователя.
```python
providers = await OAuthTokenStorage.get_user_providers(123)
# ["google", "github"]
```
#### extend_token_ttl()
Продлевает срок действия токена.
```python
# Продлить access token на 30 минут
success = await OAuthTokenStorage.extend_token_ttl(123, "google", "access", 1800)
# Продлить refresh token на 7 дней
success = await OAuthTokenStorage.extend_token_ttl(123, "google", "refresh", 604800)
```
#### get_token_info()
Получает подробную информацию о токенах включая TTL.
```python
info = await OAuthTokenStorage.get_token_info(123, "google")
# {
# "user_id": 123,
# "provider": "google",
# "access_token": {"exists": True, "ttl": 3245},
# "refresh_token": {"exists": True, "ttl": 2589600}
# }
```
## Data Structures
### Access Token Structure
```json
{
"token": "ya29.a0AfH6SM...",
"provider": "google",
"user_id": 123,
"created_at": 1640995200,
"expires_in": 3600,
"scope": "profile email",
"token_type": "Bearer"
}
```
### Refresh Token Structure
```json
{
"token": "1//04...",
"provider": "google",
"user_id": 123,
"created_at": 1640995200
}
```
## Security Considerations
### Token Expiration
- **Access tokens**: TTL основан на `expires_in` от провайдера (обычно 1 час)
- **Refresh tokens**: TTL 30 дней по умолчанию
- **Автоматическая очистка**: Redis автоматически удаляет истекшие токены
- **Внутренняя система истечения**: Использует SET + EXPIRE для точного контроля TTL
### Redis Expiration Benefits
- **Гибкость**: Можно изменять TTL существующих токенов через EXPIRE
- **Мониторинг**: Команда TTL показывает оставшееся время жизни токена
- **Расширение**: Возможность продления срока действия токенов без перезаписи
- **Атомарность**: Separate SET/EXPIRE operations для лучшего контроля
### Access Control
- Токены доступны только владельцу аккаунта
- Нет доступа к токенам через GraphQL API
- Токены не хранятся в основной базе данных
### Provider Isolation
- Токены разных провайдеров хранятся отдельно
- Удаление токенов одного провайдера не влияет на другие
- Поддержка множественных OAuth подключений
## Integration Examples
### OAuth Login Flow
```python
# После успешной авторизации через OAuth провайдера
async def handle_oauth_callback(user_id: int, provider: str, tokens: dict):
# Сохраняем токены в Redis
await OAuthTokenStorage.store_access_token(
user_id=user_id,
provider=provider,
access_token=tokens["access_token"],
expires_in=tokens.get("expires_in", 3600)
)
if "refresh_token" in tokens:
await OAuthTokenStorage.store_refresh_token(
user_id=user_id,
provider=provider,
refresh_token=tokens["refresh_token"]
)
```
### Token Refresh
```python
async def refresh_oauth_token(user_id: int, provider: str):
# Получаем refresh token
refresh_data = await OAuthTokenStorage.get_refresh_token(user_id, provider)
if not refresh_data:
return False
# Обмениваем refresh token на новый access token
new_tokens = await exchange_refresh_token(
provider, refresh_data["token"]
)
# Сохраняем новые токены
return await OAuthTokenStorage.refresh_access_token(
user_id=user_id,
provider=provider,
new_access_token=new_tokens["access_token"],
expires_in=new_tokens.get("expires_in"),
new_refresh_token=new_tokens.get("refresh_token")
)
```
### API Integration
```python
async def make_oauth_request(user_id: int, provider: str, endpoint: str):
# Получаем действующий access token
token_data = await OAuthTokenStorage.get_access_token(user_id, provider)
if not token_data:
# Токен отсутствует, требуется повторная авторизация
raise OAuthTokenMissing()
# Делаем запрос к API провайдера
headers = {"Authorization": f"Bearer {token_data['token']}"}
response = await httpx.get(endpoint, headers=headers)
if response.status_code == 401:
# Токен истек, пытаемся обновить
if await refresh_oauth_token(user_id, provider):
# Повторяем запрос с новым токеном
token_data = await OAuthTokenStorage.get_access_token(user_id, provider)
headers = {"Authorization": f"Bearer {token_data['token']}"}
response = await httpx.get(endpoint, headers=headers)
return response.json()
```
### TTL Monitoring and Management
```python
async def monitor_token_expiration(user_id: int, provider: str):
"""Мониторинг и управление сроком действия токенов"""
# Получаем информацию о токенах
info = await OAuthTokenStorage.get_token_info(user_id, provider)
# Проверяем access token
if info["access_token"]["exists"]:
ttl = info["access_token"]["ttl"]
if ttl < 300: # Меньше 5 минут
logger.warning(f"Access token expires soon: {ttl}s")
# Автоматически обновляем токен
await refresh_oauth_token(user_id, provider)
# Проверяем refresh token
if info["refresh_token"]["exists"]:
ttl = info["refresh_token"]["ttl"]
if ttl < 86400: # Меньше 1 дня
logger.warning(f"Refresh token expires soon: {ttl}s")
# Уведомляем пользователя о необходимости повторной авторизации
async def extend_session_if_active(user_id: int, provider: str):
"""Продлевает сессию для активных пользователей"""
# Проверяем активность пользователя
if await is_user_active(user_id):
# Продлеваем access token на 1 час
success = await OAuthTokenStorage.extend_token_ttl(
user_id, provider, "access", 3600
)
if success:
logger.info(f"Extended access token for active user {user_id}")
```
## Migration from Database
Если у вас уже есть OAuth токены в базе данных, используйте этот скрипт для миграции:
```python
async def migrate_oauth_tokens():
"""Миграция OAuth токенов из БД в Redis"""
with local_session() as session:
# Предполагая, что токены хранились в таблице authors
authors = session.query(Author).filter(
or_(
Author.provider_access_token.is_not(None),
Author.provider_refresh_token.is_not(None)
)
).all()
for author in authors:
# Получаем провайдер из oauth вместо старого поля oauth
if author.oauth:
for provider in author.oauth.keys():
if author.provider_access_token:
await OAuthTokenStorage.store_access_token(
user_id=author.id,
provider=provider,
access_token=author.provider_access_token
)
if author.provider_refresh_token:
await OAuthTokenStorage.store_refresh_token(
user_id=author.id,
provider=provider,
refresh_token=author.provider_refresh_token
)
print(f"Migrated OAuth tokens for {len(authors)} users")
```
## Performance Benefits
### Redis Advantages
- **Скорость**: Доступ к токенам за микросекунды
- **Масштабируемость**: Не нагружает основную БД
- **Автоматическая очистка**: TTL убирает истекшие токены
- **Память**: Эффективное использование памяти Redis
### Reduced Database Load
- OAuth токены больше не записываются в основную БД
- Уменьшено количество записей в таблице authors
- Faster user queries без JOIN к токенам
## Monitoring and Maintenance
### Redis Memory Usage
```bash
# Проверка использования памяти OAuth токенами
redis-cli --scan --pattern "oauth_*" | wc -l
redis-cli memory usage oauth_access:123:google
```
### Cleanup Statistics
```python
# Периодическая очистка и логирование (опционально)
async def oauth_cleanup_job():
cleaned = await OAuthTokenStorage.cleanup_expired_tokens()
logger.info(f"OAuth cleanup completed, {cleaned} tokens processed")
```

View File

@@ -52,7 +52,7 @@ Rate another author (karma system).
- Excludes deleted reactions
- Excludes comment reactions
#### Comments Rating
#### Comments Rating
- Calculated from LIKE/DISLIKE reactions on author's comments
- Each LIKE: +1
- Each DISLIKE: -1
@@ -79,4 +79,4 @@ Rate another author (karma system).
- All ratings exclude deleted content
- Reactions are unique per user/content
- Rating calculations are optimized with SQLAlchemy
- System supports both direct author rating and content-based rating
- System supports both direct author rating and content-based rating

212
docs/security.md Normal file
View File

@@ -0,0 +1,212 @@
# Security System
## Overview
Система безопасности обеспечивает управление паролями и email адресами пользователей через специализированные GraphQL мутации с использованием Redis для хранения токенов.
## GraphQL API
### Мутации
#### updateSecurity
Универсальная мутация для смены пароля и/или email пользователя с полной валидацией и безопасностью.
**Parameters:**
- `email: String` - Новый email (опционально)
- `old_password: String` - Текущий пароль (обязательно для любых изменений)
- `new_password: String` - Новый пароль (опционально)
**Returns:**
```typescript
type SecurityUpdateResult {
success: Boolean!
error: String
author: Author
}
```
**Примеры использования:**
```graphql
# Смена пароля
mutation {
updateSecurity(
old_password: "current123"
new_password: "newPassword456"
) {
success
error
author {
id
name
email
}
}
}
# Смена email
mutation {
updateSecurity(
email: "newemail@example.com"
old_password: "current123"
) {
success
error
author {
id
name
email
}
}
}
# Одновременная смена пароля и email
mutation {
updateSecurity(
email: "newemail@example.com"
old_password: "current123"
new_password: "newPassword456"
) {
success
error
author {
id
name
email
}
}
}
```
#### confirmEmailChange
Подтверждение смены email по токену, полученному на новый email адрес.
**Parameters:**
- `token: String!` - Токен подтверждения
**Returns:** `SecurityUpdateResult`
#### cancelEmailChange
Отмена процесса смены email.
**Returns:** `SecurityUpdateResult`
### Валидация и Ошибки
```typescript
const ERRORS = {
NOT_AUTHENTICATED: "User not authenticated",
INCORRECT_OLD_PASSWORD: "incorrect old password",
PASSWORDS_NOT_MATCH: "New passwords do not match",
EMAIL_ALREADY_EXISTS: "email already exists",
INVALID_EMAIL: "Invalid email format",
WEAK_PASSWORD: "Password too weak",
SAME_PASSWORD: "New password must be different from current",
VALIDATION_ERROR: "Validation failed",
INVALID_TOKEN: "Invalid token",
TOKEN_EXPIRED: "Token expired",
NO_PENDING_EMAIL: "No pending email change"
}
```
## Логика смены email
1. **Инициация смены:**
- Пользователь вызывает `updateSecurity` с новым email
- Генерируется токен подтверждения `token_urlsafe(32)`
- Данные смены email сохраняются в Redis с ключом `email_change:{user_id}`
- Устанавливается автоматическое истечение токена (1 час)
- Отправляется письмо на новый email с токеном
2. **Подтверждение:**
- Пользователь получает письмо с токеном
- Вызывает `confirmEmailChange` с токеном
- Система проверяет токен и срок действия в Redis
- Если токен валиден, email обновляется в базе данных
- Данные смены email удаляются из Redis
3. **Отмена:**
- Пользователь может отменить смену через `cancelEmailChange`
- Данные смены email удаляются из Redis
## Redis Storage
### Хранение токенов смены email
```json
{
"key": "email_change:{user_id}",
"value": {
"user_id": 123,
"old_email": "old@example.com",
"new_email": "new@example.com",
"token": "random_token_32_chars",
"expires_at": 1640995200
},
"ttl": 3600 // 1 час
}
```
### Хранение OAuth токенов
```json
{
"key": "oauth_access:{user_id}:{provider}",
"value": {
"token": "oauth_access_token",
"provider": "google",
"user_id": 123,
"created_at": 1640995200,
"expires_in": 3600,
"scope": "profile email"
},
"ttl": 3600 // время из expires_in или 1 час по умолчанию
}
```
```json
{
"key": "oauth_refresh:{user_id}:{provider}",
"value": {
"token": "oauth_refresh_token",
"provider": "google",
"user_id": 123,
"created_at": 1640995200
},
"ttl": 2592000 // 30 дней по умолчанию
}
```
### Преимущества Redis хранения
- **Автоматическое истечение**: TTL в Redis автоматически удаляет истекшие токены
- **Производительность**: Быстрый доступ к данным токенов
- **Масштабируемость**: Не нагружает основную базу данных
- **Безопасность**: Токены не хранятся в основной БД
- **Простота**: Не требует миграции схемы базы данных
- **OAuth токены**: Централизованное управление токенами всех OAuth провайдеров
## Безопасность
### Требования к паролю
- Минимум 8 символов
- Не может совпадать с текущим паролем
### Аутентификация
- Все операции требуют валидного токена аутентификации
- Старый пароль обязателен для подтверждения личности
### Валидация email
- Проверка формата email через регулярное выражение
- Проверка уникальности email в системе
- Защита от race conditions при смене email
### Токены безопасности
- Генерация токенов через `secrets.token_urlsafe(32)`
- Автоматическое истечение через 1 час
- Удаление токенов после использования или отмены
## Database Schema
Система не требует изменений в схеме базы данных. Все токены и временные данные хранятся в Redis.
### Защищенные поля
Следующие поля показываются только владельцу аккаунта:
- `email`
- `password`