wip
This commit is contained in:
parent
11e46f7352
commit
dc5ad46df9
|
@ -36,6 +36,10 @@
|
|||
- Пагинация списка пользователей в админ-панели
|
||||
- Серверная поддержка пагинации в API для админ-панели
|
||||
- Поиск пользователей по 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:
|
||||
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"]),
|
||||
]
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
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()
|
|
@ -31,4 +31,46 @@ SESSION_TOKEN_LIFE_SPAN = 60 * 60 * 24 * 30 # время жизни сесси
|
|||
- Проверка доступа по email или правам в системе RBAC
|
||||
|
||||
Маршруты:
|
||||
- `/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)
|
||||
- Простая установка и настройка
|
210
main.py
210
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(
|
||||
"""
|
||||
<!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():
|
||||
"""Запуск сервера и инициализация данных"""
|
||||
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],
|
||||
)
|
||||
|
|
183
panel/admin.tsx
183
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<AdminPageProps> = (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<AdminPageProps> = (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<AdminSetUserStatusResponse>(
|
||||
`${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<AdminMuteUserResponse>(
|
||||
`${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)
|
||||
|
|
131
panel/auth.ts
131
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<boolean> {
|
||||
try {
|
||||
// Используем query из graphql.ts для выполнения запроса
|
||||
const data = await query<LoginResponse>(
|
||||
`${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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'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<T = GraphQLData>(
|
|||
variables: Record<string, unknown> = {}
|
||||
): Promise<T> {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'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 <token>
|
||||
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<T = GraphQLData>(
|
|||
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')
|
||||
|
|
|
@ -18,6 +18,7 @@ const LoginPage: Component<LoginPageProps> = (props) => {
|
|||
const [password, setPassword] = createSignal('')
|
||||
const [isLoading, setIsLoading] = createSignal(false)
|
||||
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) => {
|
||||
e.preventDefault()
|
||||
|
||||
// Предотвращаем повторную отправку формы
|
||||
if (formSubmitting()) return
|
||||
|
||||
// Очищаем пробелы в email
|
||||
const cleanEmail = email().trim()
|
||||
|
||||
|
@ -34,6 +38,7 @@ const LoginPage: Component<LoginPageProps> = (props) => {
|
|||
return
|
||||
}
|
||||
|
||||
setFormSubmitting(true)
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
|
@ -56,6 +61,8 @@ const LoginPage: Component<LoginPageProps> = (props) => {
|
|||
console.error('Ошибка при входе:', err)
|
||||
setError(err instanceof Error ? err.message : 'Неизвестная ошибка')
|
||||
setIsLoading(false)
|
||||
} finally {
|
||||
setFormSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -66,12 +73,13 @@ const LoginPage: Component<LoginPageProps> = (props) => {
|
|||
|
||||
{error() && <div class="error-message">{error()}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<form onSubmit={handleSubmit} method="post">
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={email()}
|
||||
onInput={(e) => setEmail(e.currentTarget.value)}
|
||||
disabled={isLoading()}
|
||||
|
@ -85,6 +93,7 @@ const LoginPage: Component<LoginPageProps> = (props) => {
|
|||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
value={password()}
|
||||
onInput={(e) => setPassword(e.currentTarget.value)}
|
||||
disabled={isLoading()}
|
||||
|
@ -93,8 +102,15 @@ const LoginPage: Component<LoginPageProps> = (props) => {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={isLoading()}>
|
||||
{isLoading() ? 'Вход...' : 'Войти'}
|
||||
<button type="submit" disabled={isLoading() || formSubmitting()}>
|
||||
{isLoading() ? (
|
||||
<>
|
||||
<span class="spinner"></span>
|
||||
Вход...
|
||||
</>
|
||||
) : (
|
||||
'Войти'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
# own auth
|
||||
bcrypt
|
||||
authlib
|
||||
passlib
|
||||
|
|
|
@ -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",
|
||||
|
|
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.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)}
|
|
@ -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!
|
||||
|
|
0
services/run.py
Normal file
0
services/run.py
Normal file
|
@ -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"
|
||||
|
|
|
@ -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")
|
||||
|
|
Loading…
Reference in New Issue
Block a user