This commit is contained in:
Untone 2025-05-19 11:25:41 +03:00
parent 11e46f7352
commit dc5ad46df9
20 changed files with 952 additions and 509 deletions

View File

@ -36,6 +36,10 @@
- Пагинация списка пользователей в админ-панели - Пагинация списка пользователей в админ-панели
- Серверная поддержка пагинации в API для админ-панели - Серверная поддержка пагинации в API для админ-панели
- Поиск пользователей по email, имени и ID - Поиск пользователей по email, имени и ID
- Поддержка локального запуска сервера с HTTPS через `python run.py --https` с использованием Granian
- Интеграция с инструментом mkcert для генерации доверенных локальных SSL-сертификатов
- Поддержка запуска нескольких рабочих процессов через параметр `--workers`
- Возможность указать произвольный домен для сертификата через `--domain`
### Улучшено ### Улучшено
- Улучшен интерфейс админ-панели: - Улучшен интерфейс админ-панели:

View File

@ -1 +0,0 @@

View File

@ -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"]),
]

View File

@ -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

View File

@ -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
View 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()

View File

@ -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
View File

@ -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],
) )

View File

@ -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)

View File

@ -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
} }
} }

View File

@ -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')

View File

@ -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>

View File

@ -1,4 +1,3 @@
# own auth
bcrypt bcrypt
authlib authlib
passlib passlib

View File

@ -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
View 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)}")

View File

@ -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)}")

View File

@ -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
View File

View 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"

View File

@ -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")