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 для админ-панели
- Поиск пользователей по 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:
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"]),
]

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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