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

This commit is contained in:
2025-07-25 09:42:43 +03:00
parent e0f6b7d2be
commit 0bccd0d87e
6 changed files with 150 additions and 248 deletions

View File

@@ -2,9 +2,6 @@
Единый 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
@@ -37,10 +34,6 @@ 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:
"""Аутентифицированный пользователь""" """Аутентифицированный пользователь"""
@@ -355,54 +348,6 @@ 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
@@ -476,23 +421,6 @@ 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

@@ -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 { query } from '../graphql'
import { ADMIN_LOGIN_MUTATION, ADMIN_LOGOUT_MUTATION } from '../graphql/mutations' import { ADMIN_LOGIN_MUTATION, ADMIN_LOGOUT_MUTATION } from '../graphql/mutations'
import { import {
@@ -45,12 +45,14 @@ export {
interface AuthContextType { interface AuthContextType {
isAuthenticated: () => boolean isAuthenticated: () => boolean
isReady: () => boolean
login: (username: string, password: string) => Promise<void> login: (username: string, password: string) => Promise<void>
logout: () => Promise<void> logout: () => Promise<void>
} }
const AuthContext = createContext<AuthContextType>({ const AuthContext = createContext<AuthContextType>({
isAuthenticated: () => false, isAuthenticated: () => false,
isReady: () => false,
login: async () => {}, login: async () => {},
logout: async () => {} logout: async () => {}
}) })
@@ -64,10 +66,27 @@ interface AuthProviderProps {
export const AuthProvider: Component<AuthProviderProps> = (props) => { export const AuthProvider: Component<AuthProviderProps> = (props) => {
console.log('[AuthProvider] Initializing...') console.log('[AuthProvider] Initializing...')
const [isAuthenticated, setIsAuthenticated] = createSignal(checkAuthStatus()) const [isAuthenticated, setIsAuthenticated] = createSignal(checkAuthStatus())
const [isReady, setIsReady] = createSignal(false)
console.log( console.log(
`[AuthProvider] Initial auth state: ${isAuthenticated() ? 'authenticated' : 'not authenticated'}` `[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) => { const login = async (username: string, password: string) => {
console.log('[AuthProvider] Attempting login...') console.log('[AuthProvider] Attempting login...')
try { try {
@@ -127,6 +146,7 @@ export const AuthProvider: Component<AuthProviderProps> = (props) => {
const value: AuthContextType = { const value: AuthContextType = {
isAuthenticated, isAuthenticated,
isReady,
login, login,
logout logout
} }

View File

@@ -6,6 +6,7 @@ import {
GET_COMMUNITIES_QUERY, GET_COMMUNITIES_QUERY,
GET_TOPICS_QUERY GET_TOPICS_QUERY
} from '../graphql/queries' } from '../graphql/queries'
import { useAuth } from './auth'
export interface Community { export interface Community {
id: number id: number
@@ -92,6 +93,7 @@ const DataContext = createContext<DataContextType>({
const COMMUNITY_STORAGE_KEY = 'admin-selected-community' const COMMUNITY_STORAGE_KEY = 'admin-selected-community'
export function DataProvider(props: { children: JSX.Element }) { export function DataProvider(props: { children: JSX.Element }) {
const auth = useAuth()
const [communities, setCommunities] = createSignal<Community[]>([]) const [communities, setCommunities] = createSignal<Community[]>([])
const [topics, setTopics] = createSignal<Topic[]>([]) const [topics, setTopics] = createSignal<Topic[]>([])
const [allTopics, setAllTopics] = createSignal<Topic[]>([]) const [allTopics, setAllTopics] = createSignal<Topic[]>([])
@@ -140,11 +142,16 @@ export function DataProvider(props: { children: JSX.Element }) {
// Эффект для загрузки ролей при изменении сообщества // Эффект для загрузки ролей при изменении сообщества
createEffect(() => { createEffect(() => {
const community = selectedCommunity() const community = selectedCommunity()
if (community !== null) { const isReady = auth.isReady()
console.log('[DataProvider] Загрузка ролей для сообщества:', community) const isAuthenticated = auth.isAuthenticated()
if (community !== null && isReady && isAuthenticated) {
console.log('[DataProvider] Auth ready, загрузка ролей для сообщества:', community)
loadRoles(community).catch((err) => { loadRoles(community).catch((err) => {
console.warn('Не удалось загрузить роли для сообщества:', 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 // biome-ignore lint/suspicious/noExplicitAny: grahphql
queryGraphQL: async (queryStr: string, variables?: Record<string, any>) => { queryGraphQL: async (queryStr: string, variables?: Record<string, any>) => {
try { 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) return await query(`${location.origin}/graphql`, queryStr, variables)
} catch (error) { } catch (error) {
console.error('Ошибка выполнения GraphQL запроса:', error) console.error('Ошибка выполнения GraphQL запроса:', error)

View File

@@ -3,27 +3,18 @@
* @module api * @module api
*/ */
import { AUTH_TOKEN_KEY, clearAuthTokens, getAuthTokenFromCookie } from '../utils/auth' import {
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 Объект с заголовками
@@ -52,21 +43,15 @@ function getRequestHeaders(): Record<string, string> {
// Добавляем CSRF-токен, если он есть // Добавляем CSRF-токен, если он есть
const csrfToken = getCsrfTokenFromCookie() const csrfToken = getCsrfTokenFromCookie()
if (csrfToken) { if (csrfToken) {
headers[CSRF_HEADER_NAME] = csrfToken headers['X-CSRF-Token'] = csrfToken
console.debug('Добавлен CSRF-токен в запрос') console.debug('Добавлен CSRF-токен в запрос')
} }
return headers return headers
} }
interface GraphQLError extends Error {
extensions?: {
code?: string
}
}
/** /**
* Выполняет GraphQL запрос * Выполняет GraphQL запрос с retry логикой для 503 ошибок
* @param endpoint - URL эндпоинта GraphQL * @param endpoint - URL эндпоинта GraphQL
* @param query - GraphQL запрос * @param query - GraphQL запрос
* @param variables - Переменные запроса * @param variables - Переменные запроса
@@ -77,83 +62,93 @@ export async function query<T = unknown>(
query: string, query: string,
variables?: Record<string, unknown> variables?: Record<string, unknown>
): Promise<T> { ): Promise<T> {
try { const maxRetries = 3
console.log(`[GraphQL] Making request to ${endpoint}`) const retryDelay = 500 // 500ms базовая задержка
console.log(`[GraphQL] Query: ${query.substring(0, 100)}...`)
// Используем существующую функцию для получения всех необходимых заголовков for (let attempt = 1; attempt <= maxRetries; attempt++) {
const headers = getRequestHeaders() try {
console.log( console.log(`[GraphQL] Making request to ${endpoint} (attempt ${attempt}/${maxRetries})`)
`[GraphQL] Заголовки установлены, Authorization: ${headers['Authorization'] ? 'присутствует' : 'отсутствует'}` console.log(`[GraphQL] Query: ${query.substring(0, 100)}...`)
)
const response = await fetch(endpoint, { // Используем существующую функцию для получения всех необходимых заголовков
method: 'POST', const headers = getRequestHeaders()
headers, console.log(
credentials: 'include', `[GraphQL] Заголовки установлены, Authorization: ${headers['Authorization'] ? 'присутствует' : 'отсутствует'}`
body: JSON.stringify({ )
query,
variables 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-ошибок авторизации // Если получили 503 и это не последняя попытка, повторяем запрос
if (response.status === 401) { if (response.status === 503 && attempt < maxRetries) {
console.log('[GraphQL] Unauthorized response, clearing auth tokens') const delay = retryDelay * attempt // Экспоненциальная задержка
clearAuthTokens() console.log(`[GraphQL] Got 503 error, retrying after ${delay}ms...`)
// Перенаправляем на страницу входа только если мы не на ней await new Promise((resolve) => setTimeout(resolve, delay))
if (!window.location.pathname.includes('/login')) { continue
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}`)
} }
// Обработка других GraphQL-ошибок if (!response.ok) {
const unauthorizedError = result.errors.find( if (response.status === 401) {
(error: GraphQLError) => console.log('[GraphQL] Unauthorized response, clearing auth tokens')
error.message.toLowerCase().includes('unauthorized') || clearAuthTokens()
error.message.toLowerCase().includes('please login') // Перенаправляем на страницу входа только если мы не на ней
) if (!window.location.pathname.includes('/login')) {
window.location.href = '/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') 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 if (result.errors) {
} catch (error) { // Проверяем ошибки авторизации
console.error('[GraphQL] Query error:', error) const hasUnauthorized = result.errors.some(
throw error (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')
} }
/** /**

View File

@@ -9,11 +9,23 @@ import AdminPage from '../routes/admin'
export const ProtectedRoute = () => { export const ProtectedRoute = () => {
console.log('[ProtectedRoute] Checking authentication...') console.log('[ProtectedRoute] Checking authentication...')
const auth = useAuth() const auth = useAuth()
const isReady = auth.isReady()
const authenticated = auth.isAuthenticated() 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 (
<div class="loading-screen">
<div class="loading-spinner" />
<div>Инициализация авторизации...</div>
</div>
)
}
// Если авторизация готова, но пользователь не аутентифицирован
if (!authenticated) { if (!authenticated) {
console.log('[ProtectedRoute] Not authenticated, redirecting to login...') console.log('[ProtectedRoute] Not authenticated, redirecting to login...')
// Используем window.location.href для редиректа // Используем window.location.href для редиректа
@@ -21,11 +33,12 @@ export const ProtectedRoute = () => {
return ( return (
<div class="loading-screen"> <div class="loading-screen">
<div class="loading-spinner" /> <div class="loading-spinner" />
<div>Проверка авторизации...</div> <div>Перенаправление на страницу входа...</div>
</div> </div>
) )
} }
console.log('[ProtectedRoute] Auth ready and authenticated, rendering admin panel...')
return ( return (
<DataProvider> <DataProvider>
<TableSortProvider> <TableSortProvider>

View File

@@ -6,14 +6,9 @@ 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
# Создаем основные типы # Создаем основные типы
@@ -83,79 +78,3 @@ 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)