wip
This commit is contained in:
parent
11e46f7352
commit
dc5ad46df9
|
@ -36,6 +36,10 @@
|
||||||
- Пагинация списка пользователей в админ-панели
|
- Пагинация списка пользователей в админ-панели
|
||||||
- Серверная поддержка пагинации в API для админ-панели
|
- Серверная поддержка пагинации в API для админ-панели
|
||||||
- Поиск пользователей по email, имени и ID
|
- Поиск пользователей по email, имени и ID
|
||||||
|
- Поддержка локального запуска сервера с HTTPS через `python run.py --https` с использованием Granian
|
||||||
|
- Интеграция с инструментом mkcert для генерации доверенных локальных SSL-сертификатов
|
||||||
|
- Поддержка запуска нескольких рабочих процессов через параметр `--workers`
|
||||||
|
- Возможность указать произвольный домен для сертификата через `--domain`
|
||||||
|
|
||||||
### Улучшено
|
### Улучшено
|
||||||
- Улучшен интерфейс админ-панели:
|
- Улучшен интерфейс админ-панели:
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
|
|
|
@ -113,10 +113,3 @@ async def refresh_token(request: Request):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[auth] refresh_token: Ошибка при обновлении токена: {e}")
|
logger.error(f"[auth] refresh_token: Ошибка при обновлении токена: {e}")
|
||||||
return JSONResponse({"success": False, "error": str(e)}, status_code=401)
|
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"]),
|
|
||||||
]
|
|
||||||
|
|
|
@ -1,15 +1,109 @@
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import Callable, Any
|
from typing import Callable, Any, Dict, Optional
|
||||||
from graphql import GraphQLError
|
from graphql import GraphQLError
|
||||||
from services.db import local_session
|
from services.db import local_session
|
||||||
from auth.orm import Author
|
from auth.orm import Author
|
||||||
from auth.exceptions import OperationNotAllowed
|
from auth.exceptions import OperationNotAllowed
|
||||||
from utils.logger import root_logger as logger
|
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(",")
|
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:
|
def admin_auth_required(resolver: Callable) -> Callable:
|
||||||
"""
|
"""
|
||||||
Декоратор для защиты админских эндпоинтов.
|
Декоратор для защиты админских эндпоинтов.
|
||||||
|
@ -23,65 +117,39 @@ def admin_auth_required(resolver: Callable) -> Callable:
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
GraphQLError: если пользователь не авторизован или не имеет доступа администратора
|
GraphQLError: если пользователь не авторизован или не имеет доступа администратора
|
||||||
"""
|
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> @admin_auth_required
|
||||||
|
... async def admin_resolver(root, info, **kwargs):
|
||||||
|
... return "Admin data"
|
||||||
|
"""
|
||||||
@wraps(resolver)
|
@wraps(resolver)
|
||||||
async def wrapper(root: Any = None, info: Any = None, **kwargs):
|
async def wrapper(root: Any = None, info: Any = None, **kwargs):
|
||||||
try:
|
try:
|
||||||
# Проверяем наличие info и контекста
|
validate_graphql_context(info)
|
||||||
if info is None or not hasattr(info, "context"):
|
auth = info.context["request"].auth
|
||||||
logger.error("Missing GraphQL context information")
|
|
||||||
raise GraphQLError("Internal server error: missing context")
|
|
||||||
|
|
||||||
# Получаем 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:
|
with local_session() as session:
|
||||||
try:
|
|
||||||
author = session.query(Author).filter(Author.id == auth.author_id).one()
|
author = session.query(Author).filter(Author.id == auth.author_id).one()
|
||||||
|
|
||||||
# Проверка по email
|
|
||||||
if author.email in ADMIN_EMAILS:
|
if author.email in ADMIN_EMAILS:
|
||||||
logger.info(
|
logger.info(f"Admin access granted for {author.email} (ID: {author.id})")
|
||||||
f"Admin access granted for {author.email} (special admin, ID: {author.id})"
|
|
||||||
)
|
|
||||||
return await resolver(root, info, **kwargs)
|
return await resolver(root, info, **kwargs)
|
||||||
else:
|
|
||||||
logger.warning(
|
logger.warning(f"Admin access denied for {author.email} (ID: {author.id})")
|
||||||
f"Admin access denied for {author.email} (ID: {author.id}) - not in admin list"
|
|
||||||
)
|
|
||||||
raise GraphQLError("Unauthorized - not an admin")
|
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")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Если ошибка уже GraphQLError, просто перебрасываем её
|
error_msg = str(e)
|
||||||
if isinstance(e, GraphQLError):
|
if not isinstance(e, GraphQLError):
|
||||||
logger.error(f"GraphQL error in admin_auth_required: {str(e)}")
|
error_msg = f"Admin access error: {error_msg}"
|
||||||
raise e
|
logger.error(f"Error in admin_auth_required: {error_msg}")
|
||||||
|
raise GraphQLError(error_msg)
|
||||||
# Иначе, создаем новую GraphQLError
|
|
||||||
logger.error(f"Error in admin_auth_required: {str(e)}")
|
|
||||||
raise GraphQLError(f"Admin access error: {str(e)}")
|
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
def require_permission(permission_string: str):
|
def require_permission(permission_string: str) -> Callable:
|
||||||
"""
|
"""
|
||||||
Декоратор для проверки наличия указанного разрешения.
|
Декоратор для проверки наличия указанного разрешения.
|
||||||
Принимает строку в формате "resource:permission".
|
Принимает строку в формате "resource:permission".
|
||||||
|
@ -94,47 +162,46 @@ def require_permission(permission_string: str):
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: если строка разрешения имеет неверный формат
|
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"')
|
raise ValueError('Permission string must be in format "resource:permission"')
|
||||||
|
|
||||||
resource, operation = permission_string.split(":", 1)
|
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:
|
def decorator(func: Callable) -> Callable:
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
async def wrapper(parent, info: Any = None, *args, **kwargs):
|
async def wrapper(parent, info: Any = None, *args, **kwargs):
|
||||||
# Проверяем наличие info и контекста
|
try:
|
||||||
if info is None or not hasattr(info, "context"):
|
validate_graphql_context(info)
|
||||||
logger.error("Missing GraphQL context information in require_permission")
|
|
||||||
raise OperationNotAllowed("Internal server error: missing context")
|
|
||||||
|
|
||||||
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:
|
with local_session() as session:
|
||||||
try:
|
|
||||||
author = session.query(Author).filter(Author.id == auth.author_id).one()
|
author = session.query(Author).filter(Author.id == auth.author_id).one()
|
||||||
|
|
||||||
# Проверяем базовые условия
|
|
||||||
if not author.is_active:
|
if not author.is_active:
|
||||||
raise OperationNotAllowed("Account is not active")
|
raise OperationNotAllowed("Account is not active")
|
||||||
if author.is_locked():
|
if author.is_locked():
|
||||||
raise OperationNotAllowed("Account is locked")
|
raise OperationNotAllowed("Account is locked")
|
||||||
|
|
||||||
# Проверяем разрешение
|
|
||||||
if not author.has_permission(resource, operation):
|
if not author.has_permission(resource, operation):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Access denied for user {auth.author_id} - no permission {resource}:{operation}"
|
f"Access denied for user {auth.author_id} - no permission {resource}:{operation}"
|
||||||
)
|
)
|
||||||
raise OperationNotAllowed(f"No permission for {operation} on {resource}")
|
raise OperationNotAllowed(f"No permission for {operation} on {resource}")
|
||||||
|
|
||||||
# Пользователь аутентифицирован и имеет необходимое разрешение
|
|
||||||
return await func(parent, info, *args, **kwargs)
|
return await func(parent, info, *args, **kwargs)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in require_permission: {e}")
|
if isinstance(e, (OperationNotAllowed, GraphQLError)):
|
||||||
if isinstance(e, OperationNotAllowed):
|
|
||||||
raise e
|
raise e
|
||||||
|
logger.error(f"Error in require_permission: {e}")
|
||||||
raise OperationNotAllowed(str(e))
|
raise OperationNotAllowed(str(e))
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
|
@ -8,17 +8,22 @@ from utils.logger import root_logger as logger
|
||||||
from settings import SESSION_TOKEN_HEADER, SESSION_COOKIE_NAME
|
from settings import SESSION_TOKEN_HEADER, SESSION_COOKIE_NAME
|
||||||
|
|
||||||
|
|
||||||
class AuthorizationMiddleware:
|
class AuthMiddleware:
|
||||||
"""
|
"""
|
||||||
Middleware для обработки заголовка Authorization и cookie авторизации.
|
Универсальный middleware для обработки авторизации и управления cookies.
|
||||||
Извлекает Bearer токен из заголовка или cookie и добавляет его в заголовки
|
|
||||||
запроса для обработки стандартным AuthenticationMiddleware Starlette.
|
Основные функции:
|
||||||
|
1. Извлечение Bearer токена из заголовка Authorization или cookie
|
||||||
|
2. Добавление токена в заголовки запроса для обработки AuthenticationMiddleware
|
||||||
|
3. Предоставление методов для установки/удаления cookies в GraphQL резолверах
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, app: ASGIApp):
|
def __init__(self, app: ASGIApp):
|
||||||
self.app = app
|
self.app = app
|
||||||
|
self._context = None
|
||||||
|
|
||||||
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
||||||
|
"""Обработка ASGI запроса"""
|
||||||
if scope["type"] != "http":
|
if scope["type"] != "http":
|
||||||
await self.app(scope, receive, send)
|
await self.app(scope, receive, send)
|
||||||
return
|
return
|
||||||
|
@ -71,23 +76,19 @@ class AuthorizationMiddleware:
|
||||||
|
|
||||||
await self.app(scope, receive, send)
|
await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
def set_context(self, context):
|
||||||
class GraphQLExtensionsMiddleware:
|
"""Сохраняет ссылку на контекст GraphQL запроса"""
|
||||||
"""
|
self._context = context
|
||||||
Утилиты для расширения контекста GraphQL запросов
|
|
||||||
"""
|
|
||||||
|
|
||||||
def set_cookie(self, key, value, **options):
|
def set_cookie(self, key, value, **options):
|
||||||
"""Устанавливает cookie в ответе"""
|
"""Устанавливает cookie в ответе"""
|
||||||
context = getattr(self, "_context", None)
|
if self._context and "response" in self._context and hasattr(self._context["response"], "set_cookie"):
|
||||||
if context and "response" in context and hasattr(context["response"], "set_cookie"):
|
self._context["response"].set_cookie(key, value, **options)
|
||||||
context["response"].set_cookie(key, value, **options)
|
|
||||||
|
|
||||||
def delete_cookie(self, key, **options):
|
def delete_cookie(self, key, **options):
|
||||||
"""Удаляет cookie из ответа"""
|
"""Удаляет cookie из ответа"""
|
||||||
context = getattr(self, "_context", None)
|
if self._context and "response" in self._context and hasattr(self._context["response"], "delete_cookie"):
|
||||||
if context and "response" in context and hasattr(context["response"], "delete_cookie"):
|
self._context["response"].delete_cookie(key, **options)
|
||||||
context["response"].delete_cookie(key, **options)
|
|
||||||
|
|
||||||
async def resolve(self, next, root, info, *args, **kwargs):
|
async def resolve(self, next, root, info, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
@ -99,12 +100,12 @@ class GraphQLExtensionsMiddleware:
|
||||||
context = info.context
|
context = info.context
|
||||||
|
|
||||||
# Сохраняем ссылку на контекст
|
# Сохраняем ссылку на контекст
|
||||||
self._context = context
|
self.set_context(context)
|
||||||
|
|
||||||
# Добавляем себя как объект, содержащий утилитные методы
|
# Добавляем себя как объект, содержащий утилитные методы
|
||||||
context["extensions"] = self
|
context["extensions"] = self
|
||||||
|
|
||||||
return await next(root, info, *args, **kwargs)
|
return await next(root, info, *args, **kwargs)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[GraphQLExtensionsMiddleware] Ошибка: {str(e)}")
|
logger.error(f"[AuthMiddleware] Ошибка в GraphQL resolve: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
117
dev.py
Normal file
117
dev.py
Normal file
|
@ -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()
|
|
@ -32,3 +32,45 @@ SESSION_TOKEN_LIFE_SPAN = 60 * 60 * 24 * 30 # время жизни сесси
|
||||||
|
|
||||||
Маршруты:
|
Маршруты:
|
||||||
- `/admin` - административная панель с проверкой прав доступа
|
- `/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)
|
||||||
|
- Простая установка и настройка
|
194
main.py
194
main.py
|
@ -11,9 +11,10 @@ from starlette.middleware.cors import CORSMiddleware
|
||||||
from starlette.middleware.authentication import AuthenticationMiddleware
|
from starlette.middleware.authentication import AuthenticationMiddleware
|
||||||
from starlette.middleware import Middleware
|
from starlette.middleware import Middleware
|
||||||
from starlette.requests import Request
|
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.routing import Route, Mount
|
||||||
from starlette.staticfiles import StaticFiles
|
from starlette.staticfiles import StaticFiles
|
||||||
|
from starlette.types import ASGIApp
|
||||||
|
|
||||||
from cache.precache import precache_data
|
from cache.precache import precache_data
|
||||||
from cache.revalidator import revalidation_manager
|
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.schema import create_all_tables, resolvers
|
||||||
from services.search import search_service
|
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 utils.logger import root_logger as logger
|
||||||
from auth.internal import InternalAuthentication
|
from auth.internal import InternalAuthentication
|
||||||
from auth import routes as auth_routes # Импортируем маршруты авторизации
|
from auth.middleware import AuthMiddleware
|
||||||
from auth.middleware import (
|
|
||||||
AuthorizationMiddleware,
|
|
||||||
GraphQLExtensionsMiddleware,
|
|
||||||
) # Импортируем middleware для авторизации
|
|
||||||
|
|
||||||
|
# Импортируем резолверы
|
||||||
import_module("resolvers")
|
import_module("resolvers")
|
||||||
import_module("auth.resolvers")
|
|
||||||
|
|
||||||
# Создаем схему GraphQL
|
# Создаем схему GraphQL
|
||||||
schema = make_executable_schema(load_schema_from_path("schema/"), resolvers)
|
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") # Директория для собранных файлов
|
DIST_DIR = join(os.path.dirname(__file__), "dist") # Директория для собранных файлов
|
||||||
INDEX_HTML = join(os.path.dirname(__file__), "index.html")
|
INDEX_HTML = join(os.path.dirname(__file__), "index.html")
|
||||||
|
|
||||||
|
@ -50,121 +45,35 @@ async def index_handler(request: Request):
|
||||||
return FileResponse(INDEX_HTML)
|
return FileResponse(INDEX_HTML)
|
||||||
|
|
||||||
|
|
||||||
# GraphQL API
|
# Создаем единый экземпляр AuthMiddleware для использования с GraphQL
|
||||||
class CustomGraphQLHTTPHandler(GraphQLHTTPHandler):
|
auth_middleware = AuthMiddleware(lambda scope, receive, send: None)
|
||||||
|
|
||||||
|
|
||||||
|
class EnhancedGraphQLHTTPHandler(GraphQLHTTPHandler):
|
||||||
"""
|
"""
|
||||||
Кастомный GraphQL HTTP обработчик, который добавляет объект response в контекст
|
Улучшенный GraphQL HTTP обработчик с поддержкой cookie и авторизации
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async def get_context_for_request(self, request: Request, data: dict) -> dict:
|
async def get_context_for_request(self, request: Request, data: dict) -> dict:
|
||||||
"""
|
"""
|
||||||
Переопределяем метод для добавления объекта response и extensions в контекст
|
Расширяем контекст для GraphQL запросов
|
||||||
"""
|
"""
|
||||||
|
# Получаем стандартный контекст от базового класса
|
||||||
context = await super().get_context_for_request(request, data)
|
context = await super().get_context_for_request(request, data)
|
||||||
# Создаем объект ответа, который будем использовать для установки cookie
|
|
||||||
|
# Добавляем объект ответа для установки cookie
|
||||||
response = JSONResponse({})
|
response = JSONResponse({})
|
||||||
context["response"] = response
|
context["response"] = response
|
||||||
|
|
||||||
# Добавляем extensions в контекст
|
# Интегрируем с AuthMiddleware
|
||||||
if "extensions" not in context:
|
context["extensions"] = auth_middleware
|
||||||
context["extensions"] = GraphQLExtensionsMiddleware()
|
|
||||||
|
|
||||||
return context
|
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(
|
|
||||||
"""
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Доступ запрещен</title>
|
|
||||||
<style>
|
|
||||||
body { font-family: Arial, sans-serif; margin: 0; padding: 0; display: flex; justify-content: center; align-items: center; height: 100vh; background-color: #f5f5f5; }
|
|
||||||
.error-container { max-width: 500px; padding: 30px; background-color: #fff; border-radius: 5px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); text-align: center; }
|
|
||||||
h1 { color: #e74c3c; margin-bottom: 20px; }
|
|
||||||
p { color: #333; margin-bottom: 20px; line-height: 1.5; }
|
|
||||||
.back-button { background-color: #3498db; color: #fff; border: none; padding: 10px 20px; border-radius: 3px; cursor: pointer; text-decoration: none; display: inline-block; }
|
|
||||||
.back-button:hover { background-color: #2980b9; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="error-container">
|
|
||||||
<h1>Доступ запрещен</h1>
|
|
||||||
<p>У вас нет прав для доступа к административной панели. Обратитесь к администратору системы для получения необходимых разрешений.</p>
|
|
||||||
<a href="/" class="back-button">Вернуться на главную</a>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
""",
|
|
||||||
status_code=403
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Функция запуска сервера
|
# Функция запуска сервера
|
||||||
async def start():
|
async def start():
|
||||||
"""Запуск сервера и инициализация данных"""
|
"""Запуск сервера и инициализация данных"""
|
||||||
logger.info(f"Запуск сервера в режиме: {MODE}")
|
|
||||||
|
|
||||||
# Создаем все таблицы в БД
|
# Создаем все таблицы в БД
|
||||||
create_all_tables()
|
create_all_tables()
|
||||||
|
|
||||||
|
@ -192,47 +101,60 @@ async def shutdown():
|
||||||
search_service.close()
|
search_service.close()
|
||||||
|
|
||||||
# Удаляем PID-файл, если он существует
|
# Удаляем PID-файл, если он существует
|
||||||
|
from settings import DEV_SERVER_PID_FILE_NAME
|
||||||
if exists(DEV_SERVER_PID_FILE_NAME):
|
if exists(DEV_SERVER_PID_FILE_NAME):
|
||||||
os.unlink(DEV_SERVER_PID_FILE_NAME)
|
os.unlink(DEV_SERVER_PID_FILE_NAME)
|
||||||
|
|
||||||
|
|
||||||
# Добавляем маршруты статических файлов, если директория существует
|
# Создаем middleware с правильным порядком
|
||||||
routes = []
|
middleware = [
|
||||||
if exists(DIST_DIR):
|
# Начинаем с обработки ошибок
|
||||||
routes.append(Mount("/", app=StaticFiles(directory=DIST_DIR, html=True)))
|
|
||||||
|
|
||||||
# Маршруты для 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)
|
|
||||||
|
|
||||||
app = Starlette(
|
|
||||||
debug=MODE == "development",
|
|
||||||
routes=routes,
|
|
||||||
middleware=[
|
|
||||||
Middleware(ExceptionHandlerMiddleware),
|
Middleware(ExceptionHandlerMiddleware),
|
||||||
|
# CORS должен быть перед другими middleware для корректной обработки preflight-запросов
|
||||||
Middleware(
|
Middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
allow_methods=["*"],
|
allow_methods=["GET", "POST", "OPTIONS"], # Явно указываем OPTIONS
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
),
|
),
|
||||||
# Добавляем middleware для обработки Authorization заголовка с Bearer токеном
|
# После CORS идёт обработка авторизации
|
||||||
Middleware(AuthorizationMiddleware),
|
Middleware(AuthMiddleware),
|
||||||
# Добавляем middleware для аутентификации после обработки токенов
|
# И затем аутентификация
|
||||||
Middleware(AuthenticationMiddleware, backend=InternalAuthentication()),
|
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)
|
||||||
|
|
||||||
|
# Добавляем маршруты, порядок имеет значение
|
||||||
|
routes = [
|
||||||
|
Route("/graphql", graphql_handler, methods=["GET", "POST", "OPTIONS"]),
|
||||||
|
Mount("/", app=StaticFiles(directory=DIST_DIR, html=True)),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Создаем приложение Starlette с маршрутами и middleware
|
||||||
|
app = Starlette(
|
||||||
|
routes=routes,
|
||||||
|
middleware=middleware,
|
||||||
on_startup=[start],
|
on_startup=[start],
|
||||||
on_shutdown=[shutdown],
|
on_shutdown=[shutdown],
|
||||||
)
|
)
|
||||||
|
|
147
panel/admin.tsx
147
panel/admin.tsx
|
@ -51,6 +51,26 @@ interface AdminGetRolesResponse {
|
||||||
adminGetRoles: Role[]
|
adminGetRoles: Role[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интерфейс для ответа изменения статуса пользователя
|
||||||
|
*/
|
||||||
|
interface AdminSetUserStatusResponse {
|
||||||
|
adminSetUserStatus: {
|
||||||
|
success: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интерфейс для ответа изменения статуса блокировки чата
|
||||||
|
*/
|
||||||
|
interface AdminMuteUserResponse {
|
||||||
|
adminMuteUser: {
|
||||||
|
success: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Интерфейс для пропсов AdminPage
|
// Интерфейс для пропсов AdminPage
|
||||||
interface AdminPageProps {
|
interface AdminPageProps {
|
||||||
onLogout?: () => void
|
onLogout?: () => void
|
||||||
|
@ -199,42 +219,41 @@ const AdminPage: Component<AdminPageProps> = (props) => {
|
||||||
* @param page - Номер страницы
|
* @param page - Номер страницы
|
||||||
*/
|
*/
|
||||||
function handlePageChange(page: number) {
|
function handlePageChange(page: number) {
|
||||||
if (page < 1 || page > pagination().totalPages) return
|
setPagination({ ...pagination(), page })
|
||||||
setPagination((prev) => ({ ...prev, page }))
|
|
||||||
loadUsers()
|
loadUsers()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обработчик изменения количества записей на странице
|
* Обработчик изменения количества элементов на странице
|
||||||
* @param limit - Количество записей на странице
|
* @param limit - Количество элементов
|
||||||
*/
|
*/
|
||||||
function handlePerPageChange(limit: number) {
|
function handlePerPageChange(limit: number) {
|
||||||
setPagination((prev) => ({ ...prev, page: 1, limit }))
|
setPagination({ ...pagination(), page: 1, limit })
|
||||||
loadUsers()
|
loadUsers()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обработчик изменения поискового запроса
|
* Обработчик изменения поискового запроса
|
||||||
* @param e - Событие изменения ввода
|
|
||||||
*/
|
*/
|
||||||
function handleSearchChange(e: Event) {
|
function handleSearchChange(e: Event) {
|
||||||
const target = e.target as HTMLInputElement
|
const input = e.target as HTMLInputElement
|
||||||
setSearchQuery(target.value)
|
setSearchQuery(input.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Выполняет поиск при нажатии Enter или кнопки поиска
|
* Выполняет поиск
|
||||||
*/
|
*/
|
||||||
function handleSearch() {
|
function handleSearch() {
|
||||||
setPagination((prev) => ({ ...prev, page: 1 })) // Сбрасываем на первую страницу при поиске
|
setPagination({ ...pagination(), page: 1 })
|
||||||
loadUsers()
|
loadUsers()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обработчик нажатия клавиши в поле поиска
|
* Обработчик нажатия клавиш в поле поиска
|
||||||
* @param e - Событие нажатия клавиши
|
* @param e - Событие клавиатуры
|
||||||
*/
|
*/
|
||||||
function handleSearchKeyDown(e: KeyboardEvent) {
|
function handleSearchKeyDown(e: KeyboardEvent) {
|
||||||
|
// Если нажат Enter, выполняем поиск
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleSearch()
|
handleSearch()
|
||||||
|
@ -242,101 +261,105 @@ const AdminPage: Component<AdminPageProps> = (props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Блокировка/разблокировка пользователя
|
* Блокирует/разблокирует пользователя
|
||||||
* @param userId - ID пользователя
|
* @param userId - ID пользователя
|
||||||
* @param isActive - Текущий статус активности
|
* @param isActive - Текущий статус активности
|
||||||
*/
|
*/
|
||||||
async function toggleUserBlock(userId: number, isActive: boolean) {
|
async function toggleUserBlock(userId: number, isActive: boolean) {
|
||||||
// Запрашиваем подтверждение
|
|
||||||
const action = isActive ? 'заблокировать' : 'разблокировать'
|
|
||||||
if (!confirm(`Вы действительно хотите ${action} этого пользователя?`)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await query(
|
setError(null)
|
||||||
|
|
||||||
|
// Устанавливаем новый статус (противоположный текущему)
|
||||||
|
const newStatus = !isActive
|
||||||
|
|
||||||
|
// Выполняем мутацию
|
||||||
|
const result = await query<AdminSetUserStatusResponse>(
|
||||||
`${location.origin}/graphql`,
|
`${location.origin}/graphql`,
|
||||||
`
|
`
|
||||||
mutation AdminToggleUserBlock($userId: Int!) {
|
mutation AdminSetUserStatus($userId: Int!, $isActive: Boolean!) {
|
||||||
adminToggleUserBlock(userId: $userId) {
|
adminSetUserStatus(userId: $userId, isActive: $isActive) {
|
||||||
success
|
success
|
||||||
error
|
error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
{ userId }
|
{ userId, isActive: newStatus }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Обновляем статус пользователя
|
// Проверяем результат
|
||||||
setUsers((prev) =>
|
if (result?.adminSetUserStatus?.success) {
|
||||||
prev.map((user) => {
|
// Обновляем список пользователей
|
||||||
if (user.id === userId) {
|
setSuccessMessage(`Пользователь ${newStatus ? 'разблокирован' : 'заблокирован'}`)
|
||||||
return { ...user, is_active: !isActive }
|
|
||||||
}
|
|
||||||
return user
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
// Показываем сообщение об успехе
|
// Обновляем пользователя в текущем списке
|
||||||
setSuccessMessage(`Пользователь успешно ${isActive ? 'заблокирован' : 'разблокирован'}`)
|
setUsers(
|
||||||
|
users().map((user) =>
|
||||||
|
user.id === userId ? { ...user, is_active: newStatus } : user
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
// Скрываем сообщение через 3 секунды
|
// Скрываем сообщение через 3 секунды
|
||||||
setTimeout(() => setSuccessMessage(null), 3000)
|
setTimeout(() => setSuccessMessage(null), 3000)
|
||||||
|
} else {
|
||||||
|
setError(result?.adminSetUserStatus?.error || 'Ошибка обновления статуса пользователя')
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Ошибка изменения статуса блокировки:', err)
|
console.error('Ошибка при изменении статуса пользователя:', err)
|
||||||
setError(err instanceof Error ? err.message : 'Ошибка изменения статуса блокировки')
|
setError(err instanceof Error ? err.message : 'Неизвестная ошибка')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Включение/отключение режима "mute" для пользователя
|
* Включает/отключает режим блокировки чата для пользователя
|
||||||
* @param userId - ID пользователя
|
* @param userId - ID пользователя
|
||||||
* @param isMuted - Текущий статус mute
|
* @param isMuted - Текущий статус блокировки чата
|
||||||
*/
|
*/
|
||||||
async function toggleUserMute(userId: number, isMuted: boolean) {
|
async function toggleUserMute(userId: number, isMuted: boolean) {
|
||||||
// Запрашиваем подтверждение
|
|
||||||
const action = isMuted ? 'включить звук' : 'отключить звук'
|
|
||||||
if (!confirm(`Вы действительно хотите ${action} для этого пользователя?`)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await query(
|
setError(null)
|
||||||
|
|
||||||
|
// Устанавливаем новый статус (противоположный текущему)
|
||||||
|
const newMuteStatus = !isMuted
|
||||||
|
|
||||||
|
// Выполняем мутацию
|
||||||
|
const result = await query<AdminMuteUserResponse>(
|
||||||
`${location.origin}/graphql`,
|
`${location.origin}/graphql`,
|
||||||
`
|
`
|
||||||
mutation AdminToggleUserMute($userId: Int!) {
|
mutation AdminMuteUser($userId: Int!, $muted: Boolean!) {
|
||||||
adminToggleUserMute(userId: $userId) {
|
adminMuteUser(userId: $userId, muted: $muted) {
|
||||||
success
|
success
|
||||||
error
|
error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
{ userId }
|
{ userId, muted: newMuteStatus }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Обновляем статус пользователя
|
// Проверяем результат
|
||||||
setUsers((prev) =>
|
if (result?.adminMuteUser?.success) {
|
||||||
prev.map((user) => {
|
// Обновляем сообщение об успехе
|
||||||
if (user.id === userId) {
|
setSuccessMessage(`${newMuteStatus ? 'Блокировка' : 'Разблокировка'} чата выполнена`)
|
||||||
return { ...user, muted: !isMuted }
|
|
||||||
}
|
|
||||||
return user
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
// Показываем сообщение об успехе
|
// Обновляем пользователя в текущем списке
|
||||||
setSuccessMessage(`Звук для пользователя успешно ${isMuted ? 'включен' : 'отключен'}`)
|
setUsers(
|
||||||
|
users().map((user) =>
|
||||||
|
user.id === userId ? { ...user, muted: newMuteStatus } : user
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
// Скрываем сообщение через 3 секунды
|
// Скрываем сообщение через 3 секунды
|
||||||
setTimeout(() => setSuccessMessage(null), 3000)
|
setTimeout(() => setSuccessMessage(null), 3000)
|
||||||
|
} else {
|
||||||
|
setError(result?.adminMuteUser?.error || 'Ошибка обновления статуса блокировки чата')
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Ошибка изменения статуса mute:', err)
|
console.error('Ошибка при изменении статуса блокировки чата:', err)
|
||||||
setError(err instanceof Error ? err.message : 'Ошибка изменения статуса mute')
|
setError(err instanceof Error ? err.message : 'Неизвестная ошибка')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Закрывает модальное окно управления ролями
|
* Закрывает модальное окно ролей
|
||||||
*/
|
*/
|
||||||
function closeRolesModal() {
|
function closeRolesModal() {
|
||||||
setShowRolesModal(false)
|
setShowRolesModal(false)
|
||||||
|
|
111
panel/auth.ts
111
panel/auth.ts
|
@ -3,29 +3,9 @@
|
||||||
* @module auth
|
* @module auth
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { query } from './graphql'
|
// Экспортируем константы для использования в других модулях
|
||||||
|
|
||||||
|
|
||||||
// Константа для имени ключа токена в localStorage
|
|
||||||
const AUTH_COOKIE_NAME = 'auth_token'
|
|
||||||
|
|
||||||
// Константа для имени ключа токена в cookie
|
|
||||||
export const AUTH_TOKEN_KEY = 'auth_token'
|
export const AUTH_TOKEN_KEY = 'auth_token'
|
||||||
|
export const CSRF_TOKEN_KEY = 'csrf_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 ''
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Интерфейс для учетных данных
|
* Интерфейс для учетных данных
|
||||||
|
@ -51,6 +31,36 @@ interface LoginResponse {
|
||||||
login: LoginResult
|
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 Статус авторизации
|
* @returns Статус авторизации
|
||||||
|
@ -77,13 +87,17 @@ export function logout(callback?: () => void): void {
|
||||||
localStorage.removeItem(AUTH_TOKEN_KEY)
|
localStorage.removeItem(AUTH_TOKEN_KEY)
|
||||||
|
|
||||||
// Для удаления cookie устанавливаем ей истекшее время жизни
|
// Для удаления 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 {
|
try {
|
||||||
fetch('/logout', {
|
fetch('/auth/logout', {
|
||||||
method: 'GET',
|
method: 'POST', // Используем POST вместо GET для операций изменения состояния
|
||||||
credentials: 'include'
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': getCsrfTokenFromCookie() // Добавляем CSRF токен если он есть
|
||||||
|
}
|
||||||
}).catch((e) => {
|
}).catch((e) => {
|
||||||
console.error('Ошибка при запросе на выход:', e)
|
console.error('Ошибка при запросе на выход:', e)
|
||||||
})
|
})
|
||||||
|
@ -96,16 +110,24 @@ export function logout(callback?: () => void): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Выполняет вход в систему
|
* Выполняет вход в систему используя GraphQL-запрос
|
||||||
* @param credentials - Учетные данные
|
* @param credentials - Учетные данные
|
||||||
* @returns Результат авторизации
|
* @returns Результат авторизации
|
||||||
*/
|
*/
|
||||||
export async function login(credentials: Credentials): Promise<boolean> {
|
export async function login(credentials: Credentials): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
// Используем query из graphql.ts для выполнения запроса
|
console.log('Отправка запроса авторизации через GraphQL')
|
||||||
const data = await query<LoginResponse>(
|
|
||||||
`${location.origin}/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!) {
|
mutation Login($email: String!, $password: String!) {
|
||||||
login(email: $email, password: $password) {
|
login(email: $email, password: $password) {
|
||||||
success
|
success
|
||||||
|
@ -114,29 +136,42 @@ export async function login(credentials: Credentials): Promise<boolean> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
{
|
variables: {
|
||||||
email: credentials.email,
|
email: credentials.email,
|
||||||
password: credentials.password
|
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
|
// Проверяем, установил ли сервер cookie
|
||||||
const cookieToken = getAuthTokenFromCookie()
|
const cookieToken = getAuthTokenFromCookie()
|
||||||
const hasCookie = !!cookieToken && cookieToken.length > 10
|
const hasCookie = !!cookieToken && cookieToken.length > 10
|
||||||
|
|
||||||
// Если cookie не установлена, но есть токен в ответе, сохраняем его в localStorage
|
// Если cookie не установлена, но есть токен в ответе, сохраняем его в localStorage
|
||||||
if (!hasCookie && data.login.token) {
|
if (!hasCookie && result.data.login.token) {
|
||||||
localStorage.setItem(AUTH_TOKEN_KEY, data.login.token)
|
localStorage.setItem(AUTH_TOKEN_KEY, result.data.login.token)
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
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) {
|
} catch (error) {
|
||||||
console.error('Ошибка при входе:', error)
|
console.error('Ошибка при входе:', error)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
* @module api
|
* @module api
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { AUTH_TOKEN_KEY, getAuthTokenFromCookie } from "./auth"
|
import { AUTH_TOKEN_KEY, CSRF_TOKEN_KEY, getAuthTokenFromCookie, getCsrfTokenFromCookie } from './auth'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Тип для произвольных данных GraphQL
|
* Тип для произвольных данных GraphQL
|
||||||
|
@ -62,20 +62,27 @@ function hasAuthErrors(errors: Array<{ message?: string; extensions?: { code?: s
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Выполняет GraphQL запрос
|
* Подготавливает URL для GraphQL запроса
|
||||||
* @param url - URL для запроса
|
* @param url - URL или путь для запроса
|
||||||
* @param query - GraphQL запрос
|
* @returns Полный URL для запроса
|
||||||
* @param variables - Переменные запроса
|
|
||||||
* @returns Результат запроса
|
|
||||||
*/
|
*/
|
||||||
export async function query<T = GraphQLData>(
|
function prepareUrl(url: string): string {
|
||||||
url: string,
|
// Если это относительный путь, добавляем к нему origin
|
||||||
query: string,
|
if (url.startsWith('/')) {
|
||||||
variables: Record<string, unknown> = {}
|
return `${location.origin}${url}`
|
||||||
): Promise<T> {
|
}
|
||||||
try {
|
// Если это уже полный URL, используем как есть
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает заголовки для GraphQL запроса с учетом авторизации и CSRF
|
||||||
|
* @returns Объект с заголовками
|
||||||
|
*/
|
||||||
|
function getRequestHeaders(): Record<string, string> {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем наличие токена в localStorage
|
// Проверяем наличие токена в localStorage
|
||||||
|
@ -89,13 +96,41 @@ export async function query<T = GraphQLData>(
|
||||||
|
|
||||||
// Если есть токен, добавляем его в заголовок Authorization с префиксом Bearer
|
// Если есть токен, добавляем его в заголовок Authorization с префиксом Bearer
|
||||||
if (token && token.length > 10) {
|
if (token && token.length > 10) {
|
||||||
// В соответствии с логами сервера, формат должен быть: Bearer <token>
|
|
||||||
headers['Authorization'] = `Bearer ${token}`
|
headers['Authorization'] = `Bearer ${token}`
|
||||||
// Для отладки
|
|
||||||
console.debug('Отправка запроса с токеном авторизации')
|
console.debug('Отправка запроса с токеном авторизации')
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(url, {
|
// Добавляем CSRF-токен, если он есть
|
||||||
|
const csrfToken = getCsrfTokenFromCookie()
|
||||||
|
if (csrfToken) {
|
||||||
|
headers['X-CSRF-Token'] = csrfToken
|
||||||
|
console.debug('Добавлен CSRF-токен в запрос')
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выполняет GraphQL запрос
|
||||||
|
* @param url - URL для запроса
|
||||||
|
* @param query - GraphQL запрос
|
||||||
|
* @param variables - Переменные запроса
|
||||||
|
* @returns Результат запроса
|
||||||
|
*/
|
||||||
|
export async function query<T = GraphQLData>(
|
||||||
|
url: string,
|
||||||
|
query: string,
|
||||||
|
variables: Record<string, unknown> = {}
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
// Получаем все необходимые заголовки для запроса
|
||||||
|
const headers = getRequestHeaders()
|
||||||
|
|
||||||
|
// Подготавливаем полный URL
|
||||||
|
const fullUrl = prepareUrl(url)
|
||||||
|
console.debug('Отправка GraphQL запроса на:', fullUrl)
|
||||||
|
|
||||||
|
const response = await fetch(fullUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
// Важно: credentials: 'include' - для передачи cookies с запросом
|
// Важно: credentials: 'include' - для передачи cookies с запросом
|
||||||
|
@ -115,8 +150,8 @@ export async function query<T = GraphQLData>(
|
||||||
error: errorMessage
|
error: errorMessage
|
||||||
})
|
})
|
||||||
|
|
||||||
// Если получен 401 Unauthorized, перенаправляем на страницу входа
|
// Если получен 401 Unauthorized или 403 Forbidden, перенаправляем на страницу входа
|
||||||
if (response.status === 401) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
localStorage.removeItem(AUTH_TOKEN_KEY)
|
localStorage.removeItem(AUTH_TOKEN_KEY)
|
||||||
window.location.href = '/'
|
window.location.href = '/'
|
||||||
throw new Error('Unauthorized')
|
throw new Error('Unauthorized')
|
||||||
|
|
|
@ -18,6 +18,7 @@ const LoginPage: Component<LoginPageProps> = (props) => {
|
||||||
const [password, setPassword] = createSignal('')
|
const [password, setPassword] = createSignal('')
|
||||||
const [isLoading, setIsLoading] = createSignal(false)
|
const [isLoading, setIsLoading] = createSignal(false)
|
||||||
const [error, setError] = createSignal<string | null>(null)
|
const [error, setError] = createSignal<string | null>(null)
|
||||||
|
const [formSubmitting, setFormSubmitting] = createSignal(false)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обработчик отправки формы входа
|
* Обработчик отправки формы входа
|
||||||
|
@ -26,6 +27,9 @@ const LoginPage: Component<LoginPageProps> = (props) => {
|
||||||
const handleSubmit = async (e: Event) => {
|
const handleSubmit = async (e: Event) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
|
// Предотвращаем повторную отправку формы
|
||||||
|
if (formSubmitting()) return
|
||||||
|
|
||||||
// Очищаем пробелы в email
|
// Очищаем пробелы в email
|
||||||
const cleanEmail = email().trim()
|
const cleanEmail = email().trim()
|
||||||
|
|
||||||
|
@ -34,6 +38,7 @@ const LoginPage: Component<LoginPageProps> = (props) => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setFormSubmitting(true)
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
|
@ -56,6 +61,8 @@ const LoginPage: Component<LoginPageProps> = (props) => {
|
||||||
console.error('Ошибка при входе:', err)
|
console.error('Ошибка при входе:', err)
|
||||||
setError(err instanceof Error ? err.message : 'Неизвестная ошибка')
|
setError(err instanceof Error ? err.message : 'Неизвестная ошибка')
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
|
} finally {
|
||||||
|
setFormSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -66,12 +73,13 @@ const LoginPage: Component<LoginPageProps> = (props) => {
|
||||||
|
|
||||||
{error() && <div class="error-message">{error()}</div>}
|
{error() && <div class="error-message">{error()}</div>}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit} method="post">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="email">Email</label>
|
<label for="email">Email</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
id="email"
|
||||||
|
name="email"
|
||||||
value={email()}
|
value={email()}
|
||||||
onInput={(e) => setEmail(e.currentTarget.value)}
|
onInput={(e) => setEmail(e.currentTarget.value)}
|
||||||
disabled={isLoading()}
|
disabled={isLoading()}
|
||||||
|
@ -85,6 +93,7 @@ const LoginPage: Component<LoginPageProps> = (props) => {
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
id="password"
|
id="password"
|
||||||
|
name="password"
|
||||||
value={password()}
|
value={password()}
|
||||||
onInput={(e) => setPassword(e.currentTarget.value)}
|
onInput={(e) => setPassword(e.currentTarget.value)}
|
||||||
disabled={isLoading()}
|
disabled={isLoading()}
|
||||||
|
@ -93,8 +102,15 @@ const LoginPage: Component<LoginPageProps> = (props) => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" disabled={isLoading()}>
|
<button type="submit" disabled={isLoading() || formSubmitting()}>
|
||||||
{isLoading() ? 'Вход...' : 'Войти'}
|
{isLoading() ? (
|
||||||
|
<>
|
||||||
|
<span class="spinner"></span>
|
||||||
|
Вход...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Войти'
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
# own auth
|
|
||||||
bcrypt
|
bcrypt
|
||||||
authlib
|
authlib
|
||||||
passlib
|
passlib
|
||||||
|
|
|
@ -61,9 +61,33 @@ from resolvers.topic import (
|
||||||
get_topics_by_community,
|
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()
|
events_register()
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
# auth
|
||||||
|
"get_current_user",
|
||||||
|
"confirm_email",
|
||||||
|
"register_by_email",
|
||||||
|
"send_link",
|
||||||
|
"login",
|
||||||
|
|
||||||
|
# admin
|
||||||
|
"admin_get_users",
|
||||||
|
"admin_get_roles",
|
||||||
|
|
||||||
# author
|
# author
|
||||||
"get_author",
|
"get_author",
|
||||||
"get_author_id",
|
"get_author_id",
|
||||||
|
@ -74,10 +98,12 @@ __all__ = [
|
||||||
"get_authors_all",
|
"get_authors_all",
|
||||||
"load_authors_by",
|
"load_authors_by",
|
||||||
"update_author",
|
"update_author",
|
||||||
## "search_authors",
|
# "search_authors",
|
||||||
|
|
||||||
# community
|
# community
|
||||||
"get_community",
|
"get_community",
|
||||||
"get_communities_all",
|
"get_communities_all",
|
||||||
|
|
||||||
# topic
|
# topic
|
||||||
"get_topic",
|
"get_topic",
|
||||||
"get_topics_all",
|
"get_topics_all",
|
||||||
|
@ -85,12 +111,14 @@ __all__ = [
|
||||||
"get_topics_by_author",
|
"get_topics_by_author",
|
||||||
"get_topic_followers",
|
"get_topic_followers",
|
||||||
"get_topic_authors",
|
"get_topic_authors",
|
||||||
|
|
||||||
# reader
|
# reader
|
||||||
"get_shout",
|
"get_shout",
|
||||||
"load_shouts_by",
|
"load_shouts_by",
|
||||||
"load_shouts_random_top",
|
"load_shouts_random_top",
|
||||||
"load_shouts_search",
|
"load_shouts_search",
|
||||||
"load_shouts_unrated",
|
"load_shouts_unrated",
|
||||||
|
|
||||||
# feed
|
# feed
|
||||||
"load_shouts_feed",
|
"load_shouts_feed",
|
||||||
"load_shouts_coauthored",
|
"load_shouts_coauthored",
|
||||||
|
@ -98,10 +126,12 @@ __all__ = [
|
||||||
"load_shouts_with_topic",
|
"load_shouts_with_topic",
|
||||||
"load_shouts_followed_by",
|
"load_shouts_followed_by",
|
||||||
"load_shouts_authored_by",
|
"load_shouts_authored_by",
|
||||||
|
|
||||||
# follower
|
# follower
|
||||||
"follow",
|
"follow",
|
||||||
"unfollow",
|
"unfollow",
|
||||||
"get_shout_followers",
|
"get_shout_followers",
|
||||||
|
|
||||||
# reaction
|
# reaction
|
||||||
"create_reaction",
|
"create_reaction",
|
||||||
"update_reaction",
|
"update_reaction",
|
||||||
|
@ -111,15 +141,18 @@ __all__ = [
|
||||||
"load_shout_ratings",
|
"load_shout_ratings",
|
||||||
"load_comment_ratings",
|
"load_comment_ratings",
|
||||||
"load_comments_branch",
|
"load_comments_branch",
|
||||||
|
|
||||||
# notifier
|
# notifier
|
||||||
"load_notifications",
|
"load_notifications",
|
||||||
"notifications_seen_thread",
|
"notifications_seen_thread",
|
||||||
"notifications_seen_after",
|
"notifications_seen_after",
|
||||||
"notification_mark_seen",
|
"notification_mark_seen",
|
||||||
|
|
||||||
# rating
|
# rating
|
||||||
"rate_author",
|
"rate_author",
|
||||||
"get_my_rates_comments",
|
"get_my_rates_comments",
|
||||||
"get_my_rates_shouts",
|
"get_my_rates_shouts",
|
||||||
|
|
||||||
# draft
|
# draft
|
||||||
"load_drafts",
|
"load_drafts",
|
||||||
"create_draft",
|
"create_draft",
|
||||||
|
|
122
resolvers/admin.py
Normal file
122
resolvers/admin.py
Normal file
|
@ -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)}")
|
|
@ -8,7 +8,6 @@ from graphql.type import GraphQLResolveInfo
|
||||||
|
|
||||||
from auth.authenticate import login_required
|
from auth.authenticate import login_required
|
||||||
from auth.credentials import AuthCredentials
|
from auth.credentials import AuthCredentials
|
||||||
from auth.decorators import admin_auth_required
|
|
||||||
from auth.email import send_auth_email
|
from auth.email import send_auth_email
|
||||||
from auth.exceptions import InvalidToken, ObjectNotExist
|
from auth.exceptions import InvalidToken, ObjectNotExist
|
||||||
from auth.identity import Identity, Password
|
from auth.identity import Identity, Password
|
||||||
|
@ -26,10 +25,8 @@ from settings import (
|
||||||
SESSION_COOKIE_HTTPONLY,
|
SESSION_COOKIE_HTTPONLY,
|
||||||
)
|
)
|
||||||
from utils.generate_slug import generate_unique_slug
|
from utils.generate_slug import generate_unique_slug
|
||||||
from graphql.error import GraphQLError
|
from auth.sessions import SessionManager
|
||||||
from math import ceil
|
from auth.internal import verify_internal_auth
|
||||||
from sqlalchemy import or_
|
|
||||||
|
|
||||||
|
|
||||||
@mutation.field("getSession")
|
@mutation.field("getSession")
|
||||||
@login_required
|
@login_required
|
||||||
|
@ -152,7 +149,7 @@ async def register_by_email(_, _info, email: str, password: str = "", name: str
|
||||||
# Попытка отправить ссылку для подтверждения email
|
# Попытка отправить ссылку для подтверждения email
|
||||||
try:
|
try:
|
||||||
# Если auth_send_link асинхронный...
|
# Если auth_send_link асинхронный...
|
||||||
await auth_send_link(_, _info, email)
|
await send_link(_, _info, email)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[auth] registerUser: Пользователь {email} зарегистрирован, ссылка для подтверждения отправлена."
|
f"[auth] registerUser: Пользователь {email} зарегистрирован, ссылка для подтверждения отправлена."
|
||||||
)
|
)
|
||||||
|
@ -173,7 +170,7 @@ async def register_by_email(_, _info, email: str, password: str = "", name: str
|
||||||
|
|
||||||
|
|
||||||
@mutation.field("sendLink")
|
@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()
|
email = email.lower()
|
||||||
"""send link with confirm code to email"""
|
"""send link with confirm code to email"""
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
|
@ -189,7 +186,7 @@ async def auth_send_link(_, _info, email, lang="ru", template="email_confirmatio
|
||||||
|
|
||||||
|
|
||||||
@mutation.field("login")
|
@mutation.field("login")
|
||||||
async def login_mutation(_, info, email: str, password: str):
|
async def login(_, info, email: str, password: str):
|
||||||
"""
|
"""
|
||||||
Авторизация пользователя с помощью email и пароля.
|
Авторизация пользователя с помощью email и пароля.
|
||||||
|
|
||||||
|
@ -351,113 +348,150 @@ async def is_email_used(_, _info, email):
|
||||||
return user is not None
|
return user is not None
|
||||||
|
|
||||||
|
|
||||||
@query.field("adminGetUsers")
|
@mutation.field("logout")
|
||||||
@admin_auth_required
|
async def logout_resolver(_, info: GraphQLResolveInfo):
|
||||||
async def admin_get_users(_, info, limit=10, offset=0, search=None):
|
|
||||||
"""
|
"""
|
||||||
Получает список пользователей для админ-панели с поддержкой пагинации и поиска
|
Выход из системы через GraphQL с удалением сессии и cookie.
|
||||||
|
|
||||||
Args:
|
|
||||||
info: Контекст GraphQL запроса
|
|
||||||
limit: Максимальное количество записей для получения
|
|
||||||
offset: Смещение в списке результатов
|
|
||||||
search: Строка поиска (по email, имени или ID)
|
|
||||||
|
|
||||||
Returns:
|
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:
|
try:
|
||||||
# Нормализуем параметры
|
# Декодируем токен для получения user_id
|
||||||
limit = max(1, min(100, limit or 10)) # Ограничиваем количество записей от 1 до 100
|
user_id, _ = await verify_internal_auth(token)
|
||||||
offset = max(0, offset or 0) # Смещение не может быть отрицательным
|
if user_id:
|
||||||
|
# Отзываем сессию
|
||||||
with local_session() as session:
|
await SessionManager.revoke_session(user_id, token)
|
||||||
# Базовый запрос
|
logger.info(f"[auth] logout_resolver: Токен успешно отозван для пользователя {user_id}")
|
||||||
query = session.query(Author)
|
success = True
|
||||||
|
message = "Выход выполнен успешно"
|
||||||
# Применяем фильтр поиска, если указан
|
else:
|
||||||
if search and search.strip():
|
logger.warning("[auth] logout_resolver: Не удалось получить user_id из токена")
|
||||||
search_term = f"%{search.strip().lower()}%"
|
message = "Не удалось обработать токен"
|
||||||
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:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при получении списка пользователей: {str(e)}")
|
logger.error(f"[auth] logout_resolver: Ошибка при отзыве токена: {e}")
|
||||||
|
message = f"Ошибка при выходе: {str(e)}"
|
||||||
|
else:
|
||||||
|
message = "Токен не найден"
|
||||||
|
success = True # Если токена нет, то пользователь уже вышел из системы
|
||||||
|
|
||||||
|
# Удаляем cookie через extensions
|
||||||
|
try:
|
||||||
|
# Используем 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:
|
||||||
|
author = session.query(Author).filter(Author.id == user_id).first()
|
||||||
|
|
||||||
|
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())
|
||||||
|
|
||||||
|
logger.info(f"[auth] refresh_token_resolver: Токен успешно обновлен для пользователя {user_id}")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"token": new_token,
|
||||||
|
"author": author,
|
||||||
|
"error": None
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[auth] refresh_token_resolver: Ошибка при обновлении токена: {e}")
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
raise GraphQLError(f"Не удалось получить список пользователей: {str(e)}")
|
return {"success": False, "token": None, "author": None, "error": 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)}")
|
|
|
@ -1,6 +1,8 @@
|
||||||
type Mutation {
|
type Mutation {
|
||||||
# Auth mutations
|
# Auth mutations
|
||||||
login(email: String!, password: String!): AuthResult!
|
login(email: String!, password: String!): AuthResult!
|
||||||
|
logout: AuthSuccess!
|
||||||
|
refreshToken: AuthResult!
|
||||||
registerUser(email: String!, password: String, name: String): AuthResult!
|
registerUser(email: String!, password: String, name: String): AuthResult!
|
||||||
sendLink(email: String!, lang: String, template: String): Author!
|
sendLink(email: String!, lang: String, template: String): Author!
|
||||||
confirmEmail(token: String!): AuthResult!
|
confirmEmail(token: String!): AuthResult!
|
||||||
|
|
0
services/run.py
Normal file
0
services/run.py
Normal file
|
@ -4,7 +4,6 @@ import os
|
||||||
import sys
|
import sys
|
||||||
from os import environ
|
from os import environ
|
||||||
|
|
||||||
MODE = "development" if "dev" in sys.argv else "production"
|
|
||||||
DEV_SERVER_PID_FILE_NAME = "dev-server.pid"
|
DEV_SERVER_PID_FILE_NAME = "dev-server.pid"
|
||||||
|
|
||||||
PORT = environ.get("PORT") or 8000
|
PORT = environ.get("PORT") or 8000
|
||||||
|
@ -59,7 +58,7 @@ JWT_ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS = 30
|
JWT_REFRESH_TOKEN_EXPIRE_DAYS = 30
|
||||||
|
|
||||||
# Настройки сессии
|
# Настройки сессии
|
||||||
SESSION_COOKIE_NAME = "session_token"
|
SESSION_COOKIE_NAME = "auth_token"
|
||||||
SESSION_COOKIE_SECURE = True
|
SESSION_COOKIE_SECURE = True
|
||||||
SESSION_COOKIE_HTTPONLY = True
|
SESSION_COOKIE_HTTPONLY = True
|
||||||
SESSION_COOKIE_SAMESITE = "lax"
|
SESSION_COOKIE_SAMESITE = "lax"
|
||||||
|
|
|
@ -161,7 +161,7 @@ with (
|
||||||
|
|
||||||
assert isinstance(response, RedirectResponse)
|
assert isinstance(response, RedirectResponse)
|
||||||
assert response.status_code == 307
|
assert response.status_code == 307
|
||||||
assert "auth/success" in response.headers["location"]
|
assert "auth/success" in response.headers.get("location", "")
|
||||||
|
|
||||||
# Проверяем cookie
|
# Проверяем cookie
|
||||||
cookies = response.headers.getlist("set-cookie")
|
cookies = response.headers.getlist("set-cookie")
|
||||||
|
|
Loading…
Reference in New Issue
Block a user