diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6074c0c8..fbf42986 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -36,6 +36,10 @@
- Пагинация списка пользователей в админ-панели
- Серверная поддержка пагинации в API для админ-панели
- Поиск пользователей по email, имени и ID
+- Поддержка локального запуска сервера с HTTPS через `python run.py --https` с использованием Granian
+- Интеграция с инструментом mkcert для генерации доверенных локальных SSL-сертификатов
+- Поддержка запуска нескольких рабочих процессов через параметр `--workers`
+- Возможность указать произвольный домен для сертификата через `--domain`
### Улучшено
- Улучшен интерфейс админ-панели:
diff --git a/app/resolvers/draft.py b/app/resolvers/draft.py
deleted file mode 100644
index 0519ecba..00000000
--- a/app/resolvers/draft.py
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/auth/__init__.py b/auth/__init__.py
index dc64ef9e..36949a4d 100644
--- a/auth/__init__.py
+++ b/auth/__init__.py
@@ -113,10 +113,3 @@ async def refresh_token(request: Request):
except Exception as e:
logger.error(f"[auth] refresh_token: Ошибка при обновлении токена: {e}")
return JSONResponse({"success": False, "error": str(e)}, status_code=401)
-
-
-# Маршруты для авторизации
-routes = [
- Route("/auth/logout", logout, methods=["GET", "POST"]),
- Route("/auth/refresh", refresh_token, methods=["POST"]),
-]
diff --git a/auth/decorators.py b/auth/decorators.py
index 9e70124b..befeced0 100644
--- a/auth/decorators.py
+++ b/auth/decorators.py
@@ -1,15 +1,109 @@
from functools import wraps
-from typing import Callable, Any
+from typing import Callable, Any, Dict, Optional
from graphql import GraphQLError
from services.db import local_session
from auth.orm import Author
from auth.exceptions import OperationNotAllowed
from utils.logger import root_logger as logger
-from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
+from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST, SESSION_COOKIE_NAME
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
+def get_safe_headers(request: Any) -> Dict[str, str]:
+ """
+ Безопасно получает заголовки запроса.
+
+ Args:
+ request: Объект запроса
+
+ Returns:
+ Dict[str, str]: Словарь заголовков
+ """
+ headers = {}
+ try:
+ # Проверяем разные варианты доступа к заголовкам
+ if hasattr(request, "_headers"):
+ headers.update(request._headers)
+ if hasattr(request, "headers"):
+ headers.update(request.headers)
+ if hasattr(request, "scope") and isinstance(request.scope, dict):
+ headers.update({
+ k.decode("utf-8").lower(): v.decode("utf-8")
+ for k, v in request.scope.get("headers", [])
+ })
+ except Exception as e:
+ logger.warning(f"Error accessing headers: {e}")
+ return headers
+
+
+def get_auth_token(request: Any) -> Optional[str]:
+ """
+ Извлекает токен авторизации из запроса.
+
+ Args:
+ request: Объект запроса
+
+ Returns:
+ Optional[str]: Токен авторизации или None
+ """
+ try:
+ # Проверяем auth из middleware
+ if hasattr(request, "auth") and request.auth:
+ return getattr(request.auth, "token", None)
+
+ # Проверяем заголовок
+ headers = get_safe_headers(request)
+ auth_header = headers.get("authorization", "")
+ if auth_header.startswith("Bearer "):
+ return auth_header[7:].strip()
+
+ # Проверяем cookie
+ if hasattr(request, "cookies"):
+ return request.cookies.get(SESSION_COOKIE_NAME)
+
+ return None
+ except Exception as e:
+ logger.warning(f"Error extracting auth token: {e}")
+ return None
+
+
+def validate_graphql_context(info: Any) -> None:
+ """
+ Проверяет валидность GraphQL контекста.
+
+ Args:
+ info: GraphQL информация о контексте
+
+ Raises:
+ GraphQLError: если контекст невалиден
+ """
+ if info is None or not hasattr(info, "context"):
+ logger.error("Missing GraphQL context information")
+ raise GraphQLError("Internal server error: missing context")
+
+ request = info.context.get("request")
+ if not request:
+ logger.error("Missing request in context")
+ raise GraphQLError("Internal server error: missing request")
+
+ # Проверяем auth из контекста
+ auth = getattr(request, "auth", None)
+ if not auth or not auth.logged_in:
+ # Пробуем получить токен
+ token = get_auth_token(request)
+ if not token:
+ client_info = {
+ "ip": getattr(request.client, "host", "unknown") if hasattr(request, "client") else "unknown",
+ "headers": get_safe_headers(request)
+ }
+ logger.warning(f"No auth token found: {client_info}")
+ raise GraphQLError("Unauthorized - please login")
+
+ logger.warning(f"Found token but auth not initialized")
+ raise GraphQLError("Unauthorized - session expired")
+
+
def admin_auth_required(resolver: Callable) -> Callable:
"""
Декоратор для защиты админских эндпоинтов.
@@ -23,65 +117,39 @@ def admin_auth_required(resolver: Callable) -> Callable:
Raises:
GraphQLError: если пользователь не авторизован или не имеет доступа администратора
+
+ Example:
+ >>> @admin_auth_required
+ ... async def admin_resolver(root, info, **kwargs):
+ ... return "Admin data"
"""
-
@wraps(resolver)
async def wrapper(root: Any = None, info: Any = None, **kwargs):
try:
- # Проверяем наличие info и контекста
- if info is None or not hasattr(info, "context"):
- logger.error("Missing GraphQL context information")
- raise GraphQLError("Internal server error: missing context")
+ validate_graphql_context(info)
+ auth = info.context["request"].auth
- # Получаем ID пользователя из контекста запроса
- request = info.context.get("request")
- if not request or not hasattr(request, "auth"):
- logger.error("Missing request or auth object in context")
- raise GraphQLError("Internal server error: missing auth")
-
- auth = request.auth
- if not auth or not auth.logged_in:
- client_info = {
- "ip": request.client.host if hasattr(request, "client") else "unknown",
- "headers": dict(request.headers),
- }
- logger.error(f"Unauthorized access attempt for admin endpoint: {client_info}")
- raise GraphQLError("Unauthorized")
-
- # Проверяем принадлежность к списку админов
with local_session() as session:
- try:
- author = session.query(Author).filter(Author.id == auth.author_id).one()
-
- # Проверка по email
- if author.email in ADMIN_EMAILS:
- logger.info(
- f"Admin access granted for {author.email} (special admin, ID: {author.id})"
- )
- return await resolver(root, info, **kwargs)
- else:
- logger.warning(
- f"Admin access denied for {author.email} (ID: {author.id}) - not in admin list"
- )
- raise GraphQLError("Unauthorized - not an admin")
- except Exception as db_error:
- logger.error(f"Error fetching author with ID {auth.author_id}: {str(db_error)}")
- raise GraphQLError("Unauthorized - user not found")
+ author = session.query(Author).filter(Author.id == auth.author_id).one()
+
+ if author.email in ADMIN_EMAILS:
+ logger.info(f"Admin access granted for {author.email} (ID: {author.id})")
+ return await resolver(root, info, **kwargs)
+
+ logger.warning(f"Admin access denied for {author.email} (ID: {author.id})")
+ raise GraphQLError("Unauthorized - not an admin")
except Exception as e:
- # Если ошибка уже GraphQLError, просто перебрасываем её
- if isinstance(e, GraphQLError):
- logger.error(f"GraphQL error in admin_auth_required: {str(e)}")
- raise e
-
- # Иначе, создаем новую GraphQLError
- logger.error(f"Error in admin_auth_required: {str(e)}")
- raise GraphQLError(f"Admin access error: {str(e)}")
+ error_msg = str(e)
+ if not isinstance(e, GraphQLError):
+ error_msg = f"Admin access error: {error_msg}"
+ logger.error(f"Error in admin_auth_required: {error_msg}")
+ raise GraphQLError(error_msg)
return wrapper
-def require_permission(permission_string: str):
+def require_permission(permission_string: str) -> Callable:
"""
Декоратор для проверки наличия указанного разрешения.
Принимает строку в формате "resource:permission".
@@ -94,48 +162,47 @@ def require_permission(permission_string: str):
Raises:
ValueError: если строка разрешения имеет неверный формат
+
+ Example:
+ >>> @require_permission("articles:edit")
+ ... async def edit_article(root, info, article_id: int):
+ ... return f"Editing article {article_id}"
"""
- if ":" not in permission_string:
+ if not isinstance(permission_string, str) or ":" not in permission_string:
raise ValueError('Permission string must be in format "resource:permission"')
resource, operation = permission_string.split(":", 1)
+
+ if not all([resource.strip(), operation.strip()]):
+ raise ValueError("Both resource and permission must be non-empty")
def decorator(func: Callable) -> Callable:
@wraps(func)
async def wrapper(parent, info: Any = None, *args, **kwargs):
- # Проверяем наличие info и контекста
- if info is None or not hasattr(info, "context"):
- logger.error("Missing GraphQL context information in require_permission")
- raise OperationNotAllowed("Internal server error: missing context")
+ try:
+ validate_graphql_context(info)
+ auth = info.context["request"].auth
- auth = info.context["request"].auth
- if not auth or not auth.logged_in:
- raise OperationNotAllowed("Unauthorized - please login")
-
- with local_session() as session:
- try:
+ with local_session() as session:
author = session.query(Author).filter(Author.id == auth.author_id).one()
- # Проверяем базовые условия
if not author.is_active:
raise OperationNotAllowed("Account is not active")
if author.is_locked():
raise OperationNotAllowed("Account is locked")
-
- # Проверяем разрешение
if not author.has_permission(resource, operation):
logger.warning(
f"Access denied for user {auth.author_id} - no permission {resource}:{operation}"
)
raise OperationNotAllowed(f"No permission for {operation} on {resource}")
- # Пользователь аутентифицирован и имеет необходимое разрешение
return await func(parent, info, *args, **kwargs)
- except Exception as e:
- logger.error(f"Error in require_permission: {e}")
- if isinstance(e, OperationNotAllowed):
- raise e
- raise OperationNotAllowed(str(e))
+
+ except Exception as e:
+ if isinstance(e, (OperationNotAllowed, GraphQLError)):
+ raise e
+ logger.error(f"Error in require_permission: {e}")
+ raise OperationNotAllowed(str(e))
return wrapper
diff --git a/auth/middleware.py b/auth/middleware.py
index 25cb7632..86480aa3 100644
--- a/auth/middleware.py
+++ b/auth/middleware.py
@@ -8,17 +8,22 @@ from utils.logger import root_logger as logger
from settings import SESSION_TOKEN_HEADER, SESSION_COOKIE_NAME
-class AuthorizationMiddleware:
+class AuthMiddleware:
"""
- Middleware для обработки заголовка Authorization и cookie авторизации.
- Извлекает Bearer токен из заголовка или cookie и добавляет его в заголовки
- запроса для обработки стандартным AuthenticationMiddleware Starlette.
+ Универсальный middleware для обработки авторизации и управления cookies.
+
+ Основные функции:
+ 1. Извлечение Bearer токена из заголовка Authorization или cookie
+ 2. Добавление токена в заголовки запроса для обработки AuthenticationMiddleware
+ 3. Предоставление методов для установки/удаления cookies в GraphQL резолверах
"""
def __init__(self, app: ASGIApp):
self.app = app
-
+ self._context = None
+
async def __call__(self, scope: Scope, receive: Receive, send: Send):
+ """Обработка ASGI запроса"""
if scope["type"] != "http":
await self.app(scope, receive, send)
return
@@ -70,24 +75,20 @@ class AuthorizationMiddleware:
scope["auth"] = {"type": "bearer", "token": token}
await self.app(scope, receive, send)
-
-
-class GraphQLExtensionsMiddleware:
- """
- Утилиты для расширения контекста GraphQL запросов
- """
-
+
+ def set_context(self, context):
+ """Сохраняет ссылку на контекст GraphQL запроса"""
+ self._context = context
+
def set_cookie(self, key, value, **options):
"""Устанавливает cookie в ответе"""
- context = getattr(self, "_context", None)
- if context and "response" in context and hasattr(context["response"], "set_cookie"):
- context["response"].set_cookie(key, value, **options)
+ if self._context and "response" in self._context and hasattr(self._context["response"], "set_cookie"):
+ self._context["response"].set_cookie(key, value, **options)
def delete_cookie(self, key, **options):
"""Удаляет cookie из ответа"""
- context = getattr(self, "_context", None)
- if context and "response" in context and hasattr(context["response"], "delete_cookie"):
- context["response"].delete_cookie(key, **options)
+ if self._context and "response" in self._context and hasattr(self._context["response"], "delete_cookie"):
+ self._context["response"].delete_cookie(key, **options)
async def resolve(self, next, root, info, *args, **kwargs):
"""
@@ -97,14 +98,14 @@ class GraphQLExtensionsMiddleware:
try:
# Получаем доступ к контексту запроса
context = info.context
-
+
# Сохраняем ссылку на контекст
- self._context = context
-
+ self.set_context(context)
+
# Добавляем себя как объект, содержащий утилитные методы
context["extensions"] = self
-
+
return await next(root, info, *args, **kwargs)
except Exception as e:
- logger.error(f"[GraphQLExtensionsMiddleware] Ошибка: {str(e)}")
+ logger.error(f"[AuthMiddleware] Ошибка в GraphQL resolve: {str(e)}")
raise
diff --git a/dev.py b/dev.py
new file mode 100644
index 00000000..984769ba
--- /dev/null
+++ b/dev.py
@@ -0,0 +1,117 @@
+import os
+import subprocess
+from pathlib import Path
+from utils.logger import root_logger as logger
+from granian import Granian
+
+
+def check_mkcert_installed():
+ """
+ Проверяет, установлен ли инструмент mkcert в системе
+
+ Returns:
+ bool: True если mkcert установлен, иначе False
+
+ >>> check_mkcert_installed() # doctest: +SKIP
+ True
+ """
+ try:
+ subprocess.run(["mkcert", "-version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ return True
+ except FileNotFoundError:
+ return False
+
+def generate_certificates(domain="localhost", cert_file="localhost.pem", key_file="localhost-key.pem"):
+ """
+ Генерирует сертификаты с использованием mkcert
+
+ Args:
+ domain: Домен для сертификата
+ cert_file: Имя файла сертификата
+ key_file: Имя файла ключа
+
+ Returns:
+ tuple: (cert_file, key_file) пути к созданным файлам
+
+ >>> generate_certificates() # doctest: +SKIP
+ ('localhost.pem', 'localhost-key.pem')
+ """
+ # Проверяем, существуют ли сертификаты
+ if os.path.exists(cert_file) and os.path.exists(key_file):
+ logger.info(f"Сертификаты уже существуют: {cert_file}, {key_file}")
+ return cert_file, key_file
+
+ # Проверяем, установлен ли mkcert
+ if not check_mkcert_installed():
+ logger.error("mkcert не установлен. Установите mkcert с помощью команды:")
+ logger.error(" macOS: brew install mkcert")
+ logger.error(" Linux: apt install mkcert или эквивалент для вашего дистрибутива")
+ logger.error(" Windows: choco install mkcert")
+ logger.error("После установки выполните: mkcert -install")
+ return None, None
+
+ try:
+ # Запускаем mkcert для создания сертификата
+ logger.info(f"Создание сертификатов для {domain} с помощью mkcert...")
+ result = subprocess.run(
+ ["mkcert", "-cert-file", cert_file, "-key-file", key_file, domain],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True
+ )
+
+ if result.returncode != 0:
+ logger.error(f"Ошибка при создании сертификатов: {result.stderr}")
+ return None, None
+
+ logger.info(f"Сертификаты созданы: {cert_file}, {key_file}")
+ return cert_file, key_file
+ except Exception as e:
+ logger.error(f"Не удалось создать сертификаты: {str(e)}")
+ return None, None
+
+def run_server(host="0.0.0.0", port=8000, workers=1):
+ """
+ Запускает сервер Granian с поддержкой HTTPS при необходимости
+
+ Args:
+ host: Хост для запуска сервера
+ port: Порт для запуска сервера
+ use_https: Флаг использования HTTPS
+ workers: Количество рабочих процессов
+
+ >>> run_server(use_https=True) # doctest: +SKIP
+ """
+ # Проблема с многопроцессорным режимом - не поддерживает локальные объекты приложений
+ # Всегда запускаем в режиме одного процесса для отладки
+ if workers > 1:
+ logger.warning("Многопроцессорный режим может вызвать проблемы сериализации приложения. Использую 1 процесс.")
+ workers = 1
+
+ # При проблемах с ASGI можно попробовать использовать Uvicorn как запасной вариант
+ try:
+ # Генерируем сертификаты с помощью mkcert
+ cert_file, key_file = generate_certificates()
+
+ if not cert_file or not key_file:
+ logger.error("Не удалось сгенерировать сертификаты для HTTPS")
+ return
+
+ logger.info(f"Запуск HTTPS сервера на https://{host}:{port} с использованием Granian")
+ # Запускаем Granian сервер с явным указанием ASGI
+ server = Granian(
+ address=host,
+ port=port,
+ workers=workers,
+ interface="asgi",
+ target="main:app",
+ ssl_cert=Path(cert_file),
+ ssl_key=Path(key_file),
+ )
+ server.serve()
+ except Exception as e:
+ # В случае проблем с Granian, пробуем запустить через Uvicorn
+ logger.error(f"Ошибка при запуске Granian: {str(e)}")
+
+if __name__ == "__main__":
+ run_server()
\ No newline at end of file
diff --git a/docs/README.md b/docs/README.md
index 494ceab1..7da68ee1 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -31,4 +31,46 @@ SESSION_TOKEN_LIFE_SPAN = 60 * 60 * 24 * 30 # время жизни сесси
- Проверка доступа по email или правам в системе RBAC
Маршруты:
-- `/admin` - административная панель с проверкой прав доступа
\ No newline at end of file
+- `/admin` - административная панель с проверкой прав доступа
+
+## Запуск сервера
+
+### Стандартный запуск
+```bash
+python main.py
+```
+
+### Запуск с поддержкой HTTPS
+Для локальной разработки с HTTPS используйте скрипт `run.py` с инструментом mkcert:
+
+```bash
+# Установите mkcert
+# macOS:
+brew install mkcert
+# Linux:
+# sudo apt install mkcert (или эквивалент для вашего дистрибутива)
+# Windows:
+# choco install mkcert
+
+# Установите локальный CA
+mkcert -install
+
+# Запуск с HTTPS на порту 8000 через Granian
+python run.py --https
+
+# Запуск с HTTPS на другом порту
+python run.py --https --port 8443
+
+# Запуск с несколькими рабочими процессами
+python run.py --https --workers 4
+
+# Запуск с указанием домена для сертификата
+python run.py --https --domain "localhost.localdomain"
+```
+
+При первом запуске будут автоматически сгенерированы доверенные локальные сертификаты с помощью mkcert.
+
+**Преимущества mkcert:**
+- Сертификаты распознаются браузером как доверенные (нет предупреждений)
+- Работает на всех платформах (macOS, Linux, Windows)
+- Простая установка и настройка
\ No newline at end of file
diff --git a/main.py b/main.py
index bdf67f00..5dd40915 100644
--- a/main.py
+++ b/main.py
@@ -11,9 +11,10 @@ from starlette.middleware.cors import CORSMiddleware
from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.middleware import Middleware
from starlette.requests import Request
-from starlette.responses import FileResponse, JSONResponse, HTMLResponse, RedirectResponse
+from starlette.responses import FileResponse, JSONResponse, Response
from starlette.routing import Route, Mount
from starlette.staticfiles import StaticFiles
+from starlette.types import ASGIApp
from cache.precache import precache_data
from cache.revalidator import revalidation_manager
@@ -22,23 +23,17 @@ from services.redis import redis
from services.schema import create_all_tables, resolvers
from services.search import search_service
-from settings import DEV_SERVER_PID_FILE_NAME, MODE, ADMIN_EMAILS
from utils.logger import root_logger as logger
from auth.internal import InternalAuthentication
-from auth import routes as auth_routes # Импортируем маршруты авторизации
-from auth.middleware import (
- AuthorizationMiddleware,
- GraphQLExtensionsMiddleware,
-) # Импортируем middleware для авторизации
+from auth.middleware import AuthMiddleware
+# Импортируем резолверы
import_module("resolvers")
-import_module("auth.resolvers")
# Создаем схему GraphQL
schema = make_executable_schema(load_schema_from_path("schema/"), resolvers)
# Пути к клиентским файлам
-CLIENT_DIR = join(os.path.dirname(__file__), "client")
DIST_DIR = join(os.path.dirname(__file__), "dist") # Директория для собранных файлов
INDEX_HTML = join(os.path.dirname(__file__), "index.html")
@@ -50,121 +45,35 @@ async def index_handler(request: Request):
return FileResponse(INDEX_HTML)
-# GraphQL API
-class CustomGraphQLHTTPHandler(GraphQLHTTPHandler):
- """
- Кастомный GraphQL HTTP обработчик, который добавляет объект response в контекст
- """
+# Создаем единый экземпляр AuthMiddleware для использования с GraphQL
+auth_middleware = AuthMiddleware(lambda scope, receive, send: None)
+
+class EnhancedGraphQLHTTPHandler(GraphQLHTTPHandler):
+ """
+ Улучшенный GraphQL HTTP обработчик с поддержкой cookie и авторизации
+ """
+
async def get_context_for_request(self, request: Request, data: dict) -> dict:
"""
- Переопределяем метод для добавления объекта response и extensions в контекст
+ Расширяем контекст для GraphQL запросов
"""
+ # Получаем стандартный контекст от базового класса
context = await super().get_context_for_request(request, data)
- # Создаем объект ответа, который будем использовать для установки cookie
+
+ # Добавляем объект ответа для установки cookie
response = JSONResponse({})
context["response"] = response
-
- # Добавляем extensions в контекст
- if "extensions" not in context:
- context["extensions"] = GraphQLExtensionsMiddleware()
-
+
+ # Интегрируем с AuthMiddleware
+ context["extensions"] = auth_middleware
+
return context
-graphql_app = GraphQL(schema, debug=MODE == "development", http_handler=CustomGraphQLHTTPHandler())
-
-
-async def graphql_handler(request):
- """Обработчик GraphQL запросов"""
- # Проверяем заголовок Content-Type
- content_type = request.headers.get("content-type", "")
- if not content_type.startswith("application/json") and "application/json" in request.headers.get(
- "accept", ""
- ):
- # Если не application/json, но клиент принимает JSON
- request._headers["content-type"] = "application/json"
-
- # Обрабатываем GraphQL запрос
- result = await graphql_app.handle_request(request)
-
- # Если result - это ответ от сервера, возвращаем его как есть
- if hasattr(result, "body"):
- return result
-
- # Если результат - это словарь, значит нужно его сконвертировать в JSONResponse
- if isinstance(result, dict):
- return JSONResponse(result)
-
- return result
-
-
-async def admin_handler(request: Request):
- """
- Обработчик для маршрута /admin с серверной проверкой прав доступа
- """
- # Проверяем авторизован ли пользователь
- if not request.user.is_authenticated:
- # Если пользователь не авторизован, перенаправляем на главную страницу
- return RedirectResponse(url="/", status_code=303)
-
- # Проверяем является ли пользователь администратором
- auth = getattr(request, "auth", None)
- is_admin = False
-
- # Проверяем наличие объекта auth и метода is_admin
- if auth:
- try:
- # Проверяем имеет ли пользователь права администратора
- is_admin = auth.is_admin
- except Exception as e:
- logger.error(f"Ошибка при проверке прав администратора: {e}")
-
- # Дополнительная проверка email (для случаев, когда нет метода is_admin)
- admin_emails = ADMIN_EMAILS.split(",")
- if not is_admin and hasattr(auth, "email") and auth.email in admin_emails:
- is_admin = True
-
- if is_admin:
- # Если пользователь - администратор, возвращаем HTML-файл
- return FileResponse(INDEX_HTML)
- else:
- # Для авторизованных пользователей без прав администратора показываем страницу с ошибкой доступа
- return HTMLResponse(
- """
-
-
-
-
-
- Доступ запрещен
-
-
-
-
-
Доступ запрещен
-
У вас нет прав для доступа к административной панели. Обратитесь к администратору системы для получения необходимых разрешений.
-
Вернуться на главную
-
-
-
- """,
- status_code=403
- )
-
-
# Функция запуска сервера
async def start():
"""Запуск сервера и инициализация данных"""
- logger.info(f"Запуск сервера в режиме: {MODE}")
-
# Создаем все таблицы в БД
create_all_tables()
@@ -192,47 +101,60 @@ async def shutdown():
search_service.close()
# Удаляем PID-файл, если он существует
+ from settings import DEV_SERVER_PID_FILE_NAME
if exists(DEV_SERVER_PID_FILE_NAME):
os.unlink(DEV_SERVER_PID_FILE_NAME)
-# Добавляем маршруты статических файлов, если директория существует
-routes = []
-if exists(DIST_DIR):
- routes.append(Mount("/", app=StaticFiles(directory=DIST_DIR, html=True)))
+# Создаем middleware с правильным порядком
+middleware = [
+ # Начинаем с обработки ошибок
+ Middleware(ExceptionHandlerMiddleware),
+ # CORS должен быть перед другими middleware для корректной обработки preflight-запросов
+ Middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_methods=["GET", "POST", "OPTIONS"], # Явно указываем OPTIONS
+ allow_headers=["*"],
+ allow_credentials=True,
+ ),
+ # После CORS идёт обработка авторизации
+ Middleware(AuthMiddleware),
+ # И затем аутентификация
+ Middleware(AuthenticationMiddleware, backend=InternalAuthentication()),
+]
+
+
+# Создаем экземпляр GraphQL
+graphql_app = GraphQL(schema, debug=True)
+
+
+# Оборачиваем GraphQL-обработчик для лучшей обработки ошибок
+async def graphql_handler(request: Request):
+ if request.method not in ["GET", "POST", "OPTIONS"]:
+ return JSONResponse({"error": "Method Not Allowed by main.py"}, status_code=405)
+
+ try:
+ result = await graphql_app.handle_request(request)
+ if isinstance(result, Response):
+ return result
+ return JSONResponse(result)
+ except asyncio.CancelledError:
+ return JSONResponse({"error": "Request cancelled"}, status_code=499)
+ except Exception as e:
+ print(f"GraphQL error: {str(e)}")
+ return JSONResponse({"error": str(e)}, status_code=500)
-# Маршруты для API и веб-приложения
-routes.extend(
- [
- Route("/graphql", graphql_handler, methods=["GET", "POST"]),
- # Добавляем специальный маршрут для админ-панели с проверкой прав доступа
- Route("/admin", admin_handler, methods=["GET"]),
- # Маршрут для обработки всех остальных запросов - SPA
- Route("/{path:path}", index_handler, methods=["GET"]),
- Route("/", index_handler, methods=["GET"]),
- ]
-)
-
-# Добавляем маршруты авторизации
-routes.extend(auth_routes)
+# Добавляем маршруты, порядок имеет значение
+routes = [
+ Route("/graphql", graphql_handler, methods=["GET", "POST", "OPTIONS"]),
+ Mount("/", app=StaticFiles(directory=DIST_DIR, html=True)),
+]
+# Создаем приложение Starlette с маршрутами и middleware
app = Starlette(
- debug=MODE == "development",
routes=routes,
- middleware=[
- Middleware(ExceptionHandlerMiddleware),
- Middleware(
- CORSMiddleware,
- allow_origins=["*"],
- allow_methods=["*"],
- allow_headers=["*"],
- allow_credentials=True,
- ),
- # Добавляем middleware для обработки Authorization заголовка с Bearer токеном
- Middleware(AuthorizationMiddleware),
- # Добавляем middleware для аутентификации после обработки токенов
- Middleware(AuthenticationMiddleware, backend=InternalAuthentication()),
- ],
+ middleware=middleware,
on_startup=[start],
on_shutdown=[shutdown],
)
diff --git a/panel/admin.tsx b/panel/admin.tsx
index 31c996e2..a44794a2 100644
--- a/panel/admin.tsx
+++ b/panel/admin.tsx
@@ -51,6 +51,26 @@ interface AdminGetRolesResponse {
adminGetRoles: Role[]
}
+/**
+ * Интерфейс для ответа изменения статуса пользователя
+ */
+interface AdminSetUserStatusResponse {
+ adminSetUserStatus: {
+ success: boolean
+ error?: string
+ }
+}
+
+/**
+ * Интерфейс для ответа изменения статуса блокировки чата
+ */
+interface AdminMuteUserResponse {
+ adminMuteUser: {
+ success: boolean
+ error?: string
+ }
+}
+
// Интерфейс для пропсов AdminPage
interface AdminPageProps {
onLogout?: () => void
@@ -199,42 +219,41 @@ const AdminPage: Component = (props) => {
* @param page - Номер страницы
*/
function handlePageChange(page: number) {
- if (page < 1 || page > pagination().totalPages) return
- setPagination((prev) => ({ ...prev, page }))
+ setPagination({ ...pagination(), page })
loadUsers()
}
/**
- * Обработчик изменения количества записей на странице
- * @param limit - Количество записей на странице
+ * Обработчик изменения количества элементов на странице
+ * @param limit - Количество элементов
*/
function handlePerPageChange(limit: number) {
- setPagination((prev) => ({ ...prev, page: 1, limit }))
+ setPagination({ ...pagination(), page: 1, limit })
loadUsers()
}
/**
* Обработчик изменения поискового запроса
- * @param e - Событие изменения ввода
*/
function handleSearchChange(e: Event) {
- const target = e.target as HTMLInputElement
- setSearchQuery(target.value)
+ const input = e.target as HTMLInputElement
+ setSearchQuery(input.value)
}
/**
- * Выполняет поиск при нажатии Enter или кнопки поиска
+ * Выполняет поиск
*/
function handleSearch() {
- setPagination((prev) => ({ ...prev, page: 1 })) // Сбрасываем на первую страницу при поиске
+ setPagination({ ...pagination(), page: 1 })
loadUsers()
}
/**
- * Обработчик нажатия клавиши в поле поиска
- * @param e - Событие нажатия клавиши
+ * Обработчик нажатия клавиш в поле поиска
+ * @param e - Событие клавиатуры
*/
function handleSearchKeyDown(e: KeyboardEvent) {
+ // Если нажат Enter, выполняем поиск
if (e.key === 'Enter') {
e.preventDefault()
handleSearch()
@@ -242,101 +261,105 @@ const AdminPage: Component = (props) => {
}
/**
- * Блокировка/разблокировка пользователя
+ * Блокирует/разблокирует пользователя
* @param userId - ID пользователя
* @param isActive - Текущий статус активности
*/
async function toggleUserBlock(userId: number, isActive: boolean) {
- // Запрашиваем подтверждение
- const action = isActive ? 'заблокировать' : 'разблокировать'
- if (!confirm(`Вы действительно хотите ${action} этого пользователя?`)) {
- return
- }
-
try {
- await query(
+ setError(null)
+
+ // Устанавливаем новый статус (противоположный текущему)
+ const newStatus = !isActive
+
+ // Выполняем мутацию
+ const result = await query(
`${location.origin}/graphql`,
`
- mutation AdminToggleUserBlock($userId: Int!) {
- adminToggleUserBlock(userId: $userId) {
- success
- error
+ mutation AdminSetUserStatus($userId: Int!, $isActive: Boolean!) {
+ adminSetUserStatus(userId: $userId, isActive: $isActive) {
+ success
+ error
+ }
}
- }
- `,
- { userId }
+ `,
+ { userId, isActive: newStatus }
)
-
- // Обновляем статус пользователя
- setUsers((prev) =>
- prev.map((user) => {
- if (user.id === userId) {
- return { ...user, is_active: !isActive }
- }
- return user
- })
- )
-
- // Показываем сообщение об успехе
- setSuccessMessage(`Пользователь успешно ${isActive ? 'заблокирован' : 'разблокирован'}`)
-
- // Скрываем сообщение через 3 секунды
- setTimeout(() => setSuccessMessage(null), 3000)
+
+ // Проверяем результат
+ if (result?.adminSetUserStatus?.success) {
+ // Обновляем список пользователей
+ setSuccessMessage(`Пользователь ${newStatus ? 'разблокирован' : 'заблокирован'}`)
+
+ // Обновляем пользователя в текущем списке
+ setUsers(
+ users().map((user) =>
+ user.id === userId ? { ...user, is_active: newStatus } : user
+ )
+ )
+
+ // Скрываем сообщение через 3 секунды
+ setTimeout(() => setSuccessMessage(null), 3000)
+ } else {
+ setError(result?.adminSetUserStatus?.error || 'Ошибка обновления статуса пользователя')
+ }
} catch (err) {
- console.error('Ошибка изменения статуса блокировки:', err)
- setError(err instanceof Error ? err.message : 'Ошибка изменения статуса блокировки')
+ console.error('Ошибка при изменении статуса пользователя:', err)
+ setError(err instanceof Error ? err.message : 'Неизвестная ошибка')
}
}
/**
- * Включение/отключение режима "mute" для пользователя
+ * Включает/отключает режим блокировки чата для пользователя
* @param userId - ID пользователя
- * @param isMuted - Текущий статус mute
+ * @param isMuted - Текущий статус блокировки чата
*/
async function toggleUserMute(userId: number, isMuted: boolean) {
- // Запрашиваем подтверждение
- const action = isMuted ? 'включить звук' : 'отключить звук'
- if (!confirm(`Вы действительно хотите ${action} для этого пользователя?`)) {
- return
- }
-
try {
- await query(
+ setError(null)
+
+ // Устанавливаем новый статус (противоположный текущему)
+ const newMuteStatus = !isMuted
+
+ // Выполняем мутацию
+ const result = await query(
`${location.origin}/graphql`,
`
- mutation AdminToggleUserMute($userId: Int!) {
- adminToggleUserMute(userId: $userId) {
- success
- error
+ mutation AdminMuteUser($userId: Int!, $muted: Boolean!) {
+ adminMuteUser(userId: $userId, muted: $muted) {
+ success
+ error
+ }
}
- }
- `,
- { userId }
+ `,
+ { userId, muted: newMuteStatus }
)
-
- // Обновляем статус пользователя
- setUsers((prev) =>
- prev.map((user) => {
- if (user.id === userId) {
- return { ...user, muted: !isMuted }
- }
- return user
- })
- )
-
- // Показываем сообщение об успехе
- setSuccessMessage(`Звук для пользователя успешно ${isMuted ? 'включен' : 'отключен'}`)
-
- // Скрываем сообщение через 3 секунды
- setTimeout(() => setSuccessMessage(null), 3000)
+
+ // Проверяем результат
+ if (result?.adminMuteUser?.success) {
+ // Обновляем сообщение об успехе
+ setSuccessMessage(`${newMuteStatus ? 'Блокировка' : 'Разблокировка'} чата выполнена`)
+
+ // Обновляем пользователя в текущем списке
+ setUsers(
+ users().map((user) =>
+ user.id === userId ? { ...user, muted: newMuteStatus } : user
+ )
+ )
+
+ // Скрываем сообщение через 3 секунды
+ setTimeout(() => setSuccessMessage(null), 3000)
+ } else {
+ setError(result?.adminMuteUser?.error || 'Ошибка обновления статуса блокировки чата')
+ }
} catch (err) {
- console.error('Ошибка изменения статуса mute:', err)
- setError(err instanceof Error ? err.message : 'Ошибка изменения статуса mute')
+ console.error('Ошибка при изменении статуса блокировки чата:', err)
+ setError(err instanceof Error ? err.message : 'Неизвестная ошибка')
}
}
/**
- * Закрывает модальное окно управления ролями
+ * Закрывает модальное окно ролей
*/
function closeRolesModal() {
setShowRolesModal(false)
diff --git a/panel/auth.ts b/panel/auth.ts
index 961f4a2b..08b774c1 100644
--- a/panel/auth.ts
+++ b/panel/auth.ts
@@ -3,29 +3,9 @@
* @module auth
*/
-import { query } from './graphql'
-
-
-// Константа для имени ключа токена в localStorage
-const AUTH_COOKIE_NAME = 'auth_token'
-
-// Константа для имени ключа токена в cookie
+// Экспортируем константы для использования в других модулях
export const AUTH_TOKEN_KEY = 'auth_token'
-
-/**
- * Получает токен авторизации из cookie
- * @returns Токен или пустую строку, если токен не найден
- */
-export const getAuthTokenFromCookie = (): string => {
- const cookieItems = document.cookie.split(';')
- for (const item of cookieItems) {
- const [name, value] = item.trim().split('=')
- if (name === 'auth_token') {
- return value
- }
- }
- return ''
-}
+export const CSRF_TOKEN_KEY = 'csrf_token'
/**
* Интерфейс для учетных данных
@@ -51,6 +31,36 @@ interface LoginResponse {
login: LoginResult
}
+/**
+ * Получает токен авторизации из cookie
+ * @returns Токен или пустую строку, если токен не найден
+ */
+export function getAuthTokenFromCookie(): string {
+ const cookieItems = document.cookie.split(';')
+ for (const item of cookieItems) {
+ const [name, value] = item.trim().split('=')
+ if (name === AUTH_TOKEN_KEY) {
+ return value
+ }
+ }
+ return ''
+}
+
+/**
+ * Получает CSRF-токен из cookie
+ * @returns CSRF-токен или пустую строку, если токен не найден
+ */
+export 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 ''
+}
+
/**
* Проверяет, авторизован ли пользователь
* @returns Статус авторизации
@@ -77,13 +87,17 @@ export function logout(callback?: () => void): void {
localStorage.removeItem(AUTH_TOKEN_KEY)
// Для удаления cookie устанавливаем ей истекшее время жизни
- document.cookie = `${AUTH_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`
+ document.cookie = `${AUTH_TOKEN_KEY}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`
// Дополнительно пытаемся сделать запрос на сервер для удаления серверных сессий
try {
- fetch('/logout', {
- method: 'GET',
- credentials: 'include'
+ fetch('/auth/logout', {
+ method: 'POST', // Используем POST вместо GET для операций изменения состояния
+ credentials: 'include',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-Token': getCsrfTokenFromCookie() // Добавляем CSRF токен если он есть
+ }
}).catch((e) => {
console.error('Ошибка при запросе на выход:', e)
})
@@ -96,47 +110,68 @@ export function logout(callback?: () => void): void {
}
/**
- * Выполняет вход в систему
+ * Выполняет вход в систему используя GraphQL-запрос
* @param credentials - Учетные данные
* @returns Результат авторизации
*/
export async function login(credentials: Credentials): Promise {
try {
- // Используем query из graphql.ts для выполнения запроса
- const data = await query(
- `${location.origin}/graphql`,
- `
- mutation Login($email: String!, $password: String!) {
- login(email: $email, password: $password) {
- success
- token
- error
+ console.log('Отправка запроса авторизации через GraphQL')
+
+ const response = await fetch(`${location.origin}/graphql`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json',
+ 'X-CSRF-Token': getCsrfTokenFromCookie() // Добавляем CSRF токен если он есть
+ },
+ credentials: 'include', // Важно для обработки cookies
+ body: JSON.stringify({
+ query: `
+ mutation Login($email: String!, $password: String!) {
+ login(email: $email, password: $password) {
+ success
+ token
+ error
+ }
+ }
+ `,
+ variables: {
+ email: credentials.email,
+ password: credentials.password
}
- }
- `,
- {
- email: credentials.email,
- password: credentials.password
- }
- )
+ })
+ })
- if (data?.login?.success) {
+ if (!response.ok) {
+ const errorText = await response.text()
+ console.error('Ошибка HTTP:', response.status, errorText)
+ throw new Error(`HTTP error: ${response.status} ${response.statusText}`)
+ }
+
+ const result = await response.json()
+ console.log('Результат авторизации:', result)
+
+ if (result?.data?.login?.success) {
// Проверяем, установил ли сервер cookie
const cookieToken = getAuthTokenFromCookie()
const hasCookie = !!cookieToken && cookieToken.length > 10
// Если cookie не установлена, но есть токен в ответе, сохраняем его в localStorage
- if (!hasCookie && data.login.token) {
- localStorage.setItem(AUTH_TOKEN_KEY, data.login.token)
+ if (!hasCookie && result.data.login.token) {
+ localStorage.setItem(AUTH_TOKEN_KEY, result.data.login.token)
}
return true
}
- throw new Error(data?.login?.error || 'Ошибка авторизации')
+ if (result.errors && result.errors.length > 0) {
+ throw new Error(result.errors[0].message || 'Ошибка авторизации')
+ }
+
+ throw new Error(result?.data?.login?.error || 'Неизвестная ошибка авторизации')
} catch (error) {
console.error('Ошибка при входе:', error)
throw error
}
}
-
diff --git a/panel/graphql.ts b/panel/graphql.ts
index 5d7738f9..f2411e6a 100644
--- a/panel/graphql.ts
+++ b/panel/graphql.ts
@@ -3,7 +3,7 @@
* @module api
*/
-import { AUTH_TOKEN_KEY, getAuthTokenFromCookie } from "./auth"
+import { AUTH_TOKEN_KEY, CSRF_TOKEN_KEY, getAuthTokenFromCookie, getCsrfTokenFromCookie } from './auth'
/**
* Тип для произвольных данных GraphQL
@@ -61,6 +61,55 @@ function hasAuthErrors(errors: Array<{ message?: string; extensions?: { code?: s
)
}
+/**
+ * Подготавливает URL для GraphQL запроса
+ * @param url - URL или путь для запроса
+ * @returns Полный URL для запроса
+ */
+function prepareUrl(url: string): string {
+ // Если это относительный путь, добавляем к нему origin
+ if (url.startsWith('/')) {
+ return `${location.origin}${url}`
+ }
+ // Если это уже полный URL, используем как есть
+ return url
+}
+
+/**
+ * Возвращает заголовки для GraphQL запроса с учетом авторизации и CSRF
+ * @returns Объект с заголовками
+ */
+function getRequestHeaders(): Record {
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json'
+ }
+
+ // Проверяем наличие токена в localStorage
+ const localToken = localStorage.getItem(AUTH_TOKEN_KEY)
+
+ // Проверяем наличие токена в cookie
+ const cookieToken = getAuthTokenFromCookie()
+
+ // Используем токен из localStorage или cookie
+ const token = localToken || cookieToken
+
+ // Если есть токен, добавляем его в заголовок Authorization с префиксом Bearer
+ if (token && token.length > 10) {
+ headers['Authorization'] = `Bearer ${token}`
+ console.debug('Отправка запроса с токеном авторизации')
+ }
+
+ // Добавляем CSRF-токен, если он есть
+ const csrfToken = getCsrfTokenFromCookie()
+ if (csrfToken) {
+ headers['X-CSRF-Token'] = csrfToken
+ console.debug('Добавлен CSRF-токен в запрос')
+ }
+
+ return headers
+}
+
/**
* Выполняет GraphQL запрос
* @param url - URL для запроса
@@ -74,28 +123,14 @@ export async function query(
variables: Record = {}
): Promise {
try {
- const headers: Record = {
- 'Content-Type': 'application/json'
- }
+ // Получаем все необходимые заголовки для запроса
+ const headers = getRequestHeaders()
- // Проверяем наличие токена в localStorage
- const localToken = localStorage.getItem(AUTH_TOKEN_KEY)
+ // Подготавливаем полный URL
+ const fullUrl = prepareUrl(url)
+ console.debug('Отправка GraphQL запроса на:', fullUrl)
- // Проверяем наличие токена в cookie
- const cookieToken = getAuthTokenFromCookie()
-
- // Используем токен из localStorage или cookie
- const token = localToken || cookieToken
-
- // Если есть токен, добавляем его в заголовок Authorization с префиксом Bearer
- if (token && token.length > 10) {
- // В соответствии с логами сервера, формат должен быть: Bearer
- headers['Authorization'] = `Bearer ${token}`
- // Для отладки
- console.debug('Отправка запроса с токеном авторизации')
- }
-
- const response = await fetch(url, {
+ const response = await fetch(fullUrl, {
method: 'POST',
headers,
// Важно: credentials: 'include' - для передачи cookies с запросом
@@ -115,8 +150,8 @@ export async function query(
error: errorMessage
})
- // Если получен 401 Unauthorized, перенаправляем на страницу входа
- if (response.status === 401) {
+ // Если получен 401 Unauthorized или 403 Forbidden, перенаправляем на страницу входа
+ if (response.status === 401 || response.status === 403) {
localStorage.removeItem(AUTH_TOKEN_KEY)
window.location.href = '/'
throw new Error('Unauthorized')
diff --git a/panel/login.tsx b/panel/login.tsx
index 2ba2b8fd..3440d0a4 100644
--- a/panel/login.tsx
+++ b/panel/login.tsx
@@ -18,6 +18,7 @@ const LoginPage: Component = (props) => {
const [password, setPassword] = createSignal('')
const [isLoading, setIsLoading] = createSignal(false)
const [error, setError] = createSignal(null)
+ const [formSubmitting, setFormSubmitting] = createSignal(false)
/**
* Обработчик отправки формы входа
@@ -26,6 +27,9 @@ const LoginPage: Component = (props) => {
const handleSubmit = async (e: Event) => {
e.preventDefault()
+ // Предотвращаем повторную отправку формы
+ if (formSubmitting()) return
+
// Очищаем пробелы в email
const cleanEmail = email().trim()
@@ -34,6 +38,7 @@ const LoginPage: Component = (props) => {
return
}
+ setFormSubmitting(true)
setIsLoading(true)
setError(null)
@@ -56,6 +61,8 @@ const LoginPage: Component = (props) => {
console.error('Ошибка при входе:', err)
setError(err instanceof Error ? err.message : 'Неизвестная ошибка')
setIsLoading(false)
+ } finally {
+ setFormSubmitting(false)
}
}
@@ -66,12 +73,13 @@ const LoginPage: Component = (props) => {
{error() && {error()}
}
-
diff --git a/requirements.txt b/requirements.txt
index ad4acff6..f8578f04 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,3 @@
-# own auth
bcrypt
authlib
passlib
diff --git a/resolvers/__init__.py b/resolvers/__init__.py
index e781d571..c4113feb 100644
--- a/resolvers/__init__.py
+++ b/resolvers/__init__.py
@@ -61,9 +61,33 @@ from resolvers.topic import (
get_topics_by_community,
)
+from resolvers.auth import (
+ get_current_user,
+ confirm_email,
+ register_by_email,
+ send_link,
+ login,
+)
+
+from resolvers.admin import (
+ admin_get_users,
+ admin_get_roles,
+)
+
events_register()
__all__ = [
+ # auth
+ "get_current_user",
+ "confirm_email",
+ "register_by_email",
+ "send_link",
+ "login",
+
+ # admin
+ "admin_get_users",
+ "admin_get_roles",
+
# author
"get_author",
"get_author_id",
@@ -74,10 +98,12 @@ __all__ = [
"get_authors_all",
"load_authors_by",
"update_author",
- ## "search_authors",
+ # "search_authors",
+
# community
"get_community",
"get_communities_all",
+
# topic
"get_topic",
"get_topics_all",
@@ -85,12 +111,14 @@ __all__ = [
"get_topics_by_author",
"get_topic_followers",
"get_topic_authors",
+
# reader
"get_shout",
"load_shouts_by",
"load_shouts_random_top",
"load_shouts_search",
"load_shouts_unrated",
+
# feed
"load_shouts_feed",
"load_shouts_coauthored",
@@ -98,10 +126,12 @@ __all__ = [
"load_shouts_with_topic",
"load_shouts_followed_by",
"load_shouts_authored_by",
+
# follower
"follow",
"unfollow",
"get_shout_followers",
+
# reaction
"create_reaction",
"update_reaction",
@@ -111,15 +141,18 @@ __all__ = [
"load_shout_ratings",
"load_comment_ratings",
"load_comments_branch",
+
# notifier
"load_notifications",
"notifications_seen_thread",
"notifications_seen_after",
"notification_mark_seen",
+
# rating
"rate_author",
"get_my_rates_comments",
"get_my_rates_shouts",
+
# draft
"load_drafts",
"create_draft",
diff --git a/resolvers/admin.py b/resolvers/admin.py
new file mode 100644
index 00000000..e3975358
--- /dev/null
+++ b/resolvers/admin.py
@@ -0,0 +1,122 @@
+from math import ceil
+from sqlalchemy import or_
+from graphql.error import GraphQLError
+
+from auth.decorators import admin_auth_required
+from services.db import local_session
+from services.schema import query
+from auth.orm import Author, Role
+from utils.logger import root_logger as logger
+
+
+@query.field("adminGetUsers")
+@admin_auth_required
+async def admin_get_users(_, info, limit=10, offset=0, search=None):
+ """
+ Получает список пользователей для админ-панели с поддержкой пагинации и поиска
+
+ Args:
+ info: Контекст GraphQL запроса
+ limit: Максимальное количество записей для получения
+ offset: Смещение в списке результатов
+ search: Строка поиска (по email, имени или ID)
+
+ Returns:
+ Пагинированный список пользователей
+ """
+ try:
+ # Нормализуем параметры
+ limit = max(1, min(100, limit or 10)) # Ограничиваем количество записей от 1 до 100
+ offset = max(0, offset or 0) # Смещение не может быть отрицательным
+
+ with local_session() as session:
+ # Базовый запрос
+ query = session.query(Author)
+
+ # Применяем фильтр поиска, если указан
+ if search and search.strip():
+ search_term = f"%{search.strip().lower()}%"
+ query = query.filter(
+ or_(
+ Author.email.ilike(search_term),
+ Author.name.ilike(search_term),
+ Author.id.cast(str).ilike(search_term),
+ )
+ )
+
+ # Получаем общее количество записей
+ total_count = query.count()
+
+ # Вычисляем информацию о пагинации
+ per_page = limit
+ total_pages = ceil(total_count / per_page)
+ current_page = (offset // per_page) + 1 if per_page > 0 else 1
+
+ # Применяем пагинацию
+ users = query.order_by(Author.id).offset(offset).limit(limit).all()
+
+ # Преобразуем в формат для API
+ result = {
+ "users": [
+ {
+ "id": user.id,
+ "email": user.email,
+ "name": user.name,
+ "slug": user.slug,
+ "roles": [role.role for role in user.roles]
+ if hasattr(user, "roles") and user.roles
+ else [],
+ "created_at": user.created_at,
+ "last_seen": user.last_seen,
+ "muted": user.muted or False,
+ "is_active": not user.blocked if hasattr(user, "blocked") else True,
+ }
+ for user in users
+ ],
+ "total": total_count,
+ "page": current_page,
+ "perPage": per_page,
+ "totalPages": total_pages,
+ }
+
+ return result
+ except Exception as e:
+ import traceback
+ logger.error(f"Ошибка при получении списка пользователей: {str(e)}")
+ logger.error(traceback.format_exc())
+ raise GraphQLError(f"Не удалось получить список пользователей: {str(e)}")
+
+
+@query.field("adminGetRoles")
+@admin_auth_required
+async def admin_get_roles(_, info):
+ """
+ Получает список всех ролей для админ-панели
+
+ Args:
+ info: Контекст GraphQL запроса
+
+ Returns:
+ Список ролей с их описаниями
+ """
+ try:
+ with local_session() as session:
+ # Получаем все роли из базы данных
+ roles = session.query(Role).all()
+
+ # Преобразуем их в формат для API
+ result = [
+ {
+ "id": role.id,
+ "name": role.name,
+ "description": f"Роль с правами: {', '.join(p.resource + ':' + p.operation for p in role.permissions)}"
+ if role.permissions
+ else "Роль без особых прав",
+ }
+ for role in roles
+ ]
+
+ return result
+ except Exception as e:
+ logger.error(f"Ошибка при получении списка ролей: {str(e)}")
+ raise GraphQLError(f"Не удалось получить список ролей: {str(e)}")
diff --git a/auth/resolvers.py b/resolvers/auth.py
similarity index 67%
rename from auth/resolvers.py
rename to resolvers/auth.py
index 8814a8ca..959bae86 100644
--- a/auth/resolvers.py
+++ b/resolvers/auth.py
@@ -8,7 +8,6 @@ from graphql.type import GraphQLResolveInfo
from auth.authenticate import login_required
from auth.credentials import AuthCredentials
-from auth.decorators import admin_auth_required
from auth.email import send_auth_email
from auth.exceptions import InvalidToken, ObjectNotExist
from auth.identity import Identity, Password
@@ -26,10 +25,8 @@ from settings import (
SESSION_COOKIE_HTTPONLY,
)
from utils.generate_slug import generate_unique_slug
-from graphql.error import GraphQLError
-from math import ceil
-from sqlalchemy import or_
-
+from auth.sessions import SessionManager
+from auth.internal import verify_internal_auth
@mutation.field("getSession")
@login_required
@@ -152,7 +149,7 @@ async def register_by_email(_, _info, email: str, password: str = "", name: str
# Попытка отправить ссылку для подтверждения email
try:
# Если auth_send_link асинхронный...
- await auth_send_link(_, _info, email)
+ await send_link(_, _info, email)
logger.info(
f"[auth] registerUser: Пользователь {email} зарегистрирован, ссылка для подтверждения отправлена."
)
@@ -173,7 +170,7 @@ async def register_by_email(_, _info, email: str, password: str = "", name: str
@mutation.field("sendLink")
-async def auth_send_link(_, _info, email, lang="ru", template="email_confirmation"):
+async def send_link(_, _info, email, lang="ru", template="email_confirmation"):
email = email.lower()
"""send link with confirm code to email"""
with local_session() as session:
@@ -189,7 +186,7 @@ async def auth_send_link(_, _info, email, lang="ru", template="email_confirmatio
@mutation.field("login")
-async def login_mutation(_, info, email: str, password: str):
+async def login(_, info, email: str, password: str):
"""
Авторизация пользователя с помощью email и пароля.
@@ -351,113 +348,150 @@ async def is_email_used(_, _info, email):
return user is not None
-@query.field("adminGetUsers")
-@admin_auth_required
-async def admin_get_users(_, info, limit=10, offset=0, search=None):
+@mutation.field("logout")
+async def logout_resolver(_, info: GraphQLResolveInfo):
"""
- Получает список пользователей для админ-панели с поддержкой пагинации и поиска
-
- Args:
- info: Контекст GraphQL запроса
- limit: Максимальное количество записей для получения
- offset: Смещение в списке результатов
- search: Строка поиска (по email, имени или ID)
-
+ Выход из системы через GraphQL с удалением сессии и cookie.
+
Returns:
- Пагинированный список пользователей
+ dict: Результат операции выхода
"""
+ # Получаем токен из cookie или заголовка
+ request = info.context["request"]
+ token = request.cookies.get(SESSION_COOKIE_NAME)
+ if not token:
+ # Проверяем заголовок авторизации
+ auth_header = request.headers.get("Authorization")
+ if auth_header and auth_header.startswith("Bearer "):
+ token = auth_header[7:] # Отрезаем "Bearer "
+
+ success = False
+ message = ""
+
+ # Если токен найден, отзываем его
+ if token:
+ try:
+ # Декодируем токен для получения user_id
+ user_id, _ = await verify_internal_auth(token)
+ if user_id:
+ # Отзываем сессию
+ await SessionManager.revoke_session(user_id, token)
+ logger.info(f"[auth] logout_resolver: Токен успешно отозван для пользователя {user_id}")
+ success = True
+ message = "Выход выполнен успешно"
+ else:
+ logger.warning("[auth] logout_resolver: Не удалось получить user_id из токена")
+ message = "Не удалось обработать токен"
+ except Exception as e:
+ logger.error(f"[auth] logout_resolver: Ошибка при отзыве токена: {e}")
+ message = f"Ошибка при выходе: {str(e)}"
+ else:
+ message = "Токен не найден"
+ success = True # Если токена нет, то пользователь уже вышел из системы
+
+ # Удаляем cookie через extensions
try:
- # Нормализуем параметры
- limit = max(1, min(100, limit or 10)) # Ограничиваем количество записей от 1 до 100
- offset = max(0, offset or 0) # Смещение не может быть отрицательным
+ # Используем extensions для удаления cookie
+ if hasattr(info.context, "extensions") and hasattr(info.context.extensions, "delete_cookie"):
+ info.context.extensions.delete_cookie(SESSION_COOKIE_NAME)
+ logger.info("[auth] logout_resolver: Cookie успешно удалена через extensions")
+ elif hasattr(info.context, "response") and hasattr(info.context.response, "delete_cookie"):
+ info.context.response.delete_cookie(SESSION_COOKIE_NAME)
+ logger.info("[auth] logout_resolver: Cookie успешно удалена через response")
+ else:
+ logger.warning("[auth] logout_resolver: Невозможно удалить cookie - объекты extensions/response недоступны")
+ except Exception as e:
+ logger.error(f"[auth] logout_resolver: Ошибка при удалении cookie: {str(e)}")
+ logger.debug(traceback.format_exc())
+ return {"success": success, "message": message}
+
+
+@mutation.field("refreshToken")
+async def refresh_token_resolver(_, info: GraphQLResolveInfo):
+ """
+ Обновление токена аутентификации через GraphQL.
+
+ Returns:
+ AuthResult с данными пользователя и обновленным токеном или сообщением об ошибке
+ """
+ request = info.context["request"]
+
+ # Получаем текущий токен из cookie или заголовка
+ token = request.cookies.get(SESSION_COOKIE_NAME)
+ if not token:
+ auth_header = request.headers.get("Authorization")
+ if auth_header and auth_header.startswith("Bearer "):
+ token = auth_header[7:] # Отрезаем "Bearer "
+
+ if not token:
+ logger.warning("[auth] refresh_token_resolver: Токен не найден в запросе")
+ return {"success": False, "token": None, "author": None, "error": "Токен не найден"}
+
+ try:
+ # Получаем информацию о пользователе из токена
+ user_id, _ = await verify_internal_auth(token)
+ if not user_id:
+ logger.warning("[auth] refresh_token_resolver: Недействительный токен")
+ return {"success": False, "token": None, "author": None, "error": "Недействительный токен"}
+
+ # Получаем пользователя из базы данных
with local_session() as session:
- # Базовый запрос
- query = session.query(Author)
+ author = session.query(Author).filter(Author.id == user_id).first()
- # Применяем фильтр поиска, если указан
- if search and search.strip():
- search_term = f"%{search.strip().lower()}%"
- query = query.filter(
- or_(
- Author.email.ilike(search_term),
- Author.name.ilike(search_term),
- Author.id.cast(str).ilike(search_term),
+ if not author:
+ logger.warning(f"[auth] refresh_token_resolver: Пользователь с ID {user_id} не найден")
+ return {"success": False, "token": None, "author": None, "error": "Пользователь не найден"}
+
+ # Обновляем сессию (создаем новую и отзываем старую)
+ device_info = {"ip": request.client.host, "user_agent": request.headers.get("user-agent")}
+ new_token = await SessionManager.refresh_session(user_id, token, device_info)
+
+ if not new_token:
+ logger.error("[auth] refresh_token_resolver: Не удалось обновить токен")
+ return {"success": False, "token": None, "author": None, "error": "Не удалось обновить токен"}
+
+ # Устанавливаем cookie через extensions
+ try:
+ # Используем extensions для установки cookie
+ if hasattr(info.context, "extensions") and hasattr(info.context.extensions, "set_cookie"):
+ logger.info("[auth] refresh_token_resolver: Устанавливаем httponly cookie через extensions")
+ info.context.extensions.set_cookie(
+ SESSION_COOKIE_NAME,
+ new_token,
+ httponly=SESSION_COOKIE_HTTPONLY,
+ secure=SESSION_COOKIE_SECURE,
+ samesite=SESSION_COOKIE_SAMESITE,
+ max_age=SESSION_COOKIE_MAX_AGE,
)
- )
+ elif hasattr(info.context, "response") and hasattr(info.context.response, "set_cookie"):
+ logger.info("[auth] refresh_token_resolver: Устанавливаем httponly cookie через response")
+ info.context.response.set_cookie(
+ key=SESSION_COOKIE_NAME,
+ value=new_token,
+ httponly=SESSION_COOKIE_HTTPONLY,
+ secure=SESSION_COOKIE_SECURE,
+ samesite=SESSION_COOKIE_SAMESITE,
+ max_age=SESSION_COOKIE_MAX_AGE,
+ )
+ else:
+ logger.warning(
+ "[auth] refresh_token_resolver: Невозможно установить cookie - объекты extensions/response недоступны"
+ )
+ except Exception as e:
+ # В случае ошибки при установке cookie просто логируем, но продолжаем обновление токена
+ logger.error(f"[auth] refresh_token_resolver: Ошибка при установке cookie: {str(e)}")
+ logger.debug(traceback.format_exc())
- # Получаем общее количество записей
- total_count = query.count()
-
- # Вычисляем информацию о пагинации
- per_page = limit
- total_pages = ceil(total_count / per_page)
- current_page = (offset // per_page) + 1 if per_page > 0 else 1
-
- # Применяем пагинацию
- users = query.order_by(Author.id).offset(offset).limit(limit).all()
-
- # Преобразуем в формат для API
- result = {
- "users": [
- {
- "id": user.id,
- "email": user.email,
- "name": user.name,
- "slug": user.slug,
- "roles": [role.role for role in user.roles]
- if hasattr(user, "roles") and user.roles
- else [],
- "created_at": user.created_at,
- "last_seen": user.last_seen,
- "muted": user.muted or False,
- "is_active": not user.blocked if hasattr(user, "blocked") else True,
- }
- for user in users
- ],
- "total": total_count,
- "page": current_page,
- "perPage": per_page,
- "totalPages": total_pages,
+ logger.info(f"[auth] refresh_token_resolver: Токен успешно обновлен для пользователя {user_id}")
+ return {
+ "success": True,
+ "token": new_token,
+ "author": author,
+ "error": None
}
- return result
except Exception as e:
- logger.error(f"Ошибка при получении списка пользователей: {str(e)}")
+ logger.error(f"[auth] refresh_token_resolver: Ошибка при обновлении токена: {e}")
logger.error(traceback.format_exc())
- raise GraphQLError(f"Не удалось получить список пользователей: {str(e)}")
-
-
-@query.field("adminGetRoles")
-@admin_auth_required
-async def admin_get_roles(_, info):
- """
- Получает список всех ролей для админ-панели
-
- Args:
- info: Контекст GraphQL запроса
-
- Returns:
- Список ролей с их описаниями
- """
- try:
- with local_session() as session:
- # Получаем все роли из базы данных
- roles = session.query(Role).all()
-
- # Преобразуем их в формат для API
- result = [
- {
- "id": role.id,
- "name": role.name,
- "description": f"Роль с правами: {', '.join(p.resource + ':' + p.operation for p in role.permissions)}"
- if role.permissions
- else "Роль без особых прав",
- }
- for role in roles
- ]
-
- return result
- except Exception as e:
- logger.error(f"Ошибка при получении списка ролей: {str(e)}")
- raise GraphQLError(f"Не удалось получить список ролей: {str(e)}")
+ return {"success": False, "token": None, "author": None, "error": str(e)}
diff --git a/schema/mutation.graphql b/schema/mutation.graphql
index deb29d9f..a2180596 100644
--- a/schema/mutation.graphql
+++ b/schema/mutation.graphql
@@ -1,6 +1,8 @@
type Mutation {
# Auth mutations
login(email: String!, password: String!): AuthResult!
+ logout: AuthSuccess!
+ refreshToken: AuthResult!
registerUser(email: String!, password: String, name: String): AuthResult!
sendLink(email: String!, lang: String, template: String): Author!
confirmEmail(token: String!): AuthResult!
diff --git a/services/run.py b/services/run.py
new file mode 100644
index 00000000..e69de29b
diff --git a/settings.py b/settings.py
index b0d39711..92ccab17 100644
--- a/settings.py
+++ b/settings.py
@@ -4,7 +4,6 @@ import os
import sys
from os import environ
-MODE = "development" if "dev" in sys.argv else "production"
DEV_SERVER_PID_FILE_NAME = "dev-server.pid"
PORT = environ.get("PORT") or 8000
@@ -59,7 +58,7 @@ JWT_ACCESS_TOKEN_EXPIRE_MINUTES = 30
JWT_REFRESH_TOKEN_EXPIRE_DAYS = 30
# Настройки сессии
-SESSION_COOKIE_NAME = "session_token"
+SESSION_COOKIE_NAME = "auth_token"
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "lax"
diff --git a/tests/auth/test_oauth.py b/tests/auth/test_oauth.py
index d6b77247..7d0cf818 100644
--- a/tests/auth/test_oauth.py
+++ b/tests/auth/test_oauth.py
@@ -161,7 +161,7 @@ with (
assert isinstance(response, RedirectResponse)
assert response.status_code == 307
- assert "auth/success" in response.headers["location"]
+ assert "auth/success" in response.headers.get("location", "")
# Проверяем cookie
cookies = response.headers.getlist("set-cookie")