core/docs/auth.md
Untone eb2140bcc6
All checks were successful
Deploy on push / deploy (push) Successful in 6s
0.7.7-topics-editing
2025-07-03 12:15:10 +03:00

29 KiB
Raw Blame History

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

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

Модуль реализует полноценную систему аутентификации с использованием локальной БД и Redis.

Компоненты

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

Author (orm.py)

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

Role и Permission (resolvers/rbac.py)

  • Реализация RBAC (Role-Based Access Control)
  • Роли содержат наборы разрешений
  • Разрешения определяются как пары resource:operation

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

Внутренняя аутентификация

  • Проверка токена в Redis
  • Получение данных пользователя из локальной БД
  • Проверка статуса аккаунта и разрешений

Управление сессиями (sessions.py)

  • Хранение сессий в Redis
  • Поддержка:
    • Создание сессий
    • Верификация
    • Отзыв отдельных сессий
    • Отзыв всех сессий пользователя
  • Автоматическое удаление истекших сессий

JWT токены (jwtcodec.py)

  • Кодирование/декодирование JWT токенов
  • Проверка:
    • Срока действия
    • Подписи
    • Издателя
  • Поддержка пользовательских claims

OAuth интеграция (oauth.py)

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

  • Google
  • Facebook
  • GitHub

Функционал:

  • Авторизация через OAuth провайдеров
  • Получение профиля пользователя
  • Создание/обновление локального профиля

Валидация (validations.py)

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

  • Регистрации пользователей
  • Входа в систему
  • OAuth данных
  • JWT payload
  • Ответов API

Email функционал (email.py)

  • Отправка писем через Mailgun
  • Поддержка шаблонов
  • Мультиязычность (ru/en)
  • Подтверждение email
  • Сброс пароля

API Endpoints (resolvers.py)

Мутации

  • login - вход в систему
  • getSession - получение текущей сессии
  • confirmEmail - подтверждение email
  • registerUser - регистрация пользователя
  • sendLink - отправка ссылки для входа

Запросы

  • logout - выход из системы
  • isEmailUsed - проверка использования email

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

Хеширование паролей (identity.py)

  • Использование bcrypt с SHA-256
  • Настраиваемое количество раундов
  • Защита от timing-атак

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

  • Блокировка аккаунта после 5 неудачных попыток
  • Время блокировки: 30 минут
  • Сброс счетчика после успешного входа

Обработка заголовков авторизации

Особенности работы с заголовками в Starlette

При работе с заголовками в Starlette/FastAPI необходимо учитывать следующие особенности:

  1. Регистр заголовков: Заголовки в объекте Request чувствительны к регистру. Для надежного получения заголовка Authorization следует использовать регистронезависимый поиск.

  2. Формат Bearer токена: Токен может приходить как с префиксом Bearer , так и без него. Необходимо обрабатывать оба варианта.

Правильное получение заголовка авторизации

# Получение заголовка с учетом регистра
headers_dict = dict(req.headers.items())
token = None

# Ищем заголовок независимо от регистра
for header_name, header_value in headers_dict.items():
    if header_name.lower() == SESSION_TOKEN_HEADER.lower():
        token = header_value
        break

# Обработка Bearer префикса
if token and token.startswith("Bearer "):
    token = token.split("Bearer ")[1].strip()

Распространенные проблемы и их решения

  1. Проблема: Заголовок не находится при прямом обращении req.headers.get("Authorization") Решение: Использовать регистронезависимый поиск по всем заголовкам

  2. Проблема: Токен приходит с префиксом "Bearer" в одних запросах и без него в других Решение: Всегда проверять и обрабатывать оба варианта

  3. Проблема: Токен декодируется, но сессия не находится в Redis Решение: Проверить формирование ключа сессии и добавить автоматическое создание сессии для валидных токенов

  4. Проблема: Ошибки при декодировании JWT вызывают исключения Решение: Обернуть декодирование в try-except и возвращать None вместо вызова исключений

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

Основные настройки в settings.py:

  • SESSION_TOKEN_LIFE_SPAN - время жизни сессии
  • ONETIME_TOKEN_LIFE_SPAN - время жизни одноразовых токенов
  • JWT_SECRET_KEY - секретный ключ для JWT
  • JWT_ALGORITHM - алгоритм подписи JWT

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

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

# Проверка авторизации
user_id, roles = await check_auth(request)

# Добавление роли
await add_user_role(user_id, ["author"])

# Создание сессии
token = await create_local_session(author)

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

# Инициация OAuth процесса
await oauth_login(request)

# Обработка callback
response = await oauth_authorize(request)

1. Базовая авторизация на фронтенде

// pages/Login.tsx
// Предполагается, что AuthClient и createAuth импортированы корректно
// import { AuthClient } from '../auth/AuthClient'; // Путь может отличаться
// import { createAuth } from '../auth/useAuth';   // Путь может отличаться
import { Component, Show } from 'solid-js'; // Show для условного рендеринга

export const LoginPage: Component = () => {
  // Клиент и хук авторизации (пример из client/auth/useAuth.ts)
  // const authClient = new AuthClient(/* baseUrl or other config */);
  // const auth = createAuth(authClient);
  // Для простоты примера, предположим, что auth уже доступен через контекст или пропсы
  // В реальном приложении используйте useAuthContext() если он настроен
  const { store, login } = useAuthContext(); // Пример, если используется контекст

  const handleSubmit = async (event: SubmitEvent) => {
    event.preventDefault();
    const form = event.currentTarget as HTMLFormElement;
    const emailInput = form.elements.namedItem('email') as HTMLInputElement;
    const passwordInput = form.elements.namedItem('password') as HTMLInputElement;

    if (!emailInput || !passwordInput) {
      console.error("Email or password input not found");
      return;
    }

    const success = await login({
      email: emailInput.value,
      password: passwordInput.value
    });

    if (success) {
      console.log('Login successful, redirecting...');
      // window.location.href = '/'; // Раскомментируйте для реального редиректа
    } else {
      // Ошибка уже должна быть в store().error, обработанная в useAuth
      console.error('Login failed:', store().error);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label for="email">Email:</label>
        <input id="email" name="email" type="email" required />
      </div>
      <div>
        <label for="password">Пароль:</label>
        <input id="password" name="password" type="password" required />
      </div>
      <button type="submit" disabled={store().isLoading}>
        {store().isLoading ? 'Вход...' : 'Войти'}
      </button>
      <Show when={store().error}>
        <p style={{ color: 'red' }}>{store().error}</p>
      </Show>
    </form>
  );
}

2. Защита компонента с помощью ролей

// components/AdminPanel.tsx
import { useAuthContext } from '../auth'

export const AdminPanel: Component = () => {
  const auth = useAuthContext()

  // Проверяем наличие роли админа
  if (!auth.hasRole('admin')) {
    return <div>Доступ запрещен</div>
  }

  return (
    <div>
      {/* Контент админки */}
    </div>
  )
}

3. OAuth авторизация через Google

// components/GoogleLoginButton.tsx
import { Component } from 'solid-js';

export const GoogleLoginButton: Component = () => {
  const handleGoogleLogin = () => {
    // Предполагается, что API_BASE_URL настроен глобально или импортирован
    // const API_BASE_URL = 'http://localhost:8000'; // Пример
    // window.location.href = `${API_BASE_URL}/auth/login/google`;
    // Или если пути относительные и сервер на том же домене:
    window.location.href = '/auth/login/google';
  };

  return (
    <button onClick={handleGoogleLogin}>
      Войти через Google
    </button>
  );
}

4. Работа с пользователем на бэкенде

# routes/articles.py
# Предполагаемые импорты:
# from starlette.requests import Request
# from starlette.responses import JSONResponse
# from sqlalchemy.orm import Session
# from ..dependencies import get_db_session # Пример получения сессии БД
# from ..auth.decorators import login_required # Ваш декоратор
# from ..auth.orm import Author # Модель пользователя
# from ..models.article import Article # Модель статьи (пример)

# @login_required # Декоратор проверяет аутентификацию и добавляет user в request
async def create_article_example(request: Request): # Используем Request из Starlette
    """
    Пример создания статьи с проверкой прав.
    В реальном приложении используйте DI для сессии БД (например, FastAPI Depends).
    """
    user: Author = request.user # request.user добавляется декоратором @login_required

    # Проверяем право на создание статей (метод из модели auth.auth.orm)
    if not await user.has_permission('shout:create'):
        return JSONResponse({'error': 'Недостаточно прав для создания статьи'}, status_code=403)

    try:
        article_data = await request.json()
        title = article_data.get('title')
        content = article_data.get('content')

        if not title or not content:
            return JSONResponse({'error': 'Title and content are required'}, status_code=400)

    except ValueError: # Если JSON некорректен
        return JSONResponse({'error': 'Invalid JSON data'}, status_code=400)

    # Пример работы с БД. В реальном приложении сессия db будет получена через DI.
    # Здесь db - это заглушка, замените на вашу реальную логику работы с БД.
    # Пример:
    # with get_db_session() as db: # Получение сессии SQLAlchemy
    #     new_article = Article(
    #         title=title,
    #         content=content,
    #         author_id=user.id # Связываем статью с автором
    #     )
    #     db.add(new_article)
    #     db.commit()
    #     db.refresh(new_article)
    #     return JSONResponse({'id': new_article.id, 'title': new_article.title}, status_code=201)

    # Заглушка для примера в документации
    mock_article_id = 123
    print(f"User {user.id} ({user.email}) is creating article '{title}'.")
    return JSONResponse({'id': mock_article_id, 'title': title}, status_code=201)

5. Проверка прав в GraphQL резолверах

# resolvers/mutations.py
from auth.decorators import login_required
from auth.models import Author

@login_required
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 await 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

6. Создание пользователя с ролями

# scripts/create_admin.py
from auth.models import Author, Role
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),
        email_verified=True
    )

    # Назначаем роль
    admin.roles.append(admin_role)

    # Сохраняем
    db.add(admin)
    db.commit()

    return admin

7. Работа с сессиями

# auth/session_management.py (примерное название файла)
# Предполагаемые импорты:
# from starlette.responses import RedirectResponse
# from starlette.requests import Request
# from ..auth.orm import Author # Модель пользователя
# from ..auth.token import TokenStorage # Ваш модуль для работы с токенами
# from ..settings import SESSION_COOKIE_MAX_AGE, SESSION_COOKIE_NAME, SESSION_COOKIE_SECURE, SESSION_COOKIE_HTTPONLY, SESSION_COOKIE_SAMESITE

# Замените FRONTEND_URL_AUTH_SUCCESS и FRONTEND_URL_LOGOUT на реальные URL из настроек
FRONTEND_URL_AUTH_SUCCESS = "/auth/success" # Пример
FRONTEND_URL_LOGOUT = "/logout" # Пример


async def login_user_session(request: Request, user: Author, response_class=RedirectResponse):
    """
    Создание сессии пользователя и установка cookie.
    """
    if not hasattr(user, 'id'): # Проверка наличия id у пользователя
        raise ValueError("User object must have an id attribute")

    # Создаем токен сессии (TokenStorage из вашего модуля auth.token)
    session_token = TokenStorage.create_session(str(user.id)) # ID пользователя обычно число, приводим к строке если нужно

    # Устанавливаем cookie
    # В реальном приложении FRONTEND_URL_AUTH_SUCCESS должен вести на страницу вашего фронтенда
    response = response_class(url=FRONTEND_URL_AUTH_SUCCESS)
    response.set_cookie(
        key=SESSION_COOKIE_NAME, # 'session_token' из settings.py
        value=session_token,
        httponly=SESSION_COOKIE_HTTPONLY, # True из settings.py
        secure=SESSION_COOKIE_SECURE,     # True для HTTPS из settings.py
        samesite=SESSION_COOKIE_SAMESITE, # 'lax' из settings.py
        max_age=SESSION_COOKIE_MAX_AGE    # 30 дней в секундах из settings.py
    )
    print(f"Session created for user {user.id}. Token: {session_token[:10]}...") # Логируем для отладки
    return response

async def logout_user_session(request: Request, response_class=RedirectResponse):
    """
    Завершение сессии пользователя и удаление cookie.
    """
    session_token = request.cookies.get(SESSION_COOKIE_NAME)

    if session_token:
        # Удаляем токен из хранилища (TokenStorage из вашего модуля auth.token)
        TokenStorage.delete_session(session_token)
        print(f"Session token {session_token[:10]}... deleted from storage.")

    # Удаляем cookie
    # В реальном приложении FRONTEND_URL_LOGOUT должен вести на страницу вашего фронтенда
    response = response_class(url=FRONTEND_URL_LOGOUT)
    response.delete_cookie(SESSION_COOKIE_NAME)
    print(f"Cookie {SESSION_COOKIE_NAME} deleted.")
    return response

8. Проверка CSRF в формах

// components/ProfileForm.tsx
// import { useAuthContext } from '../auth'; // Предполагаем, что auth есть в контексте
import { Component, createSignal, Show } from 'solid-js';

export const ProfileForm: Component = () => {
  const { store, checkAuth } = useAuthContext(); // Пример получения из контекста
  const [message, setMessage] = createSignal<string | null>(null);
  const [error, setError] = createSignal<string | null>(null);

  const handleSubmit = async (event: SubmitEvent) => {
    event.preventDefault();
    setMessage(null);
    setError(null);
    const form = event.currentTarget as HTMLFormElement;
    const formData = new FormData(form);

    // ВАЖНО: Получение CSRF-токена из cookie - это один из способов.
    // Если CSRF-токен устанавливается как httpOnly cookie, то он будет автоматически
    // отправляться браузером, и его не нужно доставать вручную для fetch,
    // если сервер настроен на его проверку из заголовка (например, X-CSRF-Token),
    // который fetch *не* устанавливает автоматически для httpOnly cookie.
    // Либо сервер может предоставлять CSRF-токен через специальный эндпоинт.
    // Представленный ниже способ подходит, если CSRF-токен доступен для JS.
    const csrfToken = document.cookie
      .split('; ')
      .find(row => row.startsWith('csrf_token=')) // Имя cookie может отличаться
      ?.split('=')[1];

    if (!csrfToken) {
      // setError('CSRF token not found. Please refresh the page.');
      // В продакшене CSRF-токен должен быть всегда. Этот лог для отладки.
      console.warn('CSRF token not found in cookies. Ensure it is set by the server.');
      // Для данного примера, если токен не найден, можно либо прервать, либо положиться на серверную проверку.
      // Для большей безопасности, прерываем, если CSRF-защита критична на клиенте.
    }

    try {
      // Замените '/api/profile' на ваш реальный эндпоинт
      const response = await fetch('/api/profile', {
        method: 'POST',
        headers: {
          // Сервер должен быть настроен на чтение этого заголовка
          // если CSRF токен не отправляется автоматически с httpOnly cookie.
          ...(csrfToken && { 'X-CSRF-Token': csrfToken }),
          // 'Content-Type': 'application/json' // Если отправляете JSON
        },
        body: formData // FormData отправится как 'multipart/form-data'
                       // Если нужно JSON: body: JSON.stringify(Object.fromEntries(formData))
      });

      if (response.ok) {
        const result = await response.json();
        setMessage(result.message || 'Профиль успешно обновлен!');
        checkAuth(); // Обновить данные пользователя в сторе
      } else {
        const errData = await response.json();
        setError(errData.error || `Ошибка: ${response.status}`);
      }
    } catch (err) {
      console.error('Profile update error:', err);
      setError('Не удалось обновить профиль. Попробуйте позже.');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label for="name">Имя:</label>
        <input id="name" name="name" defaultValue={store().user?.name || ''} />
      </div>
      {/* Другие поля профиля */}
      <button type="submit">Сохранить изменения</button>
      <Show when={message()}>
        <p style={{ color: 'green' }}>{message()}</p>
      </Show>
      <Show when={error()}>
        <p style={{ color: 'red' }}>{error()}</p>
      </Show>
    </form>
  );
}

9. Кастомные валидаторы для форм

// 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
}

// components/RegisterForm.tsx
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" />
      {errors().map(error => (
        <div class="error">{error}</div>
      ))}
      <button type="submit">Регистрация</button>
    </form>
  )
}

10. Интеграция с внешними сервисами

# services/notifications.py
from auth.models import Author

async def notify_login(user: Author, ip: str, device: str):
    """Отправка уведомления о новом входе"""

    # Формируем текст
    text = f"""
    Новый вход в аккаунт:
    IP: {ip}
    Устройство: {device}
    Время: {datetime.now()}
    """

    # Отправляем email
    await send_email(
        to=user.email,
        subject='Новый вход в аккаунт',
        text=text
    )

    # Логируем
    logger.info(f'New login for user {user.id} from {ip}')

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

1. Тест OAuth авторизации

# tests/test_oauth.py
@pytest.mark.asyncio
async def test_google_oauth_success(client, mock_google):
    # Мокаем ответ от Google
    mock_google.return_value = {
        'id': '123',
        '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

2. Тест ролей и разрешений

# tests/test_permissions.py
def test_user_permissions():
    # Создаем тестовые данные
    role = Role(id='editor', name='Editor')
    permission = Permission(
        id='articles:edit',
        resource='articles',
        operation='edit'
    )
    role.permissions.append(permission)

    user = Author(email='test@test.com')
    user.roles.append(role)

    # Проверяем разрешения
    assert await user.has_permission('articles', 'edit')
    assert not await user.has_permission('articles', 'delete')

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

1. Rate Limiting

# middleware/rate_limit.py
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from redis import Redis

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)

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

# 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(
                'Account is locked. Try again later.',
                'ACCOUNT_LOCKED'
            )
    else:
        # Сбрасываем счетчик при успешном входе
        user.reset_failed_login()

Мониторинг

1. Логирование событий авторизации

# auth/logging.py
import structlog

logger = structlog.get_logger()

def log_auth_event(
    event_type: str,
    user_id: int = None,
    success: bool = True,
    **kwargs
):
    """
    Логирование событий авторизации

    Args:
        event_type: Тип события (login, logout, etc)
        user_id: ID пользователя
        success: Успешность операции
        **kwargs: Дополнительные поля
    """
    logger.info(
        'auth_event',
        event_type=event_type,
        user_id=user_id,
        success=success,
        **kwargs
    )

2. Метрики для Prometheus

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

# Счетчики
login_attempts = Counter(
    'auth_login_attempts_total',
    'Number of login attempts',
    ['success']
)

oauth_logins = Counter(
    'auth_oauth_logins_total',
    'Number of OAuth logins',
    ['provider']
)

# Гистограммы
login_duration = Histogram(
    'auth_login_duration_seconds',
    'Time spent processing login'
)