diff --git a/auth/middleware.py b/auth/middleware.py index 46f62b99..2cf111a2 100644 --- a/auth/middleware.py +++ b/auth/middleware.py @@ -2,9 +2,6 @@ Единый middleware для обработки авторизации в GraphQL запросах """ -import hashlib -import os -import secrets import time from collections.abc import Awaitable, MutableMapping from typing import Any, Callable, Optional @@ -37,10 +34,6 @@ 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: """Аутентифицированный пользователь""" @@ -355,54 +348,6 @@ 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 @@ -476,23 +421,6 @@ 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/context/auth.tsx b/panel/context/auth.tsx index 047a08e9..a13b60c5 100644 --- a/panel/context/auth.tsx +++ b/panel/context/auth.tsx @@ -1,4 +1,4 @@ -import { Component, createContext, createSignal, JSX, useContext } from 'solid-js' +import { Component, createContext, createSignal, JSX, onMount, useContext } from 'solid-js' import { query } from '../graphql' import { ADMIN_LOGIN_MUTATION, ADMIN_LOGOUT_MUTATION } from '../graphql/mutations' import { @@ -45,12 +45,14 @@ export { interface AuthContextType { isAuthenticated: () => boolean + isReady: () => boolean login: (username: string, password: string) => Promise logout: () => Promise } const AuthContext = createContext({ isAuthenticated: () => false, + isReady: () => false, login: async () => {}, logout: async () => {} }) @@ -64,10 +66,27 @@ interface AuthProviderProps { export const AuthProvider: Component = (props) => { console.log('[AuthProvider] Initializing...') const [isAuthenticated, setIsAuthenticated] = createSignal(checkAuthStatus()) + const [isReady, setIsReady] = createSignal(false) + console.log( `[AuthProvider] Initial auth state: ${isAuthenticated() ? 'authenticated' : 'not authenticated'}` ) + // Инициализация авторизации при монтировании + onMount(async () => { + console.log('[AuthProvider] Performing auth initialization...') + + // Небольшая задержка для завершения других инициализаций + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Проверяем текущее состояние авторизации + const authStatus = checkAuthStatus() + setIsAuthenticated(authStatus) + + console.log('[AuthProvider] Auth initialization complete, ready for requests') + setIsReady(true) + }) + const login = async (username: string, password: string) => { console.log('[AuthProvider] Attempting login...') try { @@ -127,6 +146,7 @@ export const AuthProvider: Component = (props) => { const value: AuthContextType = { isAuthenticated, + isReady, login, logout } diff --git a/panel/context/data.tsx b/panel/context/data.tsx index 42819555..54d41279 100644 --- a/panel/context/data.tsx +++ b/panel/context/data.tsx @@ -6,6 +6,7 @@ import { GET_COMMUNITIES_QUERY, GET_TOPICS_QUERY } from '../graphql/queries' +import { useAuth } from './auth' export interface Community { id: number @@ -92,6 +93,7 @@ const DataContext = createContext({ const COMMUNITY_STORAGE_KEY = 'admin-selected-community' export function DataProvider(props: { children: JSX.Element }) { + const auth = useAuth() const [communities, setCommunities] = createSignal([]) const [topics, setTopics] = createSignal([]) const [allTopics, setAllTopics] = createSignal([]) @@ -140,11 +142,16 @@ export function DataProvider(props: { children: JSX.Element }) { // Эффект для загрузки ролей при изменении сообщества createEffect(() => { const community = selectedCommunity() - if (community !== null) { - console.log('[DataProvider] Загрузка ролей для сообщества:', community) + const isReady = auth.isReady() + const isAuthenticated = auth.isAuthenticated() + + if (community !== null && isReady && isAuthenticated) { + console.log('[DataProvider] Auth ready, загрузка ролей для сообщества:', community) loadRoles(community).catch((err) => { console.warn('Не удалось загрузить роли для сообщества:', err) }) + } else if (!isReady) { + console.log('[DataProvider] Ожидание готовности авторизации перед загрузкой ролей') } }) @@ -324,6 +331,26 @@ export function DataProvider(props: { children: JSX.Element }) { // biome-ignore lint/suspicious/noExplicitAny: grahphql queryGraphQL: async (queryStr: string, variables?: Record) => { try { + // Ждем готовности авторизации перед выполнением запроса + const maxWaitTime = 5000 // 5 секунд максимум + const startTime = Date.now() + + while (!auth.isReady() && Date.now() - startTime < maxWaitTime) { + console.log('[DataProvider] Ожидание готовности авторизации для GraphQL запроса...') + await new Promise((resolve) => setTimeout(resolve, 50)) + } + + if (!auth.isReady()) { + console.warn('[DataProvider] Таймаут ожидания готовности авторизации') + throw new Error('Auth not ready') + } + + if (!auth.isAuthenticated()) { + console.warn('[DataProvider] Пользователь не авторизован') + throw new Error('User not authenticated') + } + + console.log('[DataProvider] Выполнение GraphQL запроса после готовности авторизации') return await query(`${location.origin}/graphql`, queryStr, variables) } catch (error) { console.error('Ошибка выполнения GraphQL запроса:', error) diff --git a/panel/graphql/index.ts b/panel/graphql/index.ts index 78d40971..6cbb223e 100644 --- a/panel/graphql/index.ts +++ b/panel/graphql/index.ts @@ -3,27 +3,18 @@ * @module api */ -import { AUTH_TOKEN_KEY, clearAuthTokens, getAuthTokenFromCookie } from '../utils/auth' +import { + AUTH_TOKEN_KEY, + clearAuthTokens, + getAuthTokenFromCookie, + getCsrfTokenFromCookie +} 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 Объект с заголовками @@ -52,21 +43,15 @@ function getRequestHeaders(): Record { // Добавляем CSRF-токен, если он есть const csrfToken = getCsrfTokenFromCookie() if (csrfToken) { - headers[CSRF_HEADER_NAME] = csrfToken + headers['X-CSRF-Token'] = csrfToken console.debug('Добавлен CSRF-токен в запрос') } return headers } -interface GraphQLError extends Error { - extensions?: { - code?: string - } -} - /** - * Выполняет GraphQL запрос + * Выполняет GraphQL запрос с retry логикой для 503 ошибок * @param endpoint - URL эндпоинта GraphQL * @param query - GraphQL запрос * @param variables - Переменные запроса @@ -77,83 +62,93 @@ export async function query( query: string, variables?: Record ): Promise { - try { - console.log(`[GraphQL] Making request to ${endpoint}`) - console.log(`[GraphQL] Query: ${query.substring(0, 100)}...`) + const maxRetries = 3 + const retryDelay = 500 // 500ms базовая задержка - // Используем существующую функцию для получения всех необходимых заголовков - const headers = getRequestHeaders() - console.log( - `[GraphQL] Заголовки установлены, Authorization: ${headers['Authorization'] ? 'присутствует' : 'отсутствует'}` - ) + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + console.log(`[GraphQL] Making request to ${endpoint} (attempt ${attempt}/${maxRetries})`) + console.log(`[GraphQL] Query: ${query.substring(0, 100)}...`) - const response = await fetch(endpoint, { - method: 'POST', - headers, - credentials: 'include', - body: JSON.stringify({ - query, - variables + // Используем существующую функцию для получения всех необходимых заголовков + const headers = getRequestHeaders() + console.log( + `[GraphQL] Заголовки установлены, Authorization: ${headers['Authorization'] ? 'присутствует' : 'отсутствует'}` + ) + + const response = await fetch(endpoint, { + method: 'POST', + headers, + credentials: 'include', + body: JSON.stringify({ + query, + variables + }) }) - }) - console.log(`[GraphQL] Response status: ${response.status}`) + console.log(`[GraphQL] Response status: ${response.status}`) - // Обработка HTTP-ошибок авторизации - if (response.status === 401) { - console.log('[GraphQL] Unauthorized response, clearing auth tokens') - clearAuthTokens() - // Перенаправляем на страницу входа только если мы не на ней - if (!window.location.pathname.includes('/login')) { - window.location.href = '/login' - } - throw new Error('Unauthorized') - } - - const result = await response.json() - console.log('[GraphQL] Response received:', result) - - // Обработка CSRF-ошибок - if (result.errors) { - const csrfError = result.errors.find((error: GraphQLError) => - ['CSRF_TOKEN_MISSING', 'CSRF_TOKEN_INVALID'].includes(error.extensions?.code ?? '') - ) - - if (csrfError) { - console.error('[GraphQL] CSRF Error:', csrfError) - - // Принудительное обновление страницы для получения нового токена - window.location.reload() - - throw new Error(`CSRF Error: ${csrfError.message}`) + // Если получили 503 и это не последняя попытка, повторяем запрос + if (response.status === 503 && attempt < maxRetries) { + const delay = retryDelay * attempt // Экспоненциальная задержка + console.log(`[GraphQL] Got 503 error, retrying after ${delay}ms...`) + await new Promise((resolve) => setTimeout(resolve, delay)) + continue } - // Обработка других 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' + 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' + } } - throw new Error('Unauthorized') + const errorText = await response.text() + throw new Error(`HTTP error: ${response.status} ${errorText}`) } - throw new Error(result.errors.map((e: GraphQLError) => e.message).join('; ')) - } + const result = await response.json() + console.log('[GraphQL] Response received:', result) - return result.data as T - } catch (error) { - console.error('[GraphQL] Query error:', error) - throw error + if (result.errors) { + // Проверяем ошибки авторизации + const hasUnauthorized = result.errors.some( + (error: { message?: string }) => + error.message?.toLowerCase().includes('unauthorized') || + error.message?.toLowerCase().includes('please login') + ) + + if (hasUnauthorized) { + console.log('[GraphQL] Unauthorized error in response, clearing auth tokens') + clearAuthTokens() + // Перенаправляем на страницу входа только если мы не на ней + if (!window.location.pathname.includes('/login')) { + window.location.href = '/login' + } + } + + // Handle GraphQL errors + const errorMessage = result.errors.map((e: { message?: string }) => e.message).join(', ') + throw new Error(`GraphQL error: ${errorMessage}`) + } + + return result.data + } catch (error) { + // Если это последняя попытка или ошибка не 503, пробрасываем ошибку + if (attempt === maxRetries || !(error instanceof Error) || !error.message.includes('503')) { + console.error('[GraphQL] Query error:', error) + throw error + } + + // Для других ошибок на промежуточных попытках просто логируем + console.warn(`[GraphQL] Attempt ${attempt} failed, retrying...`, error.message) + } } + + // Этот код никогда не должен выполниться, но добавляем для TypeScript + throw new Error('Max retries exceeded') } /** diff --git a/panel/ui/ProtectedRoute.tsx b/panel/ui/ProtectedRoute.tsx index ec325485..03f7ca3e 100644 --- a/panel/ui/ProtectedRoute.tsx +++ b/panel/ui/ProtectedRoute.tsx @@ -9,11 +9,23 @@ import AdminPage from '../routes/admin' export const ProtectedRoute = () => { console.log('[ProtectedRoute] Checking authentication...') const auth = useAuth() + const isReady = auth.isReady() const authenticated = auth.isAuthenticated() - console.log( - `[ProtectedRoute] Authentication state: ${authenticated ? 'authenticated' : 'not authenticated'}` - ) + console.log(`[ProtectedRoute] Auth state: ready=${isReady}, authenticated=${authenticated}`) + + // Если авторизация еще не готова, показываем загрузку + if (!isReady) { + console.log('[ProtectedRoute] Auth not ready, showing loading...') + return ( +
+
+
Инициализация авторизации...
+
+ ) + } + + // Если авторизация готова, но пользователь не аутентифицирован if (!authenticated) { console.log('[ProtectedRoute] Not authenticated, redirecting to login...') // Используем window.location.href для редиректа @@ -21,11 +33,12 @@ export const ProtectedRoute = () => { return (
-
Проверка авторизации...
+
Перенаправление на страницу входа...
) } + console.log('[ProtectedRoute] Auth ready and authenticated, rendering admin panel...') return ( diff --git a/services/schema.py b/services/schema.py index 6e9ef99a..a1f72237 100644 --- a/services/schema.py +++ b/services/schema.py @@ -6,14 +6,9 @@ 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 # Создаем основные типы @@ -83,79 +78,3 @@ 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)