userlist-demo-ready
All checks were successful
Deploy on push / deploy (push) Successful in 6s

This commit is contained in:
Untone 2025-05-20 00:00:24 +03:00
parent dc5ad46df9
commit 1d64811880
17 changed files with 1347 additions and 447 deletions

View File

@ -59,6 +59,13 @@
- Унифицирован стиль кода и именования
### Исправлено
- Исправлена критическая проблема с JWT-токенами авторизации:
- Устранена ошибка декодирования токенов `int() argument must be a string, a bytes-like object or a real number, not 'NoneType'`
- Обновлен механизм создания токенов для гарантированного задания срока истечения (exp)
- Улучшена обработка ошибок в модуле аутентификации для предотвращения создания невалидных токенов
- Стандартизован формат параметра exp в JWT: теперь всегда используется timestamp вместо datetime
- Добавлена проверка наличия обязательных полей при декодировании токенов
- Оптимизирована совместимость между разными способами хранения сессий
- Исправлена проблема с перенаправлением в SolidJS, которое сбрасывало состояние приложения:
- Обновлена функция logout для использования колбэка навигации вместо жесткого редиректа
- Добавлен компонент LoginPage для авторизации без перезагрузки страницы
@ -107,8 +114,8 @@
- Страница входа для неавторизованных пользователей в админке
- Публичное GraphQL API для модуля аутентификации:
- Типы: `AuthResult`, `Permission`, `SessionInfo`, `OAuthProvider`
- Мутации: `login`, `registerUser`, `sendLink`, `confirmEmail`, `getSession`, `changePassword`
- Запросы: `signOut`, `me`, `isEmailUsed`, `getOAuthProviders`
- Мутации: `login`, `registerUser`, `sendLink`, `confirmEmail`, `getSession`, `changePassword`, `refreshToken`
- Запросы: `logout`, `me`, `isEmailUsed`, `getOAuthProviders`
### Changed
- Переработана структура модуля auth для лучшей модульности

View File

@ -13,20 +13,42 @@ from settings import (
SESSION_COOKIE_SECURE,
SESSION_COOKIE_SAMESITE,
SESSION_COOKIE_MAX_AGE,
SESSION_TOKEN_HEADER,
)
async def logout(request: Request):
"""
Выход из системы с удалением сессии и cookie.
Поддерживает получение токена из:
1. HTTP-only cookie
2. Заголовка Authorization
"""
# Получаем токен из cookie или заголовка
token = None
# Получаем токен из cookie
if SESSION_COOKIE_NAME in request.cookies:
token = request.cookies.get(SESSION_COOKIE_NAME)
logger.debug(f"[auth] logout: Получен токен из cookie {SESSION_COOKIE_NAME}")
# Если токен не найден в cookie, проверяем заголовок
if not token:
# Проверяем заголовок авторизации
# Сначала проверяем основной заголовок авторизации
auth_header = request.headers.get(SESSION_TOKEN_HEADER)
if auth_header:
if auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
logger.debug(f"[auth] logout: Получен Bearer токен из заголовка {SESSION_TOKEN_HEADER}")
else:
token = auth_header.strip()
logger.debug(f"[auth] logout: Получен прямой токен из заголовка {SESSION_TOKEN_HEADER}")
# Если токен не найден в основном заголовке, проверяем стандартный Authorization
if not token and "Authorization" in request.headers:
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:] # Отрезаем "Bearer "
token = auth_header[7:].strip()
logger.debug("[auth] logout: Получен Bearer токен из заголовка Authorization")
# Если токен найден, отзываем его
if token:
@ -41,12 +63,19 @@ async def logout(request: Request):
logger.warning("[auth] logout: Не удалось получить user_id из токена")
except Exception as e:
logger.error(f"[auth] logout: Ошибка при отзыве токена: {e}")
else:
logger.warning("[auth] logout: Токен не найден в запросе")
# Создаем ответ с редиректом на страницу входа
response = RedirectResponse(url="/")
# Удаляем cookie с токеном
response.delete_cookie(SESSION_COOKIE_NAME)
response.delete_cookie(
key=SESSION_COOKIE_NAME,
secure=SESSION_COOKIE_SECURE,
httponly=SESSION_COOKIE_HTTPONLY,
samesite=SESSION_COOKIE_SAMESITE
)
logger.info("[auth] logout: Cookie успешно удалена")
return response
@ -55,21 +84,53 @@ async def logout(request: Request):
async def refresh_token(request: Request):
"""
Обновление токена аутентификации.
Поддерживает получение токена из:
1. HTTP-only cookie
2. Заголовка Authorization
Возвращает новый токен как в HTTP-only cookie, так и в теле ответа.
"""
# Получаем текущий токен из cookie или заголовка
token = None
source = None
# Получаем текущий токен из cookie
if SESSION_COOKIE_NAME in request.cookies:
token = request.cookies.get(SESSION_COOKIE_NAME)
source = "cookie"
logger.debug(f"[auth] refresh_token: Токен получен из cookie {SESSION_COOKIE_NAME}")
# Если токен не найден в cookie, проверяем заголовок авторизации
if not token:
# Проверяем основной заголовок авторизации
auth_header = request.headers.get(SESSION_TOKEN_HEADER)
if auth_header:
if auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
source = "header"
logger.debug(f"[auth] refresh_token: Токен получен из заголовка {SESSION_TOKEN_HEADER} (Bearer)")
else:
token = auth_header.strip()
source = "header"
logger.debug(f"[auth] refresh_token: Токен получен из заголовка {SESSION_TOKEN_HEADER} (прямой)")
# Если токен не найден в основном заголовке, проверяем стандартный Authorization
if not token and "Authorization" in request.headers:
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:] # Отрезаем "Bearer "
token = auth_header[7:].strip()
source = "header"
logger.debug("[auth] refresh_token: Токен получен из заголовка Authorization")
if not token:
logger.warning("[auth] refresh_token: Токен не найден в запросе")
return JSONResponse({"success": False, "error": "Токен не найден"}, status_code=401)
try:
# Получаем информацию о пользователе из токена
user_id, _ = await verify_internal_auth(token)
if not user_id:
logger.warning("[auth] refresh_token: Недействительный токен")
return JSONResponse({"success": False, "error": "Недействительный токен"}, status_code=401)
# Получаем пользователя из базы данных
@ -77,6 +138,7 @@ async def refresh_token(request: Request):
author = session.query(Author).filter(Author.id == user_id).first()
if not author:
logger.warning(f"[auth] refresh_token: Пользователь с ID {user_id} не найден")
return JSONResponse({"success": False, "error": "Пользователь не найден"}, status_code=404)
# Обновляем сессию (создаем новую и отзываем старую)
@ -84,6 +146,7 @@ async def refresh_token(request: Request):
new_token = await SessionManager.refresh_session(user_id, token, device_info)
if not new_token:
logger.error(f"[auth] refresh_token: Не удалось обновить токен для пользователя {user_id}")
return JSONResponse(
{"success": False, "error": "Не удалось обновить токен"}, status_code=500
)
@ -92,12 +155,13 @@ async def refresh_token(request: Request):
response = JSONResponse(
{
"success": True,
"token": new_token,
# Возвращаем токен в теле ответа только если он был получен из заголовка
"token": new_token if source == "header" else None,
"author": {"id": author.id, "email": author.email, "name": author.name},
}
)
# Устанавливаем cookie с новым токеном
# Всегда устанавливаем cookie с новым токеном
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=new_token,

View File

@ -1,133 +0,0 @@
from functools import wraps
from typing import Optional
from graphql.type import GraphQLResolveInfo
from sqlalchemy.orm import exc
from starlette.authentication import AuthenticationBackend
from starlette.requests import HTTPConnection
from auth.credentials import AuthCredentials
from auth.exceptions import OperationNotAllowed
from auth.sessions import SessionManager
from auth.orm import Author
from services.db import local_session
from settings import SESSION_TOKEN_HEADER
class JWTAuthenticate(AuthenticationBackend):
async def authenticate(self, request: HTTPConnection) -> Optional[AuthCredentials]:
"""
Аутентификация пользователя по JWT токену.
Args:
request: HTTP запрос
Returns:
AuthCredentials при успешной аутентификации или None при ошибке
"""
if SESSION_TOKEN_HEADER not in request.headers:
return None
auth_header = request.headers.get(SESSION_TOKEN_HEADER)
if not auth_header:
print("[auth.authenticate] no token in header %s" % SESSION_TOKEN_HEADER)
return None
# Обработка формата "Bearer <token>"
token = auth_header
if auth_header.startswith("Bearer "):
token = auth_header.replace("Bearer ", "", 1).strip()
if not token:
print("[auth.authenticate] empty token after Bearer prefix removal")
return None
# Проверяем сессию в Redis
payload = await SessionManager.verify_session(token)
if not payload:
return None
with local_session() as session:
try:
author = (
session.query(Author)
.filter(Author.id == payload.user_id)
.filter(Author.is_active == True) # noqa
.one()
)
if author.is_locked():
return None
# Получаем разрешения из ролей
scopes = author.get_permissions()
return AuthCredentials(
author_id=author.id, scopes=scopes, logged_in=True, email=author.email
)
except exc.NoResultFound:
return None
def login_required(func):
@wraps(func)
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
auth: AuthCredentials = info.context["request"].auth
if not auth or not auth.logged_in:
return {"error": "Please login first"}
return await func(parent, info, *args, **kwargs)
return wrap
def permission_required(resource: str, operation: str, func):
"""
Декоратор для проверки разрешений.
Args:
resource (str): Ресурс для проверки
operation (str): Операция для проверки
func: Декорируемая функция
"""
@wraps(func)
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
auth: AuthCredentials = info.context["request"].auth
if not auth.logged_in:
raise OperationNotAllowed(auth.error_message or "Please login")
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):
raise OperationNotAllowed(f"No permission for {operation} on {resource}")
return await func(parent, info, *args, **kwargs)
return wrap
def login_accepted(func):
@wraps(func)
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
auth: AuthCredentials = info.context["request"].auth
if auth and auth.logged_in:
with local_session() as session:
author = session.query(Author).filter(Author.id == auth.author_id).one()
info.context["author"] = author.dict()
info.context["user_id"] = author.id
else:
info.context["author"] = None
info.context["user_id"] = None
return await func(parent, info, *args, **kwargs)
return wrap

View File

@ -29,6 +29,7 @@ class AuthCredentials(BaseModel):
logged_in: bool = Field(False, description="Флаг, указывающий, авторизован ли пользователь")
error_message: str = Field("", description="Сообщение об ошибке аутентификации")
email: Optional[str] = Field(None, description="Email пользователя")
token: Optional[str] = Field(None, description="JWT токен авторизации")
def get_permissions(self) -> List[str]:
"""

View File

@ -1,11 +1,19 @@
from functools import wraps
from typing import Callable, Any, Dict, Optional
from graphql import GraphQLError
from graphql import GraphQLError, GraphQLResolveInfo
from sqlalchemy import exc
from auth.credentials import AuthCredentials
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, SESSION_COOKIE_NAME
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST, SESSION_TOKEN_HEADER, SESSION_COOKIE_NAME
from auth.sessions import SessionManager
from auth.jwtcodec import JWTCodec, InvalidToken, ExpiredToken
from auth.tokenstorage import TokenStorage
from services.redis import redis
from auth.internal import authenticate
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
@ -22,24 +30,51 @@ def get_safe_headers(request: Any) -> Dict[str, str]:
"""
headers = {}
try:
# Проверяем разные варианты доступа к заголовкам
if hasattr(request, "_headers"):
headers.update(request._headers)
if hasattr(request, "headers"):
headers.update(request.headers)
# Первый приоритет: scope из ASGI (самый надежный источник)
if hasattr(request, "scope") and isinstance(request.scope, dict):
scope_headers = request.scope.get("headers", [])
if scope_headers:
headers.update({
k.decode("utf-8").lower(): v.decode("utf-8")
for k, v in request.scope.get("headers", [])
for k, v in scope_headers
})
logger.debug(f"[decorators] Получены заголовки из request.scope: {len(headers)}")
# Второй приоритет: метод headers() или атрибут headers
if hasattr(request, "headers"):
if callable(request.headers):
h = request.headers()
if h:
headers.update({k.lower(): v for k, v in h.items()})
logger.debug(f"[decorators] Получены заголовки из request.headers() метода: {len(headers)}")
else:
h = request.headers
if hasattr(h, "items") and callable(h.items):
headers.update({k.lower(): v for k, v in h.items()})
logger.debug(f"[decorators] Получены заголовки из request.headers атрибута: {len(headers)}")
elif isinstance(h, dict):
headers.update({k.lower(): v for k, v in h.items()})
logger.debug(f"[decorators] Получены заголовки из request.headers словаря: {len(headers)}")
# Третий приоритет: атрибут _headers
if hasattr(request, "_headers") and request._headers:
headers.update({k.lower(): v for k, v in request._headers.items()})
logger.debug(f"[decorators] Получены заголовки из request._headers: {len(headers)}")
except Exception as e:
logger.warning(f"Error accessing headers: {e}")
logger.warning(f"[decorators] Ошибка при доступе к заголовкам: {e}")
return headers
def get_auth_token(request: Any) -> Optional[str]:
"""
Извлекает токен авторизации из запроса.
Порядок проверки:
1. Проверяет auth из middleware
2. Проверяет auth из scope
3. Проверяет заголовок Authorization
4. Проверяет cookie с именем auth_token
Args:
request: Объект запроса
@ -48,60 +83,115 @@ def get_auth_token(request: Any) -> Optional[str]:
Optional[str]: Токен авторизации или None
"""
try:
# Проверяем auth из middleware
# 1. Проверяем auth из middleware (если middleware уже обработал токен)
if hasattr(request, "auth") and request.auth:
return getattr(request.auth, "token", None)
token = getattr(request.auth, "token", None)
if token:
logger.debug(f"[decorators] Токен получен из request.auth: {len(token)}")
return token
# Проверяем заголовок
# 2. Проверяем наличие auth в scope
if hasattr(request, "scope") and isinstance(request.scope, dict) and "auth" in request.scope:
auth_info = request.scope.get("auth", {})
if isinstance(auth_info, dict) and "token" in auth_info:
token = auth_info["token"]
logger.debug(f"[decorators] Токен получен из request.scope['auth']: {len(token)}")
return token
# 3. Проверяем заголовок Authorization
headers = get_safe_headers(request)
auth_header = headers.get("authorization", "")
# Сначала проверяем основной заголовок авторизации
auth_header = headers.get(SESSION_TOKEN_HEADER.lower(), "")
if auth_header:
if auth_header.startswith("Bearer "):
return auth_header[7:].strip()
token = auth_header[7:].strip()
logger.debug(f"[decorators] Токен получен из заголовка {SESSION_TOKEN_HEADER}: {len(token)}")
return token
else:
token = auth_header.strip()
logger.debug(f"[decorators] Прямой токен получен из заголовка {SESSION_TOKEN_HEADER}: {len(token)}")
return token
# Проверяем cookie
if hasattr(request, "cookies"):
return request.cookies.get(SESSION_COOKIE_NAME)
# Затем проверяем стандартный заголовок Authorization, если основной не определен
if SESSION_TOKEN_HEADER.lower() != "authorization":
auth_header = headers.get("authorization", "")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
logger.debug(f"[decorators] Токен получен из заголовка Authorization: {len(token)}")
return token
# 4. Проверяем cookie
if hasattr(request, "cookies") and request.cookies:
token = request.cookies.get(SESSION_COOKIE_NAME)
if token:
logger.debug(f"[decorators] Токен получен из cookie {SESSION_COOKIE_NAME}: {len(token)}")
return token
# Если токен не найден ни в одном из мест
logger.debug("[decorators] Токен авторизации не найден")
return None
except Exception as e:
logger.warning(f"Error extracting auth token: {e}")
logger.warning(f"[decorators] Ошибка при извлечении токена: {e}")
return None
def validate_graphql_context(info: Any) -> None:
async def validate_graphql_context(info: Any) -> None:
"""
Проверяет валидность GraphQL контекста.
Проверяет валидность GraphQL контекста и проверяет авторизацию.
Args:
info: GraphQL информация о контексте
Raises:
GraphQLError: если контекст невалиден
GraphQLError: если контекст невалиден или пользователь не авторизован
"""
# Проверка базовой структуры контекста
if info is None or not hasattr(info, "context"):
logger.error("Missing GraphQL context information")
logger.error("[decorators] 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")
logger.error("[decorators] Missing request in context")
raise GraphQLError("Internal server error: missing request")
# Проверяем auth из контекста
# Проверяем auth из контекста - если уже авторизован, просто возвращаем
auth = getattr(request, "auth", None)
if not auth or not auth.logged_in:
# Пробуем получить токен
if auth and auth.logged_in:
logger.debug(f"[decorators] Пользователь уже авторизован: {auth.author_id}")
return
# Если аутентификации нет в request.auth, пробуем получить ее из scope
if hasattr(request, "scope") and "auth" in request.scope:
auth_cred = request.scope.get("auth")
if isinstance(auth_cred, AuthCredentials) and auth_cred.logged_in:
logger.debug(f"[decorators] Пользователь авторизован через scope: {auth_cred.author_id}")
# В этом случае мы не делаем return, чтобы также проверить токен если нужно
# Если авторизации нет ни в auth, ни в scope, пробуем получить и проверить токен
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}")
logger.warning(f"[decorators] Токен авторизации не найден: {client_info}")
raise GraphQLError("Unauthorized - please login")
logger.warning(f"Found token but auth not initialized")
raise GraphQLError("Unauthorized - session expired")
# Используем единый механизм проверки токена из auth.internal
auth_state = await authenticate(request)
if not auth_state.logged_in:
error_msg = auth_state.error or "Invalid or expired token"
logger.warning(f"[decorators] Недействительный токен: {error_msg}")
raise GraphQLError(f"Unauthorized - {error_msg}")
# Если все проверки пройдены, оставляем AuthState в scope
# AuthenticationMiddleware извлечет нужные данные оттуда при необходимости
logger.debug(f"[decorators] Токен успешно проверен для пользователя {auth_state.author_id}")
return
def admin_auth_required(resolver: Callable) -> Callable:
@ -126,11 +216,18 @@ def admin_auth_required(resolver: Callable) -> Callable:
@wraps(resolver)
async def wrapper(root: Any = None, info: Any = None, **kwargs):
try:
validate_graphql_context(info)
await validate_graphql_context(info)
auth = info.context["request"].auth
with local_session() as session:
author = session.query(Author).filter(Author.id == auth.author_id).one()
try:
# Преобразуем author_id в int для совместимости с базой данных
author_id = int(auth.author_id) if auth and auth.author_id else None
if not author_id:
logger.error(f"[admin_auth_required] ID автора не определен: {auth}")
raise GraphQLError("Unauthorized - invalid user ID")
author = session.query(Author).filter(Author.id == author_id).one()
if author.email in ADMIN_EMAILS:
logger.info(f"Admin access granted for {author.email} (ID: {author.id})")
@ -138,6 +235,9 @@ def admin_auth_required(resolver: Callable) -> Callable:
logger.warning(f"Admin access denied for {author.email} (ID: {author.id})")
raise GraphQLError("Unauthorized - not an admin")
except exc.NoResultFound:
logger.error(f"[admin_auth_required] Пользователь с ID {auth.author_id} не найден в базе данных")
raise GraphQLError("Unauthorized - user not found")
except Exception as e:
error_msg = str(e)
@ -149,61 +249,57 @@ def admin_auth_required(resolver: Callable) -> Callable:
return wrapper
def require_permission(permission_string: str) -> Callable:
def permission_required(resource: str, operation: str, func):
"""
Декоратор для проверки наличия указанного разрешения.
Принимает строку в формате "resource:permission".
Декоратор для проверки разрешений.
Args:
permission_string: Строка в формате "resource:permission"
Returns:
Декоратор, проверяющий наличие указанного разрешения
Raises:
ValueError: если строка разрешения имеет неверный формат
Example:
>>> @require_permission("articles:edit")
... async def edit_article(root, info, article_id: int):
... return f"Editing article {article_id}"
resource (str): Ресурс для проверки
operation (str): Операция для проверки
func: Декорируемая функция
"""
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):
try:
validate_graphql_context(info)
auth = info.context["request"].auth
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
auth: AuthCredentials = info.context["request"].auth
if not auth.logged_in:
raise OperationNotAllowed(auth.error_message or "Please login")
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:
if isinstance(e, (OperationNotAllowed, GraphQLError)):
raise e
logger.error(f"Error in require_permission: {e}")
raise OperationNotAllowed(str(e))
return wrap
return wrapper
return decorator
def login_accepted(func):
@wraps(func)
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
auth: AuthCredentials = info.context["request"].auth
if auth and auth.logged_in:
with local_session() as session:
author = session.query(Author).filter(Author.id == auth.author_id).one()
info.context["author"] = author.dict()
info.context["user_id"] = author.id
else:
info.context["author"] = None
info.context["user_id"] = None
return await func(parent, info, *args, **kwargs)
return wrap

View File

@ -1,5 +1,6 @@
from typing import Optional, Tuple
import time
from typing import Any
from sqlalchemy.orm import exc
from starlette.authentication import AuthenticationBackend, BaseUser, UnauthenticatedUser
@ -9,18 +10,32 @@ from auth.credentials import AuthCredentials
from auth.orm import Author
from auth.sessions import SessionManager
from services.db import local_session
from settings import SESSION_TOKEN_HEADER
from settings import SESSION_TOKEN_HEADER, SESSION_COOKIE_NAME, ADMIN_EMAILS as ADMIN_EMAILS_LIST
from utils.logger import root_logger as logger
from auth.jwtcodec import JWTCodec
from auth.exceptions import ExpiredToken, InvalidToken
from auth.state import AuthState
from auth.tokenstorage import TokenStorage
from services.redis import redis
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
class AuthenticatedUser(BaseUser):
"""Аутентифицированный пользователь для Starlette"""
def __init__(self, user_id: str, username: str = "", roles: list = None, permissions: dict = None):
def __init__(self,
user_id: str,
username: str = "",
roles: list = None,
permissions: dict = None,
token: str = None
):
self.user_id = user_id
self.username = username
self.roles = roles or []
self.permissions = permissions or {}
self.token = token
@property
def is_authenticated(self) -> bool:
@ -40,19 +55,44 @@ class InternalAuthentication(AuthenticationBackend):
async def authenticate(self, request: HTTPConnection):
"""
Аутентифицирует пользователя по токену из заголовка.
Токен должен быть обработан заранее AuthorizationMiddleware,
который извлекает Bearer токен и преобразует его в чистый токен.
Аутентифицирует пользователя по токену из заголовка или cookie.
Порядок поиска токена:
1. Проверяем заголовок SESSION_TOKEN_HEADER (может быть установлен middleware)
2. Проверяем scope/auth в request, куда middleware мог сохранить токен
3. Проверяем cookie
Возвращает:
tuple: (AuthCredentials, BaseUser)
"""
if SESSION_TOKEN_HEADER not in request.headers:
return AuthCredentials(scopes={}), UnauthenticatedUser()
token = None
token = request.headers.get(SESSION_TOKEN_HEADER)
# 1. Проверяем заголовок
if SESSION_TOKEN_HEADER in request.headers:
token_header = request.headers.get(SESSION_TOKEN_HEADER)
if token_header:
if token_header.startswith("Bearer "):
token = token_header.replace("Bearer ", "", 1).strip()
logger.debug(f"[auth.authenticate] Извлечен Bearer токен из заголовка {SESSION_TOKEN_HEADER}")
else:
token = token_header.strip()
logger.debug(f"[auth.authenticate] Извлечен прямой токен из заголовка {SESSION_TOKEN_HEADER}")
# 2. Проверяем scope/auth, который мог быть установлен middleware
if not token and hasattr(request, "scope") and "auth" in request.scope:
auth_data = request.scope.get("auth", {})
if isinstance(auth_data, dict) and "token" in auth_data:
token = auth_data["token"]
logger.debug(f"[auth.authenticate] Извлечен токен из request.scope['auth']")
# 3. Проверяем cookie
if not token and hasattr(request, "cookies") and SESSION_COOKIE_NAME in request.cookies:
token = request.cookies.get(SESSION_COOKIE_NAME)
logger.debug(f"[auth.authenticate] Извлечен токен из cookie {SESSION_COOKIE_NAME}")
# Если токен не найден, возвращаем неаутентифицированного пользователя
if not token:
logger.debug("[auth.authenticate] Пустой токен в заголовке")
logger.debug("[auth.authenticate] Токен не найден")
return AuthCredentials(scopes={}, error_message="no token"), UnauthenticatedUser()
# Проверяем сессию в Redis
@ -86,9 +126,13 @@ class InternalAuthentication(AuthenticationBackend):
author.last_seen = int(time.time())
session.commit()
# Создаем объекты авторизации
# Создаем объекты авторизации с сохранением токена
credentials = AuthCredentials(
author_id=author.id, scopes=scopes, logged_in=True, email=author.email
author_id=author.id,
scopes=scopes,
logged_in=True,
email=author.email,
token=token
)
user = AuthenticatedUser(
@ -96,6 +140,7 @@ class InternalAuthentication(AuthenticationBackend):
username=author.slug or author.email or "",
roles=roles,
permissions=scopes,
token=token
)
logger.debug(f"[auth.authenticate] Успешная аутентификация: {author.email}")
@ -166,3 +211,76 @@ async def create_internal_session(author: Author, device_info: Optional[dict] =
username=author.slug or author.email or author.phone or "",
device_info=device_info,
)
async def authenticate(request: Any) -> AuthState:
"""
Аутентифицирует запрос по токену из разных источников.
Порядок проверки:
1. Проверяет токен в заголовке Authorization
2. Проверяет токен в cookie
Args:
request: Запрос (обычно из middleware)
Returns:
AuthState: Состояние авторизации
"""
state = AuthState()
state.logged_in = False # Изначально считаем, что пользователь не авторизован
token = None
# Проверяем наличие auth в scope (установлено middleware)
if hasattr(request, "scope") and isinstance(request.scope, dict) and "auth" in request.scope:
auth_info = request.scope.get("auth", {})
if isinstance(auth_info, dict) and "token" in auth_info:
token = auth_info["token"]
logger.debug("[auth.authenticate] Извлечен токен из request.scope['auth']")
# Если токен не найден в scope, проверяем заголовок
if not token:
try:
headers = {}
if hasattr(request, "headers"):
if callable(request.headers):
headers = dict(request.headers())
else:
headers = dict(request.headers)
auth_header = headers.get(SESSION_TOKEN_HEADER, "")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
logger.debug(f"[auth.authenticate] Токен получен из заголовка {SESSION_TOKEN_HEADER}")
elif auth_header:
token = auth_header.strip()
logger.debug(f"[auth.authenticate] Прямой токен получен из заголовка {SESSION_TOKEN_HEADER}")
except Exception as e:
logger.error(f"[auth.authenticate] Ошибка при доступе к заголовкам: {e}")
# Если и в заголовке не найден, проверяем cookie
if not token and hasattr(request, "cookies") and request.cookies:
token = request.cookies.get(SESSION_COOKIE_NAME)
if token:
logger.debug(f"[auth.authenticate] Токен получен из cookie {SESSION_COOKIE_NAME}")
# Если токен все еще не найден, возвращаем не авторизованное состояние
if not token:
logger.debug("[auth.authenticate] Токен не найден")
return state
# Проверяем токен через SessionManager, который теперь совместим с TokenStorage
payload = await SessionManager.verify_session(token)
if not payload:
logger.warning(f"[auth.authenticate] Токен не валиден: не найдена сессия")
state.error = "Invalid or expired token"
return state
# Создаем успешное состояние авторизации
state.logged_in = True
state.author_id = payload.user_id
state.token = token
state.username = payload.username
logger.info(f"[auth.authenticate] Успешная аутентификация пользователя {state.author_id}")
return state

View File

@ -1,37 +1,71 @@
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
import jwt
from pydantic import BaseModel
from typing import Optional
from utils.logger import root_logger as logger
from auth.exceptions import ExpiredToken, InvalidToken
from settings import JWT_ALGORITHM, JWT_SECRET_KEY
class TokenPayload(BaseModel):
user_id: str
username: str
exp: datetime
exp: Optional[datetime] = None
iat: datetime
iss: str
class JWTCodec:
@staticmethod
def encode(user, exp: datetime) -> str:
def encode(user, exp: Optional[datetime] = None) -> str:
# Поддержка как объектов, так и словарей
if isinstance(user, dict):
# В SessionManager.create_session передается словарь {"id": user_id, "email": username}
user_id = str(user.get("id", ""))
username = user.get("email", "") or user.get("username", "")
else:
# Для объектов с атрибутами
user_id = str(getattr(user, "id", ""))
username = getattr(user, "slug", "") or getattr(user, "email", "") or getattr(user, "phone", "") or ""
logger.debug(f"[JWTCodec.encode] Кодирование токена для user_id={user_id}, username={username}")
# Если время истечения не указано, установим срок годности на 30 дней
if exp is None:
exp = datetime.now(tz=timezone.utc) + timedelta(days=30)
logger.debug(f"[JWTCodec.encode] Время истечения не указано, устанавливаем срок: {exp}")
# Важно: убедимся, что exp всегда является либо datetime, либо целым числом от timestamp
if isinstance(exp, datetime):
# Преобразуем datetime в timestamp чтобы гарантировать правильный формат
exp_timestamp = int(exp.timestamp())
else:
# Если передано что-то другое, установим значение по умолчанию
logger.warning(f"[JWTCodec.encode] Некорректный формат exp: {exp}, используем значение по умолчанию")
exp_timestamp = int((datetime.now(tz=timezone.utc) + timedelta(days=30)).timestamp())
payload = {
"user_id": user.id,
"username": user.slug or user.email or user.phone or "",
"exp": exp,
"user_id": user_id,
"username": username,
"exp": exp_timestamp, # Используем timestamp вместо datetime
"iat": datetime.now(tz=timezone.utc),
"iss": "discours",
}
logger.debug(f"[JWTCodec.encode] Сформирован payload: {payload}")
try:
return jwt.encode(payload, JWT_SECRET_KEY, JWT_ALGORITHM)
token = jwt.encode(payload, JWT_SECRET_KEY, JWT_ALGORITHM)
logger.debug(f"[JWTCodec.encode] Токен успешно создан, длина: {len(token) if token else 0}")
return token
except Exception as e:
print("[auth.jwtcodec] JWT encode error %r" % e)
logger.error(f"[JWTCodec.encode] Ошибка при кодировании JWT: {e}")
raise
@staticmethod
def decode(token: str, verify_exp: bool = True):
logger.debug(f"[JWTCodec.decode] Начало декодирования токена длиной {len(token) if token else 0}")
r = None
payload = None
try:
@ -45,18 +79,33 @@ class JWTCodec:
algorithms=[JWT_ALGORITHM],
issuer="discours",
)
logger.debug(f"[JWTCodec.decode] Декодирован payload: {payload}")
# Убедимся, что exp существует (добавим обработку если exp отсутствует)
if "exp" not in payload:
logger.warning(f"[JWTCodec.decode] В токене отсутствует поле exp")
# Добавим exp по умолчанию, чтобы избежать ошибки при создании TokenPayload
payload["exp"] = int((datetime.now(tz=timezone.utc) + timedelta(days=30)).timestamp())
r = TokenPayload(**payload)
# print('[auth.jwtcodec] debug token %r' % r)
logger.debug(f"[JWTCodec.decode] Создан объект TokenPayload: user_id={r.user_id}, username={r.username}")
return r
except jwt.InvalidIssuedAtError:
print("[auth.jwtcodec] invalid issued at: %r" % payload)
logger.error(f"[JWTCodec.decode] Недействительное время выпуска токена: {payload}")
raise ExpiredToken("jwt check token issued time")
except jwt.ExpiredSignatureError:
print("[auth.jwtcodec] expired signature %r" % payload)
logger.error(f"[JWTCodec.decode] Истек срок действия токена: {payload}")
raise ExpiredToken("jwt check token lifetime")
except jwt.InvalidSignatureError:
logger.error("[JWTCodec.decode] Недействительная подпись токена")
raise InvalidToken("jwt check signature is not valid")
except jwt.InvalidTokenError:
logger.error("[JWTCodec.decode] Недействительный токен")
raise InvalidToken("jwt check token is not valid")
except jwt.InvalidKeyError:
logger.error("[JWTCodec.decode] Недействительный ключ")
raise InvalidToken("jwt check key is not valid")
except Exception as e:
logger.error(f"[JWTCodec.decode] Неожиданная ошибка при декодировании: {e}")
raise InvalidToken(f"Ошибка декодирования: {str(e)}")

View File

@ -30,15 +30,34 @@ class AuthMiddleware:
# Извлекаем заголовки
headers = Headers(scope=scope)
auth_header = headers.get(SESSION_TOKEN_HEADER)
token = None
token_source = None
# Сначала пробуем получить токен из заголовка Authorization
# Сначала пробуем получить токен из заголовка авторизации
auth_header = headers.get(SESSION_TOKEN_HEADER)
if auth_header:
if auth_header.startswith("Bearer "):
token = auth_header.replace("Bearer ", "", 1).strip()
token_source = "header"
logger.debug(
f"[middleware] Извлечен Bearer токен из заголовка, длина: {len(token) if token else 0}"
f"[middleware] Извлечен Bearer токен из заголовка {SESSION_TOKEN_HEADER}, длина: {len(token) if token else 0}"
)
else:
# Если заголовок не начинается с Bearer, предполагаем, что это чистый токен
token = auth_header.strip()
token_source = "header"
logger.debug(
f"[middleware] Извлечен прямой токен из заголовка {SESSION_TOKEN_HEADER}, длина: {len(token) if token else 0}"
)
# Если токен не получен из основного заголовка и это не Authorization, проверяем заголовок Authorization
if not token and SESSION_TOKEN_HEADER.lower() != "authorization":
auth_header = headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header.replace("Bearer ", "", 1).strip()
token_source = "auth_header"
logger.debug(
f"[middleware] Извлечен Bearer токен из заголовка Authorization, длина: {len(token) if token else 0}"
)
# Если токен не получен из заголовка, пробуем взять из cookie
@ -50,8 +69,9 @@ class AuthMiddleware:
name, value = item.split("=", 1)
if name.strip() == SESSION_COOKIE_NAME:
token = value.strip()
token_source = "cookie"
logger.debug(
f"[middleware] Извлечен токен из cookie, длина: {len(token) if token else 0}"
f"[middleware] Извлечен токен из cookie {SESSION_COOKIE_NAME}, длина: {len(token) if token else 0}"
)
break
@ -71,24 +91,84 @@ class AuthMiddleware:
scope["headers"] = new_headers
# Также добавляем информацию о типе аутентификации для дальнейшего использования
if "auth" not in scope:
scope["auth"] = {"type": "bearer", "token": token}
scope["auth"] = {
"type": "bearer",
"token": token,
"source": token_source
}
logger.debug(f"[middleware] Токен добавлен в scope для аутентификации из источника: {token_source}")
else:
logger.debug(f"[middleware] Токен не найден ни в заголовке, ни в cookie")
await self.app(scope, receive, send)
def set_context(self, context):
"""Сохраняет ссылку на контекст GraphQL запроса"""
self._context = context
logger.debug(f"[middleware] Установлен контекст GraphQL: {bool(context)}")
def set_cookie(self, key, value, **options):
"""Устанавливает cookie в ответе"""
"""
Устанавливает cookie в ответе
Args:
key: Имя cookie
value: Значение cookie
**options: Дополнительные параметры (httponly, secure, max_age, etc.)
"""
success = False
# Способ 1: Через response
if self._context and "response" in self._context and hasattr(self._context["response"], "set_cookie"):
try:
self._context["response"].set_cookie(key, value, **options)
logger.debug(f"[middleware] Установлена cookie {key} через response")
success = True
except Exception as e:
logger.error(f"[middleware] Ошибка при установке cookie {key} через response: {str(e)}")
# Способ 2: Через собственный response в контексте
if not success and hasattr(self, "_response") and self._response and hasattr(self._response, "set_cookie"):
try:
self._response.set_cookie(key, value, **options)
logger.debug(f"[middleware] Установлена cookie {key} через _response")
success = True
except Exception as e:
logger.error(f"[middleware] Ошибка при установке cookie {key} через _response: {str(e)}")
if not success:
logger.error(f"[middleware] Не удалось установить cookie {key}: объекты response недоступны")
def delete_cookie(self, key, **options):
"""Удаляет cookie из ответа"""
"""
Удаляет cookie из ответа
Args:
key: Имя cookie для удаления
**options: Дополнительные параметры
"""
success = False
# Способ 1: Через response
if self._context and "response" in self._context and hasattr(self._context["response"], "delete_cookie"):
try:
self._context["response"].delete_cookie(key, **options)
logger.debug(f"[middleware] Удалена cookie {key} через response")
success = True
except Exception as e:
logger.error(f"[middleware] Ошибка при удалении cookie {key} через response: {str(e)}")
# Способ 2: Через собственный response в контексте
if not success and hasattr(self, "_response") and self._response and hasattr(self._response, "delete_cookie"):
try:
self._response.delete_cookie(key, **options)
logger.debug(f"[middleware] Удалена cookie {key} через _response")
success = True
except Exception as e:
logger.error(f"[middleware] Ошибка при удалении cookie {key} через _response: {str(e)}")
if not success:
logger.error(f"[middleware] Не удалось удалить cookie {key}: объекты response недоступны")
async def resolve(self, next, root, info, *args, **kwargs):
"""
@ -105,6 +185,14 @@ class AuthMiddleware:
# Добавляем себя как объект, содержащий утилитные методы
context["extensions"] = self
# Проверяем наличие response в контексте
if "response" not in context or not context["response"]:
from starlette.responses import JSONResponse
context["response"] = JSONResponse({})
logger.debug("[middleware] Создан новый response объект в контексте GraphQL")
logger.debug(f"[middleware] GraphQL resolve: контекст подготовлен, добавлены расширения для работы с cookie")
return await next(root, info, *args, **kwargs)
except Exception as e:
logger.error(f"[AuthMiddleware] Ошибка в GraphQL resolve: {str(e)}")

View File

@ -1,5 +1,5 @@
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any
from typing import Optional, Dict, Any, List
from pydantic import BaseModel
from services.redis import redis
@ -26,88 +26,237 @@ class SessionManager:
@staticmethod
def _make_session_key(user_id: str, token: str) -> str:
"""Формирует ключ сессии в Redis"""
return f"session:{user_id}:{token}"
@staticmethod
def _make_user_sessions_key(user_id: str) -> str:
"""Формирует ключ для списка сессий пользователя в Redis"""
return f"user_sessions:{user_id}"
@classmethod
async def create_session(cls, user_id: str, username: str, device_info: dict = None) -> str:
"""
Создает новую сессию для пользователя.
Создаёт ключ для сессии в Redis.
Args:
user_id: ID пользователя
username: Имя пользователя/логин
token: JWT токен сессии
Returns:
str: Ключ сессии
"""
session_key = f"session:{user_id}:{token}"
logger.debug(f"[SessionManager._make_session_key] Сформирован ключ сессии: {session_key}")
return session_key
@staticmethod
def _make_user_sessions_key(user_id: str) -> str:
"""
Создаёт ключ для списка активных сессий пользователя.
Args:
user_id: ID пользователя
Returns:
str: Ключ списка сессий
"""
return f"user_sessions:{user_id}"
@classmethod
async def create_session(cls, user_id: str, username: str, device_info: Optional[dict] = None) -> str:
"""
Создаёт новую сессию.
Args:
user_id: ID пользователя
username: Имя пользователя
device_info: Информация об устройстве (опционально)
Returns:
str: Токен сессии
str: JWT токен сессии
"""
try:
# Создаем JWT токен
exp = datetime.now(tz=timezone.utc) + timedelta(seconds=SESSION_TOKEN_LIFE_SPAN)
session_token = JWTCodec.encode({"id": user_id, "email": username}, exp)
# Создаём токен с явным указанием срока действия (30 дней)
expiration_date = datetime.now(tz=timezone.utc) + timedelta(days=30)
token = JWTCodec.encode({"id": user_id, "email": username}, exp=expiration_date)
# Создаем данные сессии
session_data = SessionData(
user_id=user_id,
username=username,
created_at=datetime.now(tz=timezone.utc),
expires_at=exp,
device_info=device_info,
)
# Ключи в Redis
session_key = cls._make_session_key(user_id, session_token)
# Сохраняем сессию в Redis
session_key = cls._make_session_key(user_id, token)
user_sessions_key = cls._make_user_sessions_key(user_id)
# Сохраняем в Redis
pipe = redis.pipeline()
await pipe.hset(session_key, mapping=session_data.dict())
await pipe.expire(session_key, SESSION_TOKEN_LIFE_SPAN)
await pipe.sadd(user_sessions_key, session_token)
await pipe.expire(user_sessions_key, SESSION_TOKEN_LIFE_SPAN)
await pipe.execute()
# Сохраняем информацию о сессии
session_data = {
"user_id": user_id,
"username": username,
"created_at": datetime.now(tz=timezone.utc).isoformat(),
"expires_at": expiration_date.isoformat(),
}
return session_token
except Exception as e:
logger.error(f"[SessionManager.create_session] Ошибка: {str(e)}")
raise
# Добавляем информацию об устройстве, если она есть
if device_info:
for key, value in device_info.items():
session_data[f"device_{key}"] = value
# Сохраняем сессию в Redis
pipeline = redis.pipeline()
# Сохраняем данные сессии
pipeline.hset(session_key, mapping=session_data)
# Добавляем токен в список сессий пользователя
pipeline.sadd(user_sessions_key, token)
# Устанавливаем время жизни ключей (30 дней)
pipeline.expire(session_key, 30 * 24 * 60 * 60)
pipeline.expire(user_sessions_key, 30 * 24 * 60 * 60)
# Также создаем ключ в формате, совместимом с TokenStorage для обратной совместимости
token_key = f"{user_id}-{username}-{token}"
pipeline.hset(token_key, mapping={"user_id": user_id, "username": username})
pipeline.expire(token_key, 30 * 24 * 60 * 60)
result = await pipeline.execute()
logger.info(f"[SessionManager.create_session] Сессия успешно создана для пользователя {user_id}")
return token
@classmethod
async def verify_session(cls, token: str) -> Optional[TokenPayload]:
"""
Проверяет валидность сессии.
Проверяет сессию по токену.
Args:
token: Токен сессии
token: JWT токен
Returns:
TokenPayload: Данные токена или None, если токен недействителен
Optional[TokenPayload]: Данные токена или None, если сессия недействительна
"""
try:
# Декодируем JWT
# Декодируем токен для получения payload
payload = JWTCodec.decode(token)
if not payload:
return None
# Получаем данные из payload
user_id = payload.user_id
# Формируем ключ сессии
session_key = cls._make_session_key(payload.user_id, token)
session_key = cls._make_session_key(user_id, token)
logger.debug(f"[SessionManager.verify_session] Сформирован ключ сессии: {session_key}")
# Проверяем существование сессии в Redis
session_exists = await redis.exists(session_key)
if not session_exists:
logger.debug(f"[SessionManager.verify_session] Сессия не найдена: {payload.user_id}")
return None
exists = await redis.exists(session_key)
if not exists:
logger.warning(f"[SessionManager.verify_session] Сессия не найдена: {user_id}. Ключ: {session_key}")
# Проверяем также ключ в старом формате TokenStorage для обратной совместимости
token_key = f"{user_id}-{payload.username}-{token}"
old_format_exists = await redis.exists(token_key)
if old_format_exists:
logger.info(f"[SessionManager.verify_session] Найдена сессия в старом формате: {token_key}")
# Миграция: создаем запись в новом формате
session_data = {
"user_id": user_id,
"username": payload.username,
}
# Копируем сессию в новый формат
pipeline = redis.pipeline()
pipeline.hset(session_key, mapping=session_data)
pipeline.expire(session_key, 30 * 24 * 60 * 60)
pipeline.sadd(cls._make_user_sessions_key(user_id), token)
await pipeline.execute()
logger.info(f"[SessionManager.verify_session] Сессия мигрирована в новый формат: {session_key}")
return payload
except Exception as e:
logger.error(f"[SessionManager.verify_session] Ошибка: {str(e)}")
# Если сессия не найдена ни в новом, ни в старом формате, проверяем все ключи в Redis
keys = await redis.keys("session:*")
logger.debug(f"[SessionManager.verify_session] Все ключи сессий в Redis: {keys}")
# Если сессии нет, возвращаем None
return None
# Если сессия найдена, возвращаем payload
return payload
@classmethod
async def get_user_sessions(cls, user_id: str) -> List[Dict[str, Any]]:
"""
Получает список активных сессий пользователя.
Args:
user_id: ID пользователя
Returns:
List[Dict[str, Any]]: Список сессий
"""
user_sessions_key = cls._make_user_sessions_key(user_id)
tokens = await redis.smembers(user_sessions_key)
sessions = []
for token in tokens:
session_key = cls._make_session_key(user_id, token)
session_data = await redis.hgetall(session_key)
if session_data:
session = dict(session_data)
session["token"] = token
sessions.append(session)
return sessions
@classmethod
async def delete_session(cls, user_id: str, token: str) -> bool:
"""
Удаляет сессию.
Args:
user_id: ID пользователя
token: JWT токен
Returns:
bool: True, если сессия успешно удалена
"""
session_key = cls._make_session_key(user_id, token)
user_sessions_key = cls._make_user_sessions_key(user_id)
# Удаляем данные сессии и токен из списка сессий пользователя
pipeline = redis.pipeline()
pipeline.delete(session_key)
pipeline.srem(user_sessions_key, token)
# Также удаляем ключ в формате TokenStorage для полной очистки
token_payload = JWTCodec.decode(token)
if token_payload:
token_key = f"{user_id}-{token_payload.username}-{token}"
pipeline.delete(token_key)
results = await pipeline.execute()
return bool(results[0]) or bool(results[1])
@classmethod
async def delete_all_sessions(cls, user_id: str) -> int:
"""
Удаляет все сессии пользователя.
Args:
user_id: ID пользователя
Returns:
int: Количество удаленных сессий
"""
user_sessions_key = cls._make_user_sessions_key(user_id)
tokens = await redis.smembers(user_sessions_key)
count = 0
for token in tokens:
session_key = cls._make_session_key(user_id, token)
# Удаляем данные сессии
deleted = await redis.delete(session_key)
count += deleted
# Также удаляем ключ в формате TokenStorage
token_payload = JWTCodec.decode(token)
if token_payload:
token_key = f"{user_id}-{token_payload.username}-{token}"
await redis.delete(token_key)
# Очищаем список токенов
await redis.delete(user_sessions_key)
return count
@classmethod
async def get_session_data(cls, user_id: str, token: str) -> Optional[Dict[str, Any]]:
"""
@ -122,7 +271,7 @@ class SessionManager:
"""
try:
session_key = cls._make_session_key(user_id, token)
session_data = await redis.hgetall(session_key)
session_data = await redis.execute("HGETALL", session_key)
return session_data if session_data else None
except Exception as e:
logger.error(f"[SessionManager.get_session_data] Ошибка: {str(e)}")

22
auth/state.py Normal file
View File

@ -0,0 +1,22 @@
"""
Классы состояния авторизации
"""
class AuthState:
"""
Класс для хранения информации о состоянии авторизации пользователя.
Используется в аутентификационных middleware и функциях.
"""
def __init__(self):
self.logged_in = False
self.author_id = None
self.token = None
self.username = None
self.is_admin = False
self.is_editor = False
self.error = None
def __bool__(self):
"""Возвращает True если пользователь авторизован"""
return self.logged_in

View File

@ -1,6 +1,7 @@
from datetime import datetime, timedelta, timezone
import json
from typing import Dict, Any, Optional
import time
from typing import Dict, Any, Optional, Tuple, List
from auth.jwtcodec import JWTCodec
from auth.validations import AuthInput
@ -11,10 +12,289 @@ from utils.logger import root_logger as logger
class TokenStorage:
"""
Хранилище токенов в Redis.
Обеспечивает создание, проверку и отзыв токенов.
Класс для работы с хранилищем токенов в Redis
"""
@staticmethod
def _make_token_key(user_id: str, username: str, token: str) -> str:
"""
Создает ключ для хранения токена
Args:
user_id: ID пользователя
username: Имя пользователя
token: Токен
Returns:
str: Ключ токена
"""
# Сохраняем в старом формате для обратной совместимости
return f"{user_id}-{username}-{token}"
@staticmethod
def _make_session_key(user_id: str, token: str) -> str:
"""
Создает ключ в новом формате SessionManager
Args:
user_id: ID пользователя
token: Токен
Returns:
str: Ключ сессии
"""
return f"session:{user_id}:{token}"
@staticmethod
def _make_user_sessions_key(user_id: str) -> str:
"""
Создает ключ для списка сессий пользователя
Args:
user_id: ID пользователя
Returns:
str: Ключ списка сессий
"""
return f"user_sessions:{user_id}"
@classmethod
async def create_session(cls, user_id: str, username: str, device_info: Optional[Dict[str, str]] = None) -> str:
"""
Создает новую сессию для пользователя
Args:
user_id: ID пользователя
username: Имя пользователя
device_info: Информация об устройстве (опционально)
Returns:
str: Токен сессии
"""
logger.debug(f"[TokenStorage.create_session] Начало создания сессии для пользователя {user_id}")
# Генерируем JWT токен с явным указанием времени истечения
expiration_date = datetime.now(tz=timezone.utc) + timedelta(days=30)
token = JWTCodec.encode({"id": user_id, "email": username}, exp=expiration_date)
logger.debug(f"[TokenStorage.create_session] Создан JWT токен длиной {len(token)}")
# Формируем ключи для Redis
token_key = cls._make_token_key(user_id, username, token)
logger.debug(f"[TokenStorage.create_session] Сформированы ключи: token_key={token_key}")
# Формируем ключи в новом формате SessionManager для совместимости
session_key = cls._make_session_key(user_id, token)
user_sessions_key = cls._make_user_sessions_key(user_id)
# Готовим данные для сохранения
token_data = {
"user_id": user_id,
"username": username,
"created_at": time.time(),
"expires_at": time.time() + 30 * 24 * 60 * 60 # 30 дней
}
if device_info:
token_data.update(device_info)
logger.debug(f"[TokenStorage.create_session] Сформированы данные сессии: {token_data}")
# Сохраняем в Redis старый формат
pipeline = redis.pipeline()
pipeline.hset(token_key, mapping=token_data)
pipeline.expire(token_key, 30 * 24 * 60 * 60) # 30 дней
# Также сохраняем в новом формате SessionManager для обеспечения совместимости
pipeline.hset(session_key, mapping=token_data)
pipeline.expire(session_key, 30 * 24 * 60 * 60) # 30 дней
pipeline.sadd(user_sessions_key, token)
pipeline.expire(user_sessions_key, 30 * 24 * 60 * 60) # 30 дней
results = await pipeline.execute()
logger.info(f"[TokenStorage.create_session] Сессия успешно создана для пользователя {user_id}")
return token
@classmethod
async def exists(cls, token_key: str) -> bool:
"""
Проверяет существование токена по ключу
Args:
token_key: Ключ токена
Returns:
bool: True, если токен существует
"""
exists = await redis.exists(token_key)
return bool(exists)
@classmethod
async def validate_token(cls, token: str) -> Tuple[bool, Optional[Dict[str, Any]]]:
"""
Проверяет валидность токена
Args:
token: JWT токен
Returns:
Tuple[bool, Dict[str, Any]]: (Валиден ли токен, данные токена)
"""
try:
# Декодируем JWT токен
payload = JWTCodec.decode(token)
if not payload:
logger.warning(f"[TokenStorage.validate_token] Токен не валиден (не удалось декодировать)")
return False, None
user_id = payload.user_id
username = payload.username
# Формируем ключи для Redis в обоих форматах
token_key = cls._make_token_key(user_id, username, token)
session_key = cls._make_session_key(user_id, token)
# Проверяем в обоих форматах для совместимости
old_exists = await redis.exists(token_key)
new_exists = await redis.exists(session_key)
if old_exists or new_exists:
logger.info(f"[TokenStorage.validate_token] Токен валиден для пользователя {user_id}")
# Получаем данные токена из актуального хранилища
if new_exists:
token_data = await redis.hgetall(session_key)
else:
token_data = await redis.hgetall(token_key)
# Если найден только в старом формате, создаем запись в новом формате
if not new_exists:
logger.info(f"[TokenStorage.validate_token] Миграция токена в новый формат: {session_key}")
await redis.hset(session_key, mapping=token_data)
await redis.expire(session_key, 30 * 24 * 60 * 60)
await redis.sadd(cls._make_user_sessions_key(user_id), token)
return True, token_data
else:
logger.warning(f"[TokenStorage.validate_token] Токен не найден в Redis: {token_key}")
return False, None
except Exception as e:
logger.error(f"[TokenStorage.validate_token] Ошибка при проверке токена: {e}")
return False, None
@classmethod
async def invalidate_token(cls, token: str) -> bool:
"""
Инвалидирует токен
Args:
token: JWT токен
Returns:
bool: True, если токен успешно инвалидирован
"""
try:
# Декодируем JWT токен
payload = JWTCodec.decode(token)
if not payload:
logger.warning(f"[TokenStorage.invalidate_token] Токен не валиден (не удалось декодировать)")
return False
user_id = payload.user_id
username = payload.username
# Формируем ключи для Redis в обоих форматах
token_key = cls._make_token_key(user_id, username, token)
session_key = cls._make_session_key(user_id, token)
user_sessions_key = cls._make_user_sessions_key(user_id)
# Удаляем токен из Redis в обоих форматах
pipeline = redis.pipeline()
pipeline.delete(token_key)
pipeline.delete(session_key)
pipeline.srem(user_sessions_key, token)
results = await pipeline.execute()
success = any(results)
if success:
logger.info(f"[TokenStorage.invalidate_token] Токен успешно инвалидирован для пользователя {user_id}")
else:
logger.warning(f"[TokenStorage.invalidate_token] Токен не найден: {token_key}")
return success
except Exception as e:
logger.error(f"[TokenStorage.invalidate_token] Ошибка при инвалидации токена: {e}")
return False
@classmethod
async def invalidate_all_tokens(cls, user_id: str) -> int:
"""
Инвалидирует все токены пользователя
Args:
user_id: ID пользователя
Returns:
int: Количество инвалидированных токенов
"""
try:
# Получаем список сессий пользователя
user_sessions_key = cls._make_user_sessions_key(user_id)
tokens = await redis.smembers(user_sessions_key)
if not tokens:
logger.warning(f"[TokenStorage.invalidate_all_tokens] Нет активных сессий пользователя {user_id}")
return 0
count = 0
for token in tokens:
# Декодируем JWT токен
try:
payload = JWTCodec.decode(token)
if payload:
username = payload.username
# Формируем ключи для Redis
token_key = cls._make_token_key(user_id, username, token)
session_key = cls._make_session_key(user_id, token)
# Удаляем токен из Redis
pipeline = redis.pipeline()
pipeline.delete(token_key)
pipeline.delete(session_key)
results = await pipeline.execute()
count += 1
except Exception as e:
logger.error(f"[TokenStorage.invalidate_all_tokens] Ошибка при обработке токена: {e}")
continue
# Удаляем список сессий пользователя
await redis.delete(user_sessions_key)
logger.info(f"[TokenStorage.invalidate_all_tokens] Инвалидировано {count} токенов пользователя {user_id}")
return count
except Exception as e:
logger.error(f"[TokenStorage.invalidate_all_tokens] Ошибка при инвалидации всех токенов: {e}")
return 0
@classmethod
async def get_session_data(cls, token: str) -> Optional[Dict[str, Any]]:
"""
Получает данные сессии
Args:
token: JWT токен
Returns:
Dict[str, Any]: Данные сессии или None
"""
valid, data = await cls.validate_token(token)
return data if valid else None
@staticmethod
async def get(token_key: str) -> Optional[str]:
"""
@ -88,43 +368,6 @@ class TokenStorage:
return one_time_token
@staticmethod
async def create_session(user: AuthInput) -> str:
"""
Создает сессионный токен для пользователя.
Args:
user: Объект пользователя
Returns:
str: Сгенерированный токен
"""
life_span = SESSION_TOKEN_LIFE_SPAN
exp = datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)
session_token = JWTCodec.encode(user, exp)
# Сохраняем токен в Redis
token_key = f"{user.id}-{user.username}-{session_token}"
user_sessions_key = f"user_sessions:{user.id}"
# Создаем данные сессии
session_data = {
"user_id": str(user.id),
"username": user.username,
"created_at": datetime.now(tz=timezone.utc).timestamp(),
"expires_at": exp.timestamp(),
}
# Сохраняем токен и добавляем его в список сессий пользователя
pipe = redis.pipeline()
await pipe.hmset(token_key, session_data)
await pipe.expire(token_key, life_span)
await pipe.sadd(user_sessions_key, session_token)
await pipe.expire(user_sessions_key, life_span)
await pipe.execute()
return session_token
@staticmethod
async def revoke(token: str) -> bool:
"""

View File

@ -86,7 +86,7 @@
- `sendLink` - отправка ссылки для входа
### Запросы
- `signOut` - выход из системы
- `logout` - выход из системы
- `isEmailUsed` - проверка использования email
## Безопасность

99
main.py
View File

@ -26,6 +26,14 @@ from services.search import search_service
from utils.logger import root_logger as logger
from auth.internal import InternalAuthentication
from auth.middleware import AuthMiddleware
from settings import (
SESSION_COOKIE_NAME,
SESSION_COOKIE_HTTPONLY,
SESSION_COOKIE_SECURE,
SESSION_COOKIE_SAMESITE,
SESSION_COOKIE_MAX_AGE,
SESSION_TOKEN_HEADER,
)
# Импортируем резолверы
import_module("resolvers")
@ -61,19 +69,49 @@ class EnhancedGraphQLHTTPHandler(GraphQLHTTPHandler):
# Получаем стандартный контекст от базового класса
context = await super().get_context_for_request(request, data)
# Добавляем объект ответа для установки cookie
# Создаем объект ответа для установки cookie
response = JSONResponse({})
context["response"] = response
# Интегрируем с AuthMiddleware
auth_middleware.set_context(context)
context["extensions"] = auth_middleware
logger.debug(f"[graphql] Подготовлен расширенный контекст для запроса")
return context
async def process_result(self, request: Request, result: dict) -> Response:
"""
Обрабатывает результат GraphQL запроса, поддерживая установку cookie
"""
# Получаем контекст запроса
context = getattr(request, "context", {})
# Получаем заранее созданный response из контекста
response = context.get("response")
if not response or not isinstance(response, Response):
# Если response не найден или не является объектом Response, создаем новый
response = await super().process_result(request, result)
else:
# Обновляем тело ответа данными из результата GraphQL
response.body = self.encode_json(result)
response.headers["content-type"] = "application/json"
response.headers["content-length"] = str(len(response.body))
logger.debug(f"[graphql] Подготовлен ответ с типом {type(response).__name__}")
return response
# Функция запуска сервера
async def start():
"""Запуск сервера и инициализация данных"""
# Инициализируем соединение с Redis
await redis.connect()
logger.info("Установлено соединение с Redis")
# Создаем все таблицы в БД
create_all_tables()
@ -86,7 +124,7 @@ async def start():
# Выводим сообщение о запуске сервера и доступности API
logger.info("Сервер запущен и готов принимать запросы")
logger.info("GraphQL API доступно по адресу: /graphql")
logger.info("Админ-панель доступна по адресу: /admin")
logger.info("Админ-панель доступна по адресу: http://127.0.0.1:8000/")
# Функция остановки сервера
@ -125,8 +163,12 @@ middleware = [
]
# Создаем экземпляр GraphQL
graphql_app = GraphQL(schema, debug=True)
# Создаем экземпляр GraphQL с улучшенным обработчиком
graphql_app = GraphQL(
schema,
debug=True,
http_handler=EnhancedGraphQLHTTPHandler()
)
# Оборачиваем GraphQL-обработчик для лучшей обработки ошибок
@ -135,14 +177,57 @@ async def graphql_handler(request: Request):
return JSONResponse({"error": "Method Not Allowed by main.py"}, status_code=405)
try:
# Обрабатываем CORS для OPTIONS запросов
if request.method == "OPTIONS":
response = JSONResponse({})
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Methods"] = "POST, GET, OPTIONS"
response.headers["Access-Control-Allow-Headers"] = "*"
response.headers["Access-Control-Max-Age"] = "86400" # 24 hours
return response
result = await graphql_app.handle_request(request)
if isinstance(result, Response):
# Если результат не является Response, преобразуем его в JSONResponse
if not isinstance(result, Response):
response = JSONResponse(result)
# Проверяем, был ли токен в запросе или ответе
if request.method == "POST" and isinstance(result, dict):
data = await request.json()
op_name = data.get("operationName", "").lower()
# Если это операция логина или обновления токена, и в ответе есть токен
if (op_name in ["login", "refreshtoken"]) and result.get("data", {}).get(op_name, {}).get("token"):
token = result["data"][op_name]["token"]
# Устанавливаем cookie с токеном
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=token,
httponly=SESSION_COOKIE_HTTPONLY,
secure=SESSION_COOKIE_SECURE,
samesite=SESSION_COOKIE_SAMESITE,
max_age=SESSION_COOKIE_MAX_AGE,
)
logger.debug(f"[graphql_handler] Установлена cookie {SESSION_COOKIE_NAME} для операции {op_name}")
# Если это операция logout, удаляем cookie
elif op_name == "logout":
response.delete_cookie(
key=SESSION_COOKIE_NAME,
secure=SESSION_COOKIE_SECURE,
httponly=SESSION_COOKIE_HTTPONLY,
samesite=SESSION_COOKIE_SAMESITE
)
logger.debug(f"[graphql_handler] Удалена cookie {SESSION_COOKIE_NAME} для операции {op_name}")
return 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)}")
logger.error(f"GraphQL error: {str(e)}")
return JSONResponse({"error": str(e)}, status_code=500)
# Добавляем маршруты, порядок имеет значение

View File

@ -63,7 +63,7 @@ async def admin_get_users(_, info, limit=10, offset=0, search=None):
"email": user.email,
"name": user.name,
"slug": user.slug,
"roles": [role.role for role in user.roles]
"roles": [role.id for role in user.roles]
if hasattr(user, "roles") and user.roles
else [],
"created_at": user.created_at,

View File

@ -6,7 +6,7 @@ from utils.logger import root_logger as logger
from graphql.type import GraphQLResolveInfo
# import asyncio # Убираем, так как резолвер будет синхронным
from auth.authenticate import login_required
from services.auth import login_required
from auth.credentials import AuthCredentials
from auth.email import send_auth_email
from auth.exceptions import InvalidToken, ObjectNotExist
@ -31,6 +31,7 @@ from auth.internal import verify_internal_auth
@mutation.field("getSession")
@login_required
async def get_current_user(_, info):
"""get current user"""
auth: AuthCredentials = info.context["request"].auth
token = info.context["request"].headers.get(SESSION_TOKEN_HEADER)
@ -49,16 +50,27 @@ async def confirm_email(_, info, token):
logger.info("[auth] confirmEmail: Начало подтверждения email по токену.")
payload = JWTCodec.decode(token)
user_id = payload.user_id
username = payload.username
# Если TokenStorage.get асинхронный, это нужно будет переделать или вызывать синхронно
# Для теста пока оставим, но это потенциальная точка отказа в синхронном резолвере
await TokenStorage.get(f"{user_id}-{payload.username}-{token}")
token_key = f"{user_id}-{username}-{token}"
await TokenStorage.get(token_key)
with local_session() as session:
user = session.query(Author).where(Author.id == user_id).first()
if not user:
logger.warning(f"[auth] confirmEmail: Пользователь с ID {user_id} не найден.")
return {"success": False, "error": "Пользователь не найден"}
# Если TokenStorage.create_session асинхронный...
session_token = await TokenStorage.create_session(user)
# Создаем сессионный токен с новым форматом вызова и явным временем истечения
device_info = {"email": user.email} if hasattr(user, "email") else None
session_token = await TokenStorage.create_session(
user_id=str(user_id),
username=user.username or user.email or user.slug or username,
device_info=device_info
)
user.email_verified = True
user.last_seen = int(time.time())
session.add(user)
@ -79,6 +91,7 @@ async def confirm_email(_, info, token):
def create_user(user_dict):
"""create new user account"""
user = Author(**user_dict)
with local_session() as session:
# Добавляем пользователя в БД
@ -118,8 +131,8 @@ def create_user(user_dict):
@mutation.field("registerUser")
async def register_by_email(_, _info, email: str, password: str = "", name: str = ""):
"""register new user account by email"""
email = email.lower()
"""creates new user account"""
logger.info(f"[auth] registerUser: Попытка регистрации для {email}")
with local_session() as session:
user = session.query(Author).filter(Author.email == email).first()
@ -171,8 +184,8 @@ async def register_by_email(_, _info, email: str, password: str = "", name: str
@mutation.field("sendLink")
async def send_link(_, _info, email, lang="ru", template="email_confirmation"):
email = email.lower()
"""send link with confirm code to email"""
email = email.lower()
with local_session() as session:
user = session.query(Author).filter(Author.email == email).first()
if not user:
@ -264,20 +277,23 @@ async def login(_, info, email: str, password: str):
# Создаем сессионный токен
logger.info(f"[auth] login: СОЗДАНИЕ ТОКЕНА для {email}, id={valid_author.id}")
token = await TokenStorage.create_session(valid_author)
token = await TokenStorage.create_session(
user_id=str(valid_author.id),
username=valid_author.username or valid_author.email or valid_author.slug or "",
device_info={"email": valid_author.email} if hasattr(valid_author, "email") else None
)
logger.info(f"[auth] login: токен успешно создан, длина: {len(token) if token else 0}")
# Обновляем время последнего входа
valid_author.last_seen = int(time.time())
session.commit()
# Устанавливаем httponly cookie с помощью GraphQLExtensionsMiddleware
# Устанавливаем httponly cookie различными способами для надежности
cookie_set = False
# Метод 1: GraphQL контекст через extensions
try:
# Используем extensions для установки cookie
if hasattr(info.context, "extensions") and hasattr(
info.context.extensions, "set_cookie"
):
logger.info("[auth] login: Устанавливаем httponly cookie через extensions")
if hasattr(info.context, "extensions") and hasattr(info.context.extensions, "set_cookie"):
info.context.extensions.set_cookie(
SESSION_COOKIE_NAME,
token,
@ -286,8 +302,15 @@ async def login(_, info, email: str, password: str):
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] login: Устанавливаем httponly cookie через response")
logger.info(f"[auth] login: Установлена cookie через extensions")
cookie_set = True
except Exception as e:
logger.error(f"[auth] login: Ошибка при установке cookie через extensions: {str(e)}")
# Метод 2: GraphQL контекст через response
if not cookie_set:
try:
if hasattr(info.context, "response") and hasattr(info.context.response, "set_cookie"):
info.context.response.set_cookie(
key=SESSION_COOKIE_NAME,
value=token,
@ -296,14 +319,32 @@ async def login(_, info, email: str, password: str):
samesite=SESSION_COOKIE_SAMESITE,
max_age=SESSION_COOKIE_MAX_AGE,
)
else:
logger.warning(
"[auth] login: Невозможно установить cookie - объекты extensions/response недоступны"
)
logger.info(f"[auth] login: Установлена cookie через response")
cookie_set = True
except Exception as e:
# В случае ошибки при установке cookie просто логируем, но продолжаем авторизацию
logger.error(f"[auth] login: Ошибка при установке cookie: {str(e)}")
logger.debug(traceback.format_exc())
logger.error(f"[auth] login: Ошибка при установке cookie через response: {str(e)}")
# Если ни один способ не сработал, создаем response в контексте
if not cookie_set and hasattr(info.context, "request") and not hasattr(info.context, "response"):
try:
from starlette.responses import JSONResponse
response = JSONResponse({})
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=token,
httponly=SESSION_COOKIE_HTTPONLY,
secure=SESSION_COOKIE_SECURE,
samesite=SESSION_COOKIE_SAMESITE,
max_age=SESSION_COOKIE_MAX_AGE,
)
info.context["response"] = response
logger.info(f"[auth] login: Создан новый response и установлена cookie")
cookie_set = True
except Exception as e:
logger.error(f"[auth] login: Ошибка при создании response и установке cookie: {str(e)}")
if not cookie_set:
logger.warning(f"[auth] login: Не удалось установить cookie никаким способом")
# Возвращаем успешный результат
logger.info(f"[auth] login: Успешный вход для {email}")
@ -327,21 +368,10 @@ async def login(_, info, email: str, password: str):
logger.error(traceback.format_exc())
return {"success": False, "token": None, "author": None, "error": str(e)}
# Если по какой-то причине мы дошли до этой точки, вернем безопасный результат
return default_response
@query.field("signOut")
@login_required
async def sign_out(_, info: GraphQLResolveInfo):
token = info.context["request"].headers.get(SESSION_TOKEN_HEADER, "")
# Если TokenStorage.revoke асинхронный...
status = await TokenStorage.revoke(token)
return status
@query.field("isEmailUsed")
async def is_email_used(_, _info, email):
"""check if email is used"""
email = email.lower()
with local_session() as session:
user = session.query(Author).filter(Author.email == email).first()

View File

@ -7,7 +7,7 @@ type Query {
# search_authors(what: String!): [Author]
# Auth queries
signOut: AuthSuccess!
logout: AuthResult!
me: AuthResult!
isEmailUsed(email: String!): Boolean!
isAdmin: Boolean!

View File

@ -16,7 +16,7 @@ class RedisService:
self._client = None
async def connect(self):
if self._uri:
if self._uri and self._client is None:
self._client = await Redis.from_url(self._uri, decode_responses=True)
logger.info("Redis connection was established.")
@ -26,6 +26,11 @@ class RedisService:
logger.info("Redis connection was closed.")
async def execute(self, command, *args, **kwargs):
# Автоматически подключаемся к Redis, если соединение не установлено
if self._client is None:
await self.connect()
logger.info(f"[redis] Автоматически установлено соединение при выполнении команды {command}")
if self._client:
try:
logger.debug(f"{command}") # {args[0]}") # {args} {kwargs}")
@ -47,31 +52,43 @@ class RedisService:
Returns:
Pipeline: объект pipeline Redis
"""
if self._client:
if self._client is None:
# Выбрасываем исключение, так как pipeline нельзя создать до подключения
raise Exception("Redis client is not initialized. Call redis.connect() first.")
return self._client.pipeline()
raise Exception("Redis client is not initialized")
async def subscribe(self, *channels):
if self._client:
# Автоматически подключаемся к Redis, если соединение не установлено
if self._client is None:
await self.connect()
async with self._client.pubsub() as pubsub:
for channel in channels:
await pubsub.subscribe(channel)
self.pubsub_channels.append(channel)
async def unsubscribe(self, *channels):
if not self._client:
if self._client is None:
return
async with self._client.pubsub() as pubsub:
for channel in channels:
await pubsub.unsubscribe(channel)
self.pubsub_channels.remove(channel)
async def publish(self, channel, data):
if not self._client:
return
# Автоматически подключаемся к Redis, если соединение не установлено
if self._client is None:
await self.connect()
await self._client.publish(channel, data)
async def set(self, key, data, ex=None):
# Автоматически подключаемся к Redis, если соединение не установлено
if self._client is None:
await self.connect()
# Prepare the command arguments
args = [key, data]
@ -84,6 +101,10 @@ class RedisService:
await self.execute("set", *args)
async def get(self, key):
# Автоматически подключаемся к Redis, если соединение не установлено
if self._client is None:
await self.connect()
return await self.execute("get", key)
async def delete(self, *keys):
@ -96,8 +117,13 @@ class RedisService:
Returns:
int: Количество удаленных ключей
"""
if not self._client or not keys:
if not keys:
return 0
# Автоматически подключаемся к Redis, если соединение не установлено
if self._client is None:
await self.connect()
return await self._client.delete(*keys)
async def hmset(self, key, mapping):
@ -108,8 +134,10 @@ class RedisService:
key: Ключ хеша
mapping: Словарь с полями и значениями
"""
if not self._client:
return
# Автоматически подключаемся к Redis, если соединение не установлено
if self._client is None:
await self.connect()
await self._client.hset(key, mapping=mapping)
async def expire(self, key, seconds):
@ -120,8 +148,10 @@ class RedisService:
key: Ключ
seconds: Время жизни в секундах
"""
if not self._client:
return
# Автоматически подключаемся к Redis, если соединение не установлено
if self._client is None:
await self.connect()
await self._client.expire(key, seconds)
async def sadd(self, key, *values):
@ -132,8 +162,10 @@ class RedisService:
key: Ключ множества
*values: Значения для добавления
"""
if not self._client:
return
# Автоматически подключаемся к Redis, если соединение не установлено
if self._client is None:
await self.connect()
await self._client.sadd(key, *values)
async def srem(self, key, *values):
@ -144,8 +176,10 @@ class RedisService:
key: Ключ множества
*values: Значения для удаления
"""
if not self._client:
return
# Автоматически подключаемся к Redis, если соединение не установлено
if self._client is None:
await self.connect()
await self._client.srem(key, *values)
async def smembers(self, key):
@ -158,10 +192,57 @@ class RedisService:
Returns:
set: Множество элементов
"""
if not self._client:
return set()
# Автоматически подключаемся к Redis, если соединение не установлено
if self._client is None:
await self.connect()
return await self._client.smembers(key)
async def exists(self, key):
"""
Проверяет, существует ли ключ в Redis.
Args:
key: Ключ для проверки
Returns:
bool: True, если ключ существует, False в противном случае
"""
# Автоматически подключаемся к Redis, если соединение не установлено
if self._client is None:
await self.connect()
return await self._client.exists(key)
async def expire(self, key, seconds):
"""
Устанавливает время жизни ключа.
Args:
key: Ключ
seconds: Время жизни в секундах
"""
# Автоматически подключаемся к Redis, если соединение не установлено
if self._client is None:
await self.connect()
return await self._client.expire(key, seconds)
async def keys(self, pattern):
"""
Возвращает все ключи, соответствующие шаблону.
Args:
pattern: Шаблон для поиска ключей
"""
# Автоматически подключаемся к Redis, если соединение не установлено
if self._client is None:
await self.connect()
return await self._client.keys(pattern)
redis = RedisService()