diff --git a/CHANGELOG.md b/CHANGELOG.md index fbf42986..0b37d141 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 для лучшей модульности diff --git a/auth/__init__.py b/auth/__init__.py index 36949a4d..ab33703d 100644 --- a/auth/__init__.py +++ b/auth/__init__.py @@ -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 = request.cookies.get(SESSION_COOKIE_NAME) + 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 = request.cookies.get(SESSION_COOKIE_NAME) + 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, diff --git a/auth/authenticate.py b/auth/authenticate.py deleted file mode 100644 index 8a9599af..00000000 --- a/auth/authenticate.py +++ /dev/null @@ -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 = 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 diff --git a/auth/credentials.py b/auth/credentials.py index c02bc481..2e8e5a7b 100644 --- a/auth/credentials.py +++ b/auth/credentials.py @@ -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]: """ diff --git a/auth/decorators.py b/auth/decorators.py index befeced0..97e75598 100644 --- a/auth/decorators.py +++ b/auth/decorators.py @@ -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): - headers.update({ - k.decode("utf-8").lower(): v.decode("utf-8") - for k, v in request.scope.get("headers", []) - }) + scope_headers = request.scope.get("headers", []) + if scope_headers: + headers.update({ + k.decode("utf-8").lower(): v.decode("utf-8") + 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", "") - if auth_header.startswith("Bearer "): - return auth_header[7:].strip() + + # Сначала проверяем основной заголовок авторизации + auth_header = headers.get(SESSION_TOKEN_HEADER.lower(), "") + if auth_header: + if auth_header.startswith("Bearer "): + 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 + + # Затем проверяем стандартный заголовок 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 - # Проверяем cookie - if hasattr(request, "cookies"): - return request.cookies.get(SESSION_COOKIE_NAME) + # 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: - # Пробуем получить токен - 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") + 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"[decorators] Токен авторизации не найден: {client_info}") + raise GraphQLError("Unauthorized - please login") + + # Используем единый механизм проверки токена из 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,18 +216,28 @@ 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() - - 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") + 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})") + 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 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") + @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") - 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 + with local_session() as session: + author = session.query(Author).filter(Author.id == auth.author_id).one() - 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.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}") + # Проверяем разрешение + if not author.has_permission(resource, operation): + raise OperationNotAllowed(f"No permission for {operation} on {resource}") - return await func(parent, info, *args, **kwargs) + return await func(parent, info, *args, **kwargs) - except Exception as e: - 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 diff --git a/auth/internal.py b/auth/internal.py index 637d2c1a..628f34db 100644 --- a/auth/internal.py +++ b/auth/internal.py @@ -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 = request.headers.get(SESSION_TOKEN_HEADER) + token = None + + # 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 diff --git a/auth/jwtcodec.py b/auth/jwtcodec.py index d83cb313..3938bf66 100644 --- a/auth/jwtcodec.py +++ b/auth/jwtcodec.py @@ -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)}") diff --git a/auth/middleware.py b/auth/middleware.py index 86480aa3..239a54f8 100644 --- a/auth/middleware.py +++ b/auth/middleware.py @@ -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"): - self._context["response"].set_cookie(key, value, **options) + 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"): - self._context["response"].delete_cookie(key, **options) + 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)}") diff --git a/auth/sessions.py b/auth/sessions.py index de1c87c7..386c1f9b 100644 --- a/auth/sessions.py +++ b/auth/sessions.py @@ -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}" + """ + Создаёт ключ для сессии в Redis. + + Args: + user_id: ID пользователя + 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: - """Формирует ключ для списка сессий пользователя в Redis""" + """ + Создаёт ключ для списка активных сессий пользователя. + + 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: dict = None) -> str: + async def create_session(cls, user_id: str, username: str, device_info: Optional[dict] = None) -> str: """ - Создает новую сессию для пользователя. - + Создаёт новую сессию. + Args: user_id: ID пользователя - username: Имя пользователя/логин + 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, token) + user_sessions_key = cls._make_user_sessions_key(user_id) - # Ключи в Redis - session_key = cls._make_session_key(user_id, session_token) - user_sessions_key = cls._make_user_sessions_key(user_id) + # Сохраняем информацию о сессии + session_data = { + "user_id": user_id, + "username": username, + "created_at": datetime.now(tz=timezone.utc).isoformat(), + "expires_at": expiration_date.isoformat(), + } - # Сохраняем в 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() + # Добавляем информацию об устройстве, если она есть + if device_info: + for key, value in device_info.items(): + session_data[f"device_{key}"] = value - return session_token - except Exception as e: - logger.error(f"[SessionManager.create_session] Ошибка: {str(e)}") - raise + # Сохраняем сессию в 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 = JWTCodec.decode(token) - - # Формируем ключ сессии - session_key = cls._make_session_key(payload.user_id, token) - - # Проверяем существование сессии в Redis - session_exists = await redis.exists(session_key) - if not session_exists: - logger.debug(f"[SessionManager.verify_session] Сессия не найдена: {payload.user_id}") - return None - - return payload - - except Exception as e: - logger.error(f"[SessionManager.verify_session] Ошибка: {str(e)}") + # Декодируем токен для получения payload + payload = JWTCodec.decode(token) + if not payload: return None + # Получаем данные из payload + user_id = payload.user_id + + # Формируем ключ сессии + session_key = cls._make_session_key(user_id, token) + logger.debug(f"[SessionManager.verify_session] Сформирован ключ сессии: {session_key}") + + # Проверяем существование сессии в Redis + 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 + + # Если сессия не найдена ни в новом, ни в старом формате, проверяем все ключи в 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)}") diff --git a/auth/state.py b/auth/state.py new file mode 100644 index 00000000..ecb638a7 --- /dev/null +++ b/auth/state.py @@ -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 \ No newline at end of file diff --git a/auth/tokenstorage.py b/auth/tokenstorage.py index fa522d4f..b1895bfe 100644 --- a/auth/tokenstorage.py +++ b/auth/tokenstorage.py @@ -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: """ diff --git a/docs/auth.md b/docs/auth.md index 5b5f583d..f59df047 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -86,7 +86,7 @@ - `sendLink` - отправка ссылки для входа ### Запросы -- `signOut` - выход из системы +- `logout` - выход из системы - `isEmailUsed` - проверка использования email ## Безопасность diff --git a/main.py b/main.py index 5dd40915..07a13f3d 100644 --- a/main.py +++ b/main.py @@ -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): - return result - return JSONResponse(result) + + # Если результат не является 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 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) # Добавляем маршруты, порядок имеет значение diff --git a/resolvers/admin.py b/resolvers/admin.py index e3975358..64fb5529 100644 --- a/resolvers/admin.py +++ b/resolvers/admin.py @@ -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, diff --git a/resolvers/auth.py b/resolvers/auth.py index 959bae86..1b9bf363 100644 --- a/resolvers/auth.py +++ b/resolvers/auth.py @@ -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) @@ -42,23 +43,34 @@ async def get_current_user(_, info): return {"token": token, "author": author} -@mutation.field("confirmEmail") +@mutation.field("confirmEmail") async def confirm_email(_, info, token): """confirm owning email address""" try: 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) @@ -75,10 +87,11 @@ async def confirm_email(_, info, token): "token": None, "author": None, "error": f"Ошибка подтверждения email: {str(e)}", - } + } 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,9 +302,34 @@ 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") - info.context.response.set_cookie( + 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, + httponly=SESSION_COOKIE_HTTPONLY, + secure=SESSION_COOKIE_SECURE, + samesite=SESSION_COOKIE_SAMESITE, + max_age=SESSION_COOKIE_MAX_AGE, + ) + logger.info(f"[auth] login: Установлена cookie через response") + cookie_set = True + except Exception as e: + 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, @@ -296,15 +337,15 @@ 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 недоступны" - ) - except Exception as e: - # В случае ошибки при установке cookie просто логируем, но продолжаем авторизацию - logger.error(f"[auth] login: Ошибка при установке cookie: {str(e)}") - logger.debug(traceback.format_exc()) - + 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}") result = {"success": True, "token": token, "author": valid_author, "error": None} @@ -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() diff --git a/schema/query.graphql b/schema/query.graphql index 44d5ead4..2a4d10cf 100644 --- a/schema/query.graphql +++ b/schema/query.graphql @@ -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! diff --git a/services/redis.py b/services/redis.py index 38dcce5f..bcbf4382 100644 --- a/services/redis.py +++ b/services/redis.py @@ -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: - return self._client.pipeline() - raise Exception("Redis client is not initialized") + if self._client is None: + # Выбрасываем исключение, так как pipeline нельзя создать до подключения + raise Exception("Redis client is not initialized. Call redis.connect() first.") + + return self._client.pipeline() async def subscribe(self, *channels): - if self._client: - async with self._client.pubsub() as pubsub: - for channel in channels: - await pubsub.subscribe(channel) - self.pubsub_channels.append(channel) + # Автоматически подключаемся к 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,9 +192,56 @@ 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()