diff --git a/auth/middleware.py b/auth/middleware.py index 2cf111a2..46f62b99 100644 --- a/auth/middleware.py +++ b/auth/middleware.py @@ -2,6 +2,9 @@ Единый middleware для обработки авторизации в GraphQL запросах """ +import hashlib +import os +import secrets import time from collections.abc import Awaitable, MutableMapping from typing import Any, Callable, Optional @@ -34,6 +37,10 @@ from utils.logger import root_logger as logger ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",") +# Добавляем константу для CSRF токена +CSRF_TOKEN_KEY = os.environ.get("CSRF_TOKEN_KEY", "csrf_token") +CSRF_HEADER_NAME = os.environ.get("CSRF_HEADER_NAME", "X-CSRF-Token") + class AuthenticatedUser: """Аутентифицированный пользователь""" @@ -348,6 +355,54 @@ class AuthMiddleware: logger.error(f"[AuthMiddleware] Ошибка в GraphQL resolve: {e!s}") raise + def generate_csrf_token(self, user_id: Optional[str] = None) -> str: + """ + Генерирует криптографически стойкий CSRF токен + + Args: + user_id: Необязательный идентификатор пользователя для привязки токена + + Returns: + str: CSRF токен + """ + # Используем secrets для генерации случайных байт + random_bytes = secrets.token_bytes(32) + + # Добавляем user_id для привязки токена к сессии, если передан + salt = user_id.encode() if user_id else b"" + + # Создаем хеш с солью + csrf_token = hashlib.sha256(random_bytes + salt).hexdigest() + + return csrf_token + + async def validate_csrf_token(self, request: Request) -> bool: + """ + Проверяет CSRF токен в запросе + + Args: + request: Объект запроса + + Returns: + bool: Результат проверки CSRF токена + """ + # Получаем токен из заголовка + request_csrf_token = request.headers.get(CSRF_HEADER_NAME) + + # Получаем токен из куки + cookie_csrf_token = request.cookies.get(CSRF_TOKEN_KEY) + + # Проверяем наличие и совпадение токенов + if not request_csrf_token or not cookie_csrf_token: + logger.warning("[CSRF] Токен отсутствует") + return False + + if request_csrf_token != cookie_csrf_token: + logger.warning("[CSRF] Токены не совпадают") + return False + + return True + async def process_result(self, request: Request, result: Any) -> Response: """ Обрабатывает результат GraphQL запроса, поддерживая установку cookie @@ -421,6 +476,23 @@ class AuthMiddleware: samesite=SESSION_COOKIE_SAMESITE, ) logger.debug(f"[graphql_handler] Удалена cookie {SESSION_COOKIE_NAME} для операции {op_name}") + + # Если это аутентификация, генерируем и устанавливаем CSRF токен + if op_name in ["login", "refreshtoken"]: + # Генерируем CSRF токен + csrf_token = self.generate_csrf_token() + + # Устанавливаем токен в куки + response.set_cookie( + key=CSRF_TOKEN_KEY, + value=csrf_token, + httponly=False, # Должен быть доступен JavaScript + secure=SESSION_COOKIE_SECURE, + samesite=SESSION_COOKIE_SAMESITE, + max_age=SESSION_COOKIE_MAX_AGE, + ) + logger.debug("[CSRF] Установлен новый CSRF токен") + except Exception as e: logger.error(f"[process_result] Ошибка при обработке POST запроса: {e!s}") diff --git a/panel/graphql/index.ts b/panel/graphql/index.ts index 0dfa93b0..78d40971 100644 --- a/panel/graphql/index.ts +++ b/panel/graphql/index.ts @@ -3,18 +3,27 @@ * @module api */ -import { - AUTH_TOKEN_KEY, - clearAuthTokens, - getAuthTokenFromCookie, - getCsrfTokenFromCookie -} from '../utils/auth' +import { AUTH_TOKEN_KEY, clearAuthTokens, getAuthTokenFromCookie } from '../utils/auth' /** * Тип для произвольных данных GraphQL */ type GraphQLData = Record +const CSRF_TOKEN_KEY = 'csrf_token' +const CSRF_HEADER_NAME = 'X-CSRF-Token' + +function getCsrfTokenFromCookie(): string { + const cookieItems = document.cookie.split(';') + for (const item of cookieItems) { + const [name, value] = item.trim().split('=') + if (name === CSRF_TOKEN_KEY) { + return value + } + } + return '' +} + /** * Возвращает заголовки для GraphQL запроса с учетом авторизации и CSRF * @returns Объект с заголовками @@ -43,13 +52,19 @@ function getRequestHeaders(): Record { // Добавляем CSRF-токен, если он есть const csrfToken = getCsrfTokenFromCookie() if (csrfToken) { - headers['X-CSRF-Token'] = csrfToken + headers[CSRF_HEADER_NAME] = csrfToken console.debug('Добавлен CSRF-токен в запрос') } return headers } +interface GraphQLError extends Error { + extensions?: { + code?: string + } +} + /** * Выполняет GraphQL запрос * @param endpoint - URL эндпоинта GraphQL @@ -84,45 +99,57 @@ export async function query( console.log(`[GraphQL] Response status: ${response.status}`) - if (!response.ok) { - if (response.status === 401) { - console.log('[GraphQL] Unauthorized response, clearing auth tokens') - clearAuthTokens() - // Перенаправляем на страницу входа только если мы не на ней - if (!window.location.pathname.includes('/login')) { - window.location.href = '/login' - } + // Обработка HTTP-ошибок авторизации + if (response.status === 401) { + console.log('[GraphQL] Unauthorized response, clearing auth tokens') + clearAuthTokens() + // Перенаправляем на страницу входа только если мы не на ней + if (!window.location.pathname.includes('/login')) { + window.location.href = '/login' } - const errorText = await response.text() - throw new Error(`HTTP error: ${response.status} ${errorText}`) + throw new Error('Unauthorized') } const result = await response.json() console.log('[GraphQL] Response received:', result) + // Обработка CSRF-ошибок if (result.errors) { - // Проверяем ошибки авторизации - const hasUnauthorized = result.errors.some( - (error: { message?: string }) => - error.message?.toLowerCase().includes('unauthorized') || - error.message?.toLowerCase().includes('please login') + const csrfError = result.errors.find((error: GraphQLError) => + ['CSRF_TOKEN_MISSING', 'CSRF_TOKEN_INVALID'].includes(error.extensions?.code ?? '') ) - if (hasUnauthorized) { - console.log('[GraphQL] Unauthorized error in response, clearing auth tokens') + if (csrfError) { + console.error('[GraphQL] CSRF Error:', csrfError) + + // Принудительное обновление страницы для получения нового токена + window.location.reload() + + throw new Error(`CSRF Error: ${csrfError.message}`) + } + + // Обработка других GraphQL-ошибок + const unauthorizedError = result.errors.find( + (error: GraphQLError) => + error.message.toLowerCase().includes('unauthorized') || + error.message.toLowerCase().includes('please login') + ) + + if (unauthorizedError) { + console.log('[GraphQL] Unauthorized response, clearing auth tokens') clearAuthTokens() + // Перенаправляем на страницу входа только если мы не на ней if (!window.location.pathname.includes('/login')) { window.location.href = '/login' } + throw new Error('Unauthorized') } - // Handle GraphQL errors - const errorMessage = result.errors.map((e: { message?: string }) => e.message).join(', ') - throw new Error(`GraphQL error: ${errorMessage}`) + throw new Error(result.errors.map((e: GraphQLError) => e.message).join('; ')) } - return result.data + return result.data as T } catch (error) { console.error('[GraphQL] Query error:', error) throw error diff --git a/services/schema.py b/services/schema.py index a1f72237..6e9ef99a 100644 --- a/services/schema.py +++ b/services/schema.py @@ -6,9 +6,14 @@ from ariadne import ( ObjectType, QueryType, SchemaBindable, + graphql, load_schema_from_path, + make_executable_schema, ) +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from auth.middleware import CSRF_HEADER_NAME, CSRF_TOKEN_KEY from services.db import create_table_if_not_exists, local_session # Создаем основные типы @@ -78,3 +83,79 @@ def create_all_tables() -> None: table_name = getattr(model, "__tablename__", str(model)) logger.error(f"Error creating table {table_name}: {e}") raise + + +async def graphql_handler(request: Request) -> Response: + """ + Обработчик GraphQL запросов с проверкой CSRF токена + """ + try: + # Проверяем CSRF токен для всех мутаций + data = await request.json() + op_name = data.get("operationName", "").lower() + + # Проверяем CSRF только для мутаций + if op_name and (op_name.endswith("mutation") or op_name in ["login", "refreshtoken"]): + # Получаем токен из заголовка + request_csrf_token = request.headers.get(CSRF_HEADER_NAME) + + # Получаем токен из куки + cookie_csrf_token = request.cookies.get(CSRF_TOKEN_KEY) + + # Строгая проверка токена + if not request_csrf_token or not cookie_csrf_token: + # Возвращаем ошибку как часть GraphQL-ответа + return JSONResponse( + { + "data": None, + "errors": [{"message": "CSRF токен отсутствует", "extensions": {"code": "CSRF_TOKEN_MISSING"}}], + } + ) + + if request_csrf_token != cookie_csrf_token: + # Возвращаем ошибку как часть GraphQL-ответа + return JSONResponse( + { + "data": None, + "errors": [ + {"message": "Недопустимый CSRF токен", "extensions": {"code": "CSRF_TOKEN_INVALID"}} + ], + } + ) + + # Существующая логика обработки GraphQL запроса + schema = get_schema() + result = await graphql( + schema, + data.get("query"), + variable_values=data.get("variables"), + operation_name=data.get("operationName"), + context_value={"request": request}, + ) + + # Обработка ошибок GraphQL + if result.errors: + return JSONResponse( + { + "data": result.data, + "errors": [{"message": str(error), "locations": error.locations} for error in result.errors], + } + ) + + return JSONResponse({"data": result.data}) + + except Exception as e: + logger.error(f"GraphQL handler error: {e}") + return JSONResponse( + { + "data": None, + "errors": [{"message": "Внутренняя ошибка сервера", "extensions": {"code": "INTERNAL_SERVER_ERROR"}}], + } + ) + + +def get_schema(): + """ + Создает и возвращает GraphQL схему + """ + return make_executable_schema(type_defs, resolvers)