This commit is contained in:
@@ -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}")
|
||||
|
||||
|
@@ -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<string, unknown>
|
||||
|
||||
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<string, string> {
|
||||
// Добавляем 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<T = unknown>(
|
||||
|
||||
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
|
||||
|
@@ -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)
|
||||
|
Reference in New Issue
Block a user