From dc5ad46df903a1e61bb572e7c033e98908483c1d Mon Sep 17 00:00:00 2001 From: Untone Date: Mon, 19 May 2025 11:25:41 +0300 Subject: [PATCH] wip --- CHANGELOG.md | 4 + app/resolvers/draft.py | 1 - auth/__init__.py | 7 - auth/decorators.py | 205 ++++++++++++++------- auth/middleware.py | 47 ++--- dev.py | 117 ++++++++++++ docs/README.md | 44 ++++- main.py | 210 +++++++-------------- panel/admin.tsx | 183 +++++++++++-------- panel/auth.ts | 131 ++++++++----- panel/graphql.ts | 81 +++++--- panel/login.tsx | 22 ++- requirements.txt | 1 - resolvers/__init__.py | 35 +++- resolvers/admin.py | 122 +++++++++++++ auth/resolvers.py => resolvers/auth.py | 244 ++++++++++++++----------- schema/mutation.graphql | 2 + services/run.py | 0 settings.py | 3 +- tests/auth/test_oauth.py | 2 +- 20 files changed, 952 insertions(+), 509 deletions(-) delete mode 100644 app/resolvers/draft.py create mode 100644 dev.py create mode 100644 resolvers/admin.py rename auth/resolvers.py => resolvers/auth.py (67%) create mode 100644 services/run.py 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()}
} -
+
setEmail(e.currentTarget.value)} disabled={isLoading()} @@ -85,6 +93,7 @@ const LoginPage: Component = (props) => { setPassword(e.currentTarget.value)} disabled={isLoading()} @@ -93,8 +102,15 @@ const LoginPage: Component = (props) => { />
-
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")