Files
core/docs/auth.md
Untone 1b48675b92
Some checks failed
Deploy on push / deploy (push) Failing after 2m22s
[0.9.7] - 2025-08-18
### 🔄 Изменения
- **SQLAlchemy KeyError** - исправление ошибки `KeyError: Reaction` при инициализации
- **Исправлена ошибка SQLAlchemy**: Устранена проблема `InvalidRequestError: When initializing mapper Mapper[Shout(shout)], expression Reaction failed to locate a name (Reaction)`

### 🧪 Тестирование
- **Исправление тестов** - адаптация к новой структуре моделей
- **RBAC инициализация** - добавление `rbac.initialize_rbac()` в `conftest.py`
- **Создан тест для getSession**: Добавлен комплексный тест `test_getSession_cookies.py` с проверкой всех сценариев
- **Покрытие edge cases**: Тесты проверяют работу с валидными/невалидными токенами, отсутствующими пользователями
- **Мокирование зависимостей**: Использование unittest.mock для изоляции тестируемого кода

### 🔧 Рефакторинг
- **Упрощена архитектура**: Убраны сложные конструкции с отложенными импортами, заменены на чистую архитектуру
- **Перемещение моделей** - `Author` и связанные модели перенесены в `orm/author.py`: Вынесены базовые модели пользователей (`Author`, `AuthorFollower`, `AuthorBookmark`, `AuthorRating`) из `orm.author` в отдельный модуль
- **Устранены циклические импорты**: Разорван цикл между `auth.core` → `orm.community` → `orm.author` через реструктуризацию архитектуры
- **Создан модуль `utils/password.py`**: Класс `Password` вынесен в utils для избежания циклических зависимостей
- **Оптимизированы импорты моделей**: Убран прямой импорт `Shout` из `orm/community.py`, заменен на строковые ссылки

### 🔧 Авторизация с cookies
- **getSession теперь работает с cookies**: Мутация `getSession` теперь может получать токен из httpOnly cookies даже без заголовка Authorization
- **Убрано требование авторизации**: `getSession` больше не требует декоратор `@login_required`, работает автономно
- **Поддержка dual-авторизации**: Токен может быть получен как из заголовка Authorization, так и из cookie `session_token`
- **Автоматическая установка cookies**: Middleware автоматически устанавливает httpOnly cookies при успешном `getSession`
- **Обновлена GraphQL схема**: `SessionInfo` теперь содержит поля `success`, `error` и опциональные `token`, `author`
- **Единообразная обработка токенов**: Все модули теперь используют централизованные функции для работы с токенами
- **Улучшена обработка ошибок**: Добавлена детальная валидация токенов и пользователей в `getSession`
- **Логирование операций**: Добавлены подробные логи для отслеживания процесса авторизации

### 📝 Документация
- **Обновлена схема GraphQL**: `SessionInfo` тип теперь соответствует новому формату ответа
- Обновлена документация RBAC
- Обновлена документация авторизации с cookies
2025-08-18 14:25:25 +03:00

24 KiB
Raw Blame History

Модуль аутентификации и авторизации

Общее описание

Модуль реализует полноценную систему аутентификации с использованием локальной БД, Redis и httpOnly cookies для безопасного хранения токенов сессий.

Архитектура системы

Основные компоненты

1. AuthMiddleware (auth/middleware.py)

  • Единый middleware для обработки авторизации в GraphQL запросах
  • Извлечение Bearer токена из заголовка Authorization или httpOnly cookie
  • Проверка сессии через TokenStorage
  • Создание request.user и request.auth
  • Предоставление методов для установки/удаления cookies

2. EnhancedGraphQLHTTPHandler (auth/handler.py)

  • Расширенный GraphQL HTTP обработчик с поддержкой cookie и авторизации
  • Создание расширенного контекста запроса с авторизационными данными
  • Корректная обработка ответов с cookie и headers
  • Интеграция с AuthMiddleware

3. TokenStorage (auth/tokens/storage.py)

  • Централизованное управление токенами сессий
  • Хранение в Redis с TTL
  • Верификация и валидация токенов
  • Управление жизненным циклом сессий

4. AuthCredentials (auth/credentials.py)

  • Модель данных для хранения информации об авторизации
  • Содержит author_id, scopes, logged_in, error_message, email, token

Модели данных

Author (orm/author.py)

  • Основная модель пользователя с расширенным функционалом аутентификации
  • Поддерживает:
    • Локальную аутентификацию по email/телефону
    • Систему ролей и разрешений (RBAC)
    • Блокировку аккаунта при множественных неудачных попытках входа
    • Верификацию email/телефона

Система httpOnly Cookies

Принципы работы

  1. Безопасное хранение: Токены сессий хранятся в httpOnly cookies, недоступных для JavaScript
  2. Автоматическая отправка: Cookies автоматически отправляются с каждым запросом
  3. Защита от XSS: httpOnly cookies защищены от кражи через JavaScript
  4. Двойная поддержка: Система поддерживает как cookies, так и заголовок Authorization

Конфигурация cookies

# settings.py
SESSION_COOKIE_NAME = "session_token"
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SECURE = True  # для HTTPS
SESSION_COOKIE_SAMESITE = "lax"
SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60  # 30 дней

Установка cookies

# В AuthMiddleware
def set_session_cookie(self, response: Response, token: str) -> None:
    """Устанавливает httpOnly cookie с токеном сессии"""
    response.set_cookie(
        key=SESSION_COOKIE_NAME,
        value=token,
        httponly=SESSION_COOKIE_HTTPONLY,
        secure=SESSION_COOKIE_SECURE,
        samesite=SESSION_COOKIE_SAMESITE,
        max_age=SESSION_COOKIE_MAX_AGE
    )

Аутентификация

Извлечение токенов

Система проверяет токены в следующем порядке приоритета:

  1. httpOnly cookies - основной источник для веб-приложений
  2. Заголовок Authorization - для API клиентов и мобильных приложений
# auth/utils.py
async def extract_token_from_request(request) -> str | None:
    """DRY функция для извлечения токена из request"""
    
    # 1. Проверяем cookies
    if hasattr(request, "cookies") and request.cookies:
        token = request.cookies.get(SESSION_COOKIE_NAME)
        if token:
            return token

    # 2. Проверяем заголовок Authorization
    headers = get_safe_headers(request)
    auth_header = headers.get("authorization", "")
    if auth_header and auth_header.startswith("Bearer "):
        token = auth_header[7:].strip()
        return token

    return None

Безопасное получение заголовков

# auth/utils.py
def get_safe_headers(request: Any) -> dict[str, str]:
    """Безопасно получает заголовки запроса"""
    headers = {}
    try:
        # Первый приоритет: scope из ASGI
        if hasattr(request, "scope") and isinstance(request.scope, dict):
            scope_headers = request.scope.get("headers", [])
            if scope_headers:
                headers.update({k.decode("utf-8").lower(): v.decode("utf-8") 
                              for k, v in scope_headers})

        # Второй приоритет: метод headers() или атрибут headers
        if hasattr(request, "headers"):
            if callable(request.headers):
                h = request.headers()
                if h:
                    headers.update({k.lower(): v for k, v in h.items()})
            else:
                h = request.headers
                if hasattr(h, "items") and callable(h.items):
                    headers.update({k.lower(): v for k, v in h.items()})

    except Exception as e:
        logger.warning(f"Ошибка при доступе к заголовкам: {e}")

    return headers

Управление сессиями

Создание сессии

# auth/tokens/sessions.py
async def create_session(author_id: int, email: str, **kwargs) -> str:
    """Создает новую сессию для пользователя"""
    session_data = {
        "author_id": author_id,
        "email": email,
        "created_at": int(time.time()),
        **kwargs
    }
    
    # Генерируем уникальный токен
    token = generate_session_token()
    
    # Сохраняем в Redis
    await redis.execute(
        "SETEX",
        f"session:{token}",
        SESSION_TOKEN_LIFE_SPAN,
        json.dumps(session_data)
    )
    
    return token

Верификация сессии

# auth/tokens/storage.py
async def verify_session(token: str) -> dict | None:
    """Верифицирует токен сессии"""
    if not token:
        return None
        
    try:
        # Получаем данные сессии из Redis
        session_data = await redis.execute("GET", f"session:{token}")
        if not session_data:
            return None
            
        return json.loads(session_data)
        
    except Exception as e:
        logger.error(f"Ошибка верификации сессии: {e}")
        return None

Удаление сессии

# auth/tokens/storage.py
async def delete_session(token: str) -> bool:
    """Удаляет сессию пользователя"""
    try:
        result = await redis.execute("DEL", f"session:{token}")
        return bool(result)
    except Exception as e:
        logger.error(f"Ошибка удаления сессии: {e}")
        return False

OAuth интеграция

Поддерживаемые провайдеры

  • Google - OAuth 2.0 с PKCE
  • Facebook - OAuth 2.0
  • GitHub - OAuth 2.0

Реализация

# auth/oauth.py
class OAuthProvider:
    """Базовый класс для OAuth провайдеров"""
    
    def __init__(self, client_id: str, client_secret: str, redirect_uri: str):
        self.client_id = client_id
        self.client_secret = client_secret
        self.redirect_uri = redirect_uri
    
    async def get_authorization_url(self, state: str = None) -> str:
        """Генерирует URL для авторизации"""
        pass
    
    async def exchange_code_for_token(self, code: str) -> dict:
        """Обменивает код авторизации на токен доступа"""
        pass
    
    async def get_user_info(self, access_token: str) -> dict:
        """Получает информацию о пользователе"""
        pass

Валидация

Модели валидации

# auth/validations.py
from pydantic import BaseModel, EmailStr

class LoginRequest(BaseModel):
    email: EmailStr
    password: str

class RegisterRequest(BaseModel):
    email: EmailStr
    password: str
    name: str
    phone: str | None = None

class PasswordResetRequest(BaseModel):
    email: EmailStr

class EmailConfirmationRequest(BaseModel):
    token: str

API Endpoints

GraphQL мутации

# Мутации аутентификации
mutation Login($email: String!, $password: String!) {
    login(email: $email, password: $password) {
        success
        token
        user {
            id
            email
            name
        }
        error
    }
}

mutation Register($input: RegisterInput!) {
    registerUser(input: $input) {
        success
        user {
            id
            email
            name
        }
        error
    }
}

mutation Logout {
    logout {
        success
        message
    }
}

# Получение текущей сессии
query GetSession {
    getSession {
        success
        token
        user {
            id
            email
            name
            roles
        }
        error
    }
}

REST API endpoints

# Основные endpoints
POST /auth/login          # Вход в систему
POST /auth/register       # Регистрация
POST /auth/logout         # Выход из системы
GET  /auth/session        # Получение текущей сессии
POST /auth/refresh        # Обновление токена

# OAuth endpoints
GET  /auth/oauth/{provider}           # Инициация OAuth
GET  /auth/oauth/{provider}/callback  # OAuth callback

Безопасность

Хеширование паролей

# auth/identity.py
from passlib.context import CryptContext

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

def hash_password(password: str) -> str:
    """Хеширует пароль с использованием bcrypt"""
    return pwd_context.hash(password)

def verify_password(plain_password: str, hashed_password: str) -> bool:
    """Проверяет пароль"""
    return pwd_context.verify(plain_password, hashed_password)

Защита от брутфорса

# auth/core.py
async def handle_login_attempt(author: Author, success: bool) -> None:
    """Обрабатывает попытку входа"""
    if not success:
        # Увеличиваем счетчик неудачных попыток
        author.failed_login_attempts += 1
        
        if author.failed_login_attempts >= 5:
            # Блокируем аккаунт на 30 минут
            author.account_locked_until = int(time.time()) + 1800
            logger.warning(f"Аккаунт {author.email} заблокирован")
    else:
        # Сбрасываем счетчик при успешном входе
        author.failed_login_attempts = 0
        author.account_locked_until = None

CSRF защита

# auth/middleware.py
def generate_csrf_token() -> str:
    """Генерирует CSRF токен"""
    return secrets.token_urlsafe(32)

def verify_csrf_token(token: str, stored_token: str) -> bool:
    """Проверяет CSRF токен"""
    return secrets.compare_digest(token, stored_token)

Декораторы

Основные декораторы

# auth/decorators.py
from functools import wraps
from graphql import GraphQLError

def login_required(func):
    """Декоратор для проверки авторизации"""
    @wraps(func)
    async def wrapper(*args, **kwargs):
        info = args[-1] if args else None
        if not info or not hasattr(info, 'context'):
            raise GraphQLError("Context not available")
            
        user = info.context.get('user')
        if not user or not user.is_authenticated:
            raise GraphQLError("Authentication required")
            
        return await func(*args, **kwargs)
    return wrapper

def require_permission(permission: str):
    """Декоратор для проверки разрешений"""
    def decorator(func):
        @wraps(func)
        async def wrapper(*args, **kwargs):
            info = args[-1] if args else None
            if not info or not hasattr(info, 'context'):
                raise GraphQLError("Context not available")
                
            user = info.context.get('user')
            if not user or not user.is_authenticated:
                raise GraphQLError("Authentication required")
                
            # Проверяем разрешение через RBAC
            has_perm = await check_user_permission(
                user.id, permission, info.context.get('community_id', 1)
            )
            
            if not has_perm:
                raise GraphQLError("Insufficient permissions")
                
            return await func(*args, **kwargs)
        return wrapper
    return decorator

Интеграция с RBAC

Проверка разрешений

# auth/decorators.py
async def check_user_permission(author_id: int, permission: str, community_id: int) -> bool:
    """Проверяет разрешение пользователя через RBAC систему"""
    try:
        from rbac.api import user_has_permission
        return await user_has_permission(author_id, permission, community_id)
    except Exception as e:
        logger.error(f"Ошибка проверки разрешений: {e}")
        return False

Получение ролей пользователя

# auth/middleware.py
async def get_user_roles(author_id: int, community_id: int = 1) -> list[str]:
    """Получает роли пользователя в сообществе"""
    try:
        from rbac.api import get_user_roles_in_community
        return get_user_roles_in_community(author_id, community_id)
    except Exception as e:
        logger.error(f"Ошибка получения ролей: {e}")
        return []

Мониторинг и логирование

Логирование событий

# auth/middleware.py
def log_auth_event(event_type: str, user_id: int | None = None, 
                   success: bool = True, **kwargs):
    """Логирует события авторизации"""
    logger.info(
        "auth_event",
        event_type=event_type,
        user_id=user_id,
        success=success,
        ip_address=kwargs.get('ip'),
        user_agent=kwargs.get('user_agent'),
        **kwargs
    )

Метрики

# auth/middleware.py
from prometheus_client import Counter, Histogram

# Счетчики
login_attempts = Counter('auth_login_attempts_total', 'Number of login attempts', ['success'])
session_creations = Counter('auth_sessions_created_total', 'Number of sessions created')
session_deletions = Counter('auth_sessions_deleted_total', 'Number of sessions deleted')

# Гистограммы
auth_duration = Histogram('auth_operation_duration_seconds', 'Time spent on auth operations', ['operation'])

Конфигурация

Основные настройки

# settings.py

# Настройки сессий
SESSION_TOKEN_LIFE_SPAN = 30 * 24 * 60 * 60  # 30 дней
SESSION_COOKIE_NAME = "session_token"
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SECURE = True  # для HTTPS
SESSION_COOKIE_SAMESITE = "lax"
SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60

# JWT настройки
JWT_SECRET_KEY = "your-secret-key"
JWT_ALGORITHM = "HS256"
JWT_EXPIRATION_DELTA = 30 * 24 * 60 * 60

# OAuth настройки
GOOGLE_CLIENT_ID = "your-google-client-id"
GOOGLE_CLIENT_SECRET = "your-google-client-secret"
FACEBOOK_CLIENT_ID = "your-facebook-client-id"
FACEBOOK_CLIENT_SECRET = "your-facebook-client-secret"

# Безопасность
MAX_LOGIN_ATTEMPTS = 5
ACCOUNT_LOCKOUT_DURATION = 1800  # 30 минут
PASSWORD_MIN_LENGTH = 8

Примеры использования

1. Вход в систему

// Frontend - React/SolidJS
const handleLogin = async (email: string, password: string) => {
  try {
    const response = await fetch('/auth/login', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ email, password }),
      credentials: 'include', // Важно для cookies
    });

    if (response.ok) {
      const data = await response.json();
      // Cookie автоматически установится браузером
      // Перенаправляем на главную страницу
      window.location.href = '/';
    } else {
      const error = await response.json();
      console.error('Login failed:', error.message);
    }
  } catch (error) {
    console.error('Login error:', error);
  }
};

2. Проверка авторизации

// Frontend - проверка текущей сессии
const checkAuth = async () => {
  try {
    const response = await fetch('/auth/session', {
      credentials: 'include',
    });

    if (response.ok) {
      const data = await response.json();
      if (data.user) {
        // Пользователь авторизован
        setUser(data.user);
        setIsAuthenticated(true);
      }
    }
  } catch (error) {
    console.error('Auth check failed:', error);
  }
};

3. Защищенный API endpoint

# Backend - Python
from auth.decorators import login_required, require_permission

@login_required
@require_permission("shout:create")
async def create_shout(info, input_data):
    """Создание публикации с проверкой прав"""
    user = info.context.get('user')
    
    # Создаем публикацию
    shout = Shout(
        title=input_data['title'],
        content=input_data['content'],
        author_id=user.id
    )
    
    db.add(shout)
    db.commit()
    
    return shout

4. OAuth авторизация

// Frontend - OAuth кнопка
const handleGoogleLogin = () => {
  // Перенаправляем на OAuth endpoint
  window.location.href = '/auth/oauth/google';
};

// Обработка OAuth callback
useEffect(() => {
  const urlParams = new URLSearchParams(window.location.search);
  const code = urlParams.get('code');
  const state = urlParams.get('state');
  
  if (code && state) {
    // Обмениваем код на токен
    exchangeOAuthCode(code, state);
  }
}, []);

5. Выход из системы

// Frontend - выход
const handleLogout = async () => {
  try {
    await fetch('/auth/logout', {
      method: 'POST',
      credentials: 'include',
    });
    
    // Очищаем локальное состояние
    setUser(null);
    setIsAuthenticated(false);
    
    // Перенаправляем на страницу входа
    window.location.href = '/login';
  } catch (error) {
    console.error('Logout failed:', error);
  }
};

Тестирование

Тесты аутентификации

# tests/test_auth.py
import pytest
from httpx import AsyncClient

@pytest.mark.asyncio
async def test_login_success(client: AsyncClient):
    """Тест успешного входа"""
    response = await client.post("/auth/login", json={
        "email": "test@example.com",
        "password": "password123"
    })
    
    assert response.status_code == 200
    data = response.json()
    assert data["success"] is True
    assert "token" in data
    
    # Проверяем установку cookie
    cookies = response.cookies
    assert "session_token" in cookies

@pytest.mark.asyncio
async def test_protected_endpoint_with_cookie(client: AsyncClient):
    """Тест защищенного endpoint с cookie"""
    # Сначала входим в систему
    login_response = await client.post("/auth/login", json={
        "email": "test@example.com",
        "password": "password123"
    })
    
    # Получаем cookie
    session_cookie = login_response.cookies.get("session_token")
    
    # Делаем запрос к защищенному endpoint
    response = await client.get("/auth/session", cookies={
        "session_token": session_cookie
    })
    
    assert response.status_code == 200
    data = response.json()
    assert data["user"]["email"] == "test@example.com"

Тесты OAuth

# tests/test_oauth.py
@pytest.mark.asyncio
async def test_google_oauth_flow(client: AsyncClient, mock_google):
    """Тест OAuth flow для Google"""
    # Мокаем ответ от Google
    mock_google.return_value = {
        "id": "12345",
        "email": "test@gmail.com",
        "name": "Test User"
    }
    
    # Инициация OAuth
    response = await client.get("/auth/oauth/google")
    assert response.status_code == 302
    
    # Проверяем редирект
    assert "accounts.google.com" in response.headers["location"]

Безопасность

Лучшие практики

  1. httpOnly Cookies: Токены сессий хранятся только в httpOnly cookies
  2. HTTPS: Все endpoints должны работать через HTTPS в продакшене
  3. SameSite: Используется SameSite=lax для защиты от CSRF
  4. Rate Limiting: Ограничение количества попыток входа
  5. Логирование: Детальное логирование всех событий авторизации
  6. Валидация: Строгая валидация всех входных данных

Защита от атак

  • XSS: httpOnly cookies недоступны для JavaScript
  • CSRF: SameSite cookies и CSRF токены
  • Session Hijacking: Secure cookies и регулярная ротация токенов
  • Brute Force: Ограничение попыток входа и блокировка аккаунтов
  • SQL Injection: Использование ORM и параметризованных запросов

Миграция

Обновление существующего кода

Если в вашем коде используются старые методы аутентификации:

# Старый код
token = request.headers.get("Authorization")

# Новый код
from auth.utils import extract_token_from_request
token = await extract_token_from_request(request)

Совместимость

Новая система полностью совместима с существующим кодом:

  • Поддерживаются как cookies, так и заголовки Authorization
  • Все существующие декораторы работают без изменений
  • API endpoints сохранили свои сигнатуры
  • RBAC интеграция работает как прежде