csrf-fix
Some checks failed
Deploy on push / deploy (push) Failing after 5s

This commit is contained in:
2025-07-25 09:27:55 +03:00
parent 472b24527a
commit e0f6b7d2be
3 changed files with 208 additions and 28 deletions

View File

@@ -2,6 +2,9 @@
Единый middleware для обработки авторизации в GraphQL запросах Единый middleware для обработки авторизации в GraphQL запросах
""" """
import hashlib
import os
import secrets
import time import time
from collections.abc import Awaitable, MutableMapping from collections.abc import Awaitable, MutableMapping
from typing import Any, Callable, Optional from typing import Any, Callable, Optional
@@ -34,6 +37,10 @@ from utils.logger import root_logger as logger
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",") 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: class AuthenticatedUser:
"""Аутентифицированный пользователь""" """Аутентифицированный пользователь"""
@@ -348,6 +355,54 @@ class AuthMiddleware:
logger.error(f"[AuthMiddleware] Ошибка в GraphQL resolve: {e!s}") logger.error(f"[AuthMiddleware] Ошибка в GraphQL resolve: {e!s}")
raise 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: async def process_result(self, request: Request, result: Any) -> Response:
""" """
Обрабатывает результат GraphQL запроса, поддерживая установку cookie Обрабатывает результат GraphQL запроса, поддерживая установку cookie
@@ -421,6 +476,23 @@ class AuthMiddleware:
samesite=SESSION_COOKIE_SAMESITE, samesite=SESSION_COOKIE_SAMESITE,
) )
logger.debug(f"[graphql_handler] Удалена cookie {SESSION_COOKIE_NAME} для операции {op_name}") 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: except Exception as e:
logger.error(f"[process_result] Ошибка при обработке POST запроса: {e!s}") logger.error(f"[process_result] Ошибка при обработке POST запроса: {e!s}")

View File

@@ -3,18 +3,27 @@
* @module api * @module api
*/ */
import { import { AUTH_TOKEN_KEY, clearAuthTokens, getAuthTokenFromCookie } from '../utils/auth'
AUTH_TOKEN_KEY,
clearAuthTokens,
getAuthTokenFromCookie,
getCsrfTokenFromCookie
} from '../utils/auth'
/** /**
* Тип для произвольных данных GraphQL * Тип для произвольных данных GraphQL
*/ */
type GraphQLData = Record<string, unknown> 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 * Возвращает заголовки для GraphQL запроса с учетом авторизации и CSRF
* @returns Объект с заголовками * @returns Объект с заголовками
@@ -43,13 +52,19 @@ function getRequestHeaders(): Record<string, string> {
// Добавляем CSRF-токен, если он есть // Добавляем CSRF-токен, если он есть
const csrfToken = getCsrfTokenFromCookie() const csrfToken = getCsrfTokenFromCookie()
if (csrfToken) { if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken headers[CSRF_HEADER_NAME] = csrfToken
console.debug('Добавлен CSRF-токен в запрос') console.debug('Добавлен CSRF-токен в запрос')
} }
return headers return headers
} }
interface GraphQLError extends Error {
extensions?: {
code?: string
}
}
/** /**
* Выполняет GraphQL запрос * Выполняет GraphQL запрос
* @param endpoint - URL эндпоинта GraphQL * @param endpoint - URL эндпоинта GraphQL
@@ -84,7 +99,7 @@ export async function query<T = unknown>(
console.log(`[GraphQL] Response status: ${response.status}`) console.log(`[GraphQL] Response status: ${response.status}`)
if (!response.ok) { // Обработка HTTP-ошибок авторизации
if (response.status === 401) { if (response.status === 401) {
console.log('[GraphQL] Unauthorized response, clearing auth tokens') console.log('[GraphQL] Unauthorized response, clearing auth tokens')
clearAuthTokens() clearAuthTokens()
@@ -92,37 +107,49 @@ export async function query<T = unknown>(
if (!window.location.pathname.includes('/login')) { if (!window.location.pathname.includes('/login')) {
window.location.href = '/login' window.location.href = '/login'
} }
} throw new Error('Unauthorized')
const errorText = await response.text()
throw new Error(`HTTP error: ${response.status} ${errorText}`)
} }
const result = await response.json() const result = await response.json()
console.log('[GraphQL] Response received:', result) console.log('[GraphQL] Response received:', result)
// Обработка CSRF-ошибок
if (result.errors) { if (result.errors) {
// Проверяем ошибки авторизации const csrfError = result.errors.find((error: GraphQLError) =>
const hasUnauthorized = result.errors.some( ['CSRF_TOKEN_MISSING', 'CSRF_TOKEN_INVALID'].includes(error.extensions?.code ?? '')
(error: { message?: string }) =>
error.message?.toLowerCase().includes('unauthorized') ||
error.message?.toLowerCase().includes('please login')
) )
if (hasUnauthorized) { if (csrfError) {
console.log('[GraphQL] Unauthorized error in response, clearing auth tokens') 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() clearAuthTokens()
// Перенаправляем на страницу входа только если мы не на ней // Перенаправляем на страницу входа только если мы не на ней
if (!window.location.pathname.includes('/login')) { if (!window.location.pathname.includes('/login')) {
window.location.href = '/login' window.location.href = '/login'
} }
throw new Error('Unauthorized')
} }
// Handle GraphQL errors throw new Error(result.errors.map((e: GraphQLError) => e.message).join('; '))
const errorMessage = result.errors.map((e: { message?: string }) => e.message).join(', ')
throw new Error(`GraphQL error: ${errorMessage}`)
} }
return result.data return result.data as T
} catch (error) { } catch (error) {
console.error('[GraphQL] Query error:', error) console.error('[GraphQL] Query error:', error)
throw error throw error

View File

@@ -6,9 +6,14 @@ from ariadne import (
ObjectType, ObjectType,
QueryType, QueryType,
SchemaBindable, SchemaBindable,
graphql,
load_schema_from_path, 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 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)) table_name = getattr(model, "__tablename__", str(model))
logger.error(f"Error creating table {table_name}: {e}") logger.error(f"Error creating table {table_name}: {e}")
raise 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)