linted+fmt
All checks were successful
Deploy on push / deploy (push) Successful in 6s

This commit is contained in:
2025-05-29 12:37:39 +03:00
parent d4c16658bd
commit 4070f4fcde
49 changed files with 835 additions and 983 deletions

View File

@@ -2,25 +2,25 @@ from starlette.requests import Request
from starlette.responses import JSONResponse, RedirectResponse
from starlette.routing import Route
from auth.sessions import SessionManager
from auth.internal import verify_internal_auth
from auth.orm import Author
from auth.sessions import SessionManager
from services.db import local_session
from utils.logger import root_logger as logger
from settings import (
SESSION_COOKIE_NAME,
SESSION_COOKIE_HTTPONLY,
SESSION_COOKIE_SECURE,
SESSION_COOKIE_SAMESITE,
SESSION_COOKIE_MAX_AGE,
SESSION_COOKIE_NAME,
SESSION_COOKIE_SAMESITE,
SESSION_COOKIE_SECURE,
SESSION_TOKEN_HEADER,
)
from utils.logger import root_logger as logger
async def logout(request: Request):
"""
Выход из системы с удалением сессии и cookie.
Поддерживает получение токена из:
1. HTTP-only cookie
2. Заголовка Authorization
@@ -30,7 +30,7 @@ async def logout(request: Request):
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:
# Сначала проверяем основной заголовок авторизации
@@ -42,7 +42,7 @@ async def logout(request: Request):
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")
@@ -74,7 +74,7 @@ async def logout(request: Request):
key=SESSION_COOKIE_NAME,
secure=SESSION_COOKIE_SECURE,
httponly=SESSION_COOKIE_HTTPONLY,
samesite=SESSION_COOKIE_SAMESITE
samesite=SESSION_COOKIE_SAMESITE,
)
logger.info("[auth] logout: Cookie успешно удалена")
@@ -84,22 +84,22 @@ async def logout(request: Request):
async def refresh_token(request: Request):
"""
Обновление токена аутентификации.
Поддерживает получение токена из:
1. HTTP-only cookie
2. Заголовка Authorization
Возвращает новый токен как в HTTP-only 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:
# Проверяем основной заголовок авторизации
@@ -113,7 +113,7 @@ async def refresh_token(request: Request):
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")
@@ -147,9 +147,7 @@ async def refresh_token(request: Request):
if not new_token:
logger.error(f"[auth] refresh_token: Не удалось обновить токен для пользователя {user_id}")
return JSONResponse(
{"success": False, "error": "Не удалось обновить токен"}, status_code=500
)
return JSONResponse({"success": False, "error": "Не удалось обновить токен"}, status_code=500)
# Создаем ответ
response = JSONResponse(

View File

@@ -1,4 +1,4 @@
from typing import Dict, List, Optional, Set, Any
from typing import Any, Dict, List, Optional, Set
from pydantic import BaseModel, Field

View File

@@ -1,19 +1,21 @@
from functools import wraps
from typing import Callable, Any, Dict, Optional
from typing import Any, Callable, Dict, Optional
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_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
from auth.jwtcodec import ExpiredToken, InvalidToken, JWTCodec
from auth.orm import Author
from auth.sessions import SessionManager
from auth.tokenstorage import TokenStorage
from services.db import local_session
from services.redis import redis
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
from settings import SESSION_COOKIE_NAME, SESSION_TOKEN_HEADER
from utils.logger import root_logger as logger
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
@@ -21,10 +23,10 @@ ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
def get_safe_headers(request: Any) -> Dict[str, str]:
"""
Безопасно получает заголовки запроса.
Args:
request: Объект запроса
Returns:
Dict[str, str]: Словарь заголовков
"""
@@ -34,12 +36,9 @@ def get_safe_headers(request: Any) -> Dict[str, str]:
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 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):
@@ -55,15 +54,15 @@ def get_safe_headers(request: Any) -> Dict[str, str]:
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"[decorators] Ошибка при доступе к заголовкам: {e}")
return headers
@@ -72,13 +71,13 @@ def get_auth_token(request: Any) -> Optional[str]:
Извлекает токен авторизации из запроса.
Порядок проверки:
1. Проверяет auth из middleware
2. Проверяет auth из scope
2. Проверяет auth из scope
3. Проверяет заголовок Authorization
4. Проверяет cookie с именем auth_token
Args:
request: Объект запроса
Returns:
Optional[str]: Токен авторизации или None
"""
@@ -100,7 +99,7 @@ def get_auth_token(request: Any) -> Optional[str]:
# 3. Проверяем заголовок Authorization
headers = get_safe_headers(request)
# Сначала проверяем основной заголовок авторизации
auth_header = headers.get(SESSION_TOKEN_HEADER.lower(), "")
if auth_header:
@@ -112,7 +111,7 @@ def get_auth_token(request: Any) -> Optional[str]:
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", "")
@@ -139,10 +138,10 @@ def get_auth_token(request: Any) -> Optional[str]:
async def validate_graphql_context(info: Any) -> None:
"""
Проверяет валидность GraphQL контекста и проверяет авторизацию.
Args:
info: GraphQL информация о контексте
Raises:
GraphQLError: если контекст невалиден или пользователь не авторизован
"""
@@ -161,7 +160,7 @@ async def validate_graphql_context(info: Any) -> None:
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")
@@ -170,49 +169,45 @@ async def validate_graphql_context(info: Any) -> None:
# Устанавливаем auth в request для дальнейшего использования
request.auth = auth_cred
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)
"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}")
# Если все проверки пройдены, создаем AuthCredentials и устанавливаем в request.auth
with local_session() as session:
try:
author = session.query(Author).filter(Author.id == auth_state.author_id).one()
# Получаем разрешения из ролей
scopes = author.get_permissions()
# Создаем объект авторизации
auth_cred = AuthCredentials(
author_id=author.id,
scopes=scopes,
logged_in=True,
email=author.email,
token=auth_state.token
author_id=author.id, scopes=scopes, logged_in=True, email=author.email, token=auth_state.token
)
# Устанавливаем auth в request
request.auth = auth_cred
logger.debug(f"[decorators] Токен успешно проверен и установлен для пользователя {auth_state.author_id}")
except exc.NoResultFound:
logger.error(f"[decorators] Пользователь с ID {auth_state.author_id} не найден в базе данных")
raise GraphQLError("Unauthorized - user not found")
return
@@ -229,18 +224,19 @@ def admin_auth_required(resolver: Callable) -> Callable:
Raises:
GraphQLError: если пользователь не авторизован или не имеет доступа администратора
Example:
>>> @admin_auth_required
... async def admin_resolver(root, info, **kwargs):
... return "Admin data"
"""
@wraps(resolver)
async def wrapper(root: Any = None, info: Any = None, **kwargs):
try:
# Проверяем авторизацию пользователя
await validate_graphql_context(info)
# Получаем объект авторизации
auth = info.context["request"].auth
if not auth or not auth.logged_in:
@@ -255,22 +251,24 @@ def admin_auth_required(resolver: Callable) -> Callable:
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)
# Проверяем роли пользователя
admin_roles = ['admin', 'super']
admin_roles = ["admin", "super"]
user_roles = [role.id for role in author.roles] if author.roles else []
if any(role in admin_roles for role in user_roles):
logger.info(f"Admin access granted for {author.email} (ID: {author.id}) with role: {user_roles}")
logger.info(
f"Admin access granted for {author.email} (ID: {author.id}) with role: {user_roles}"
)
return await resolver(root, info, **kwargs)
logger.warning(f"Admin access denied for {author.email} (ID: {author.id}). Roles: {user_roles}")
raise GraphQLError("Unauthorized - not an admin")
except exc.NoResultFound:
@@ -301,7 +299,7 @@ def permission_required(resource: str, operation: str, func):
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
# Сначала проверяем авторизацию
await validate_graphql_context(info)
# Получаем объект авторизации
logger.debug(f"[permission_required] Контекст: {info.context}")
auth = info.context["request"].auth
@@ -324,21 +322,27 @@ def permission_required(resource: str, operation: str, func):
if author.email in ADMIN_EMAILS:
logger.debug(f"[permission_required] Администратор {author.email} имеет все разрешения")
return await func(parent, info, *args, **kwargs)
# Проверяем роли пользователя
admin_roles = ['admin', 'super']
admin_roles = ["admin", "super"]
user_roles = [role.id for role in author.roles] if author.roles else []
if any(role in admin_roles for role in user_roles):
logger.debug(f"[permission_required] Пользователь с ролью администратора {author.email} имеет все разрешения")
logger.debug(
f"[permission_required] Пользователь с ролью администратора {author.email} имеет все разрешения"
)
return await func(parent, info, *args, **kwargs)
# Проверяем разрешение
if not author.has_permission(resource, operation):
logger.warning(f"[permission_required] У пользователя {author.email} нет разрешения {operation} на {resource}")
logger.warning(
f"[permission_required] У пользователя {author.email} нет разрешения {operation} на {resource}"
)
raise OperationNotAllowed(f"No permission for {operation} on {resource}")
logger.debug(f"[permission_required] Пользователь {author.email} имеет разрешение {operation} на {resource}")
logger.debug(
f"[permission_required] Пользователь {author.email} имеет разрешение {operation} на {resource}"
)
return await func(parent, info, *args, **kwargs)
except exc.NoResultFound:
logger.error(f"[permission_required] Пользователь с ID {auth.author_id} не найден в базе данных")
@@ -349,14 +353,15 @@ def permission_required(resource: str, operation: str, func):
def login_accepted(func):
"""
Декоратор для резолверов, которые могут работать как с авторизованными,
Декоратор для резолверов, которые могут работать как с авторизованными,
так и с неавторизованными пользователями.
Добавляет информацию о пользователе в контекст, если пользователь авторизован.
Args:
func: Декорируемая функция
"""
@wraps(func)
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
try:
@@ -366,10 +371,10 @@ def login_accepted(func):
except GraphQLError:
# Игнорируем ошибку авторизации
pass
# Получаем объект авторизации
auth = getattr(info.context["request"], "auth", None)
if auth and auth.logged_in:
# Если пользователь авторизован, добавляем информацию о нем в контекст
with local_session() as session:

View File

@@ -1,46 +1,48 @@
from ariadne.asgi.handlers import GraphQLHTTPHandler
from starlette.requests import Request
from starlette.responses import Response, JSONResponse
from starlette.responses import JSONResponse, Response
from auth.middleware import auth_middleware
from utils.logger import root_logger as logger
class EnhancedGraphQLHTTPHandler(GraphQLHTTPHandler):
"""
Улучшенный GraphQL HTTP обработчик с поддержкой cookie и авторизации.
Расширяет стандартный GraphQLHTTPHandler для:
1. Создания расширенного контекста запроса с авторизационными данными
2. Корректной обработки ответов с cookie и headers
3. Интеграции с AuthMiddleware
"""
async def get_context_for_request(self, request: Request, data: dict) -> dict:
"""
Расширяем контекст для GraphQL запросов.
Добавляет к стандартному контексту:
- Объект response для установки cookie
- Интеграцию с AuthMiddleware
- Расширения для управления авторизацией
Args:
request: Starlette Request объект
data: данные запроса
Returns:
dict: контекст с дополнительными данными для авторизации и cookie
"""
# Получаем стандартный контекст от базового класса
context = await super().get_context_for_request(request, data)
# Создаем объект ответа для установки cookie
response = JSONResponse({})
context["response"] = response
# Интегрируем с AuthMiddleware
auth_middleware.set_context(context)
context["extensions"] = auth_middleware
# Добавляем данные авторизации только если они доступны
# Без проверки hasattr, так как это вызывает ошибку до обработки AuthenticationMiddleware
if hasattr(request, "auth") and request.auth:
@@ -48,7 +50,7 @@ class EnhancedGraphQLHTTPHandler(GraphQLHTTPHandler):
context["auth"] = request.auth
# Безопасно логируем информацию о типе объекта auth
logger.debug(f"[graphql] Добавлены данные авторизации в контекст: {type(request.auth).__name__}")
logger.debug(f"[graphql] Подготовлен расширенный контекст для запроса")
return context

View File

@@ -1,13 +1,12 @@
from binascii import hexlify
from hashlib import sha256
from typing import Any, Dict, TypeVar, TYPE_CHECKING
from typing import TYPE_CHECKING, Any, Dict, TypeVar
from passlib.hash import bcrypt
from auth.exceptions import ExpiredToken, InvalidToken, InvalidPassword
from auth.exceptions import ExpiredToken, InvalidPassword, InvalidToken
from auth.jwtcodec import JWTCodec
from auth.tokenstorage import TokenStorage
from services.db import local_session
# Для типизации
@@ -86,9 +85,7 @@ class Identity:
# Проверим исходный пароль в orm_author
if not orm_author.password:
logger.warning(
f"[auth.identity] Пароль в исходном объекте автора пуст: email={orm_author.email}"
)
logger.warning(f"[auth.identity] Пароль в исходном объекте автора пуст: email={orm_author.email}")
raise InvalidPassword("Пароль не установлен для данного пользователя")
# Проверяем пароль напрямую, не используя dict()

View File

@@ -1,22 +1,22 @@
from typing import Optional, Tuple
import time
from typing import Any
from typing import Any, Optional, Tuple
from sqlalchemy.orm import exc
from starlette.authentication import AuthenticationBackend, BaseUser, UnauthenticatedUser
from starlette.requests import HTTPConnection
from auth.credentials import AuthCredentials
from auth.exceptions import ExpiredToken, InvalidToken
from auth.jwtcodec import JWTCodec
from auth.orm import Author
from auth.sessions import SessionManager
from services.db import local_session
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.db import local_session
from services.redis import redis
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
from settings import SESSION_COOKIE_NAME, SESSION_TOKEN_HEADER
from utils.logger import root_logger as logger
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
@@ -24,13 +24,9 @@ ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
class AuthenticatedUser(BaseUser):
"""Аутентифицированный пользователь для Starlette"""
def __init__(self,
user_id: str,
username: str = "",
roles: list = None,
permissions: dict = None,
token: str = 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 []
@@ -56,17 +52,17 @@ class InternalAuthentication(AuthenticationBackend):
async def authenticate(self, request: HTTPConnection):
"""
Аутентифицирует пользователя по токену из заголовка или cookie.
Порядок поиска токена:
1. Проверяем заголовок SESSION_TOKEN_HEADER (может быть установлен middleware)
2. Проверяем scope/auth в request, куда middleware мог сохранить токен
2. Проверяем scope/auth в request, куда middleware мог сохранить токен
3. Проверяем cookie
Возвращает:
tuple: (AuthCredentials, BaseUser)
"""
token = None
# 1. Проверяем заголовок
if SESSION_TOKEN_HEADER in request.headers:
token_header = request.headers.get(SESSION_TOKEN_HEADER)
@@ -77,19 +73,19 @@ class InternalAuthentication(AuthenticationBackend):
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] Токен не найден")
@@ -112,9 +108,7 @@ class InternalAuthentication(AuthenticationBackend):
if author.is_locked():
logger.debug(f"[auth.authenticate] Аккаунт заблокирован: {author.id}")
return AuthCredentials(
scopes={}, error_message="Account is locked"
), UnauthenticatedUser()
return AuthCredentials(scopes={}, error_message="Account is locked"), UnauthenticatedUser()
# Получаем разрешения из ролей
scopes = author.get_permissions()
@@ -128,11 +122,7 @@ class InternalAuthentication(AuthenticationBackend):
# Создаем объекты авторизации с сохранением токена
credentials = AuthCredentials(
author_id=author.id,
scopes=scopes,
logged_in=True,
email=author.email,
token=token
author_id=author.id, scopes=scopes, logged_in=True, email=author.email, token=token
)
user = AuthenticatedUser(
@@ -140,7 +130,7 @@ class InternalAuthentication(AuthenticationBackend):
username=author.slug or author.email or "",
roles=roles,
permissions=scopes,
token=token
token=token,
)
logger.debug(f"[auth.authenticate] Успешная аутентификация: {author.email}")
@@ -163,7 +153,7 @@ async def verify_internal_auth(token: str) -> Tuple[str, list, bool]:
tuple: (user_id, roles, is_admin)
"""
logger.debug(f"[verify_internal_auth] Проверка токена: {token[:10]}...")
# Обработка формата "Bearer <token>" (если токен не был обработан ранее)
if token and token.startswith("Bearer "):
token = token.replace("Bearer ", "", 1).strip()
@@ -188,11 +178,13 @@ async def verify_internal_auth(token: str) -> Tuple[str, list, bool]:
# Получаем роли
roles = [role.id for role in author.roles]
logger.debug(f"[verify_internal_auth] Роли пользователя: {roles}")
# Определяем, является ли пользователь администратором
is_admin = any(role in ['admin', 'super'] for role in roles) or author.email in ADMIN_EMAILS
logger.debug(f"[verify_internal_auth] Пользователь {author.id} {'является' if is_admin else 'не является'} администратором")
is_admin = any(role in ["admin", "super"] for role in roles) or author.email in ADMIN_EMAILS
logger.debug(
f"[verify_internal_auth] Пользователь {author.id} {'является' if is_admin else 'не является'} администратором"
)
return str(author.id), roles, is_admin
except exc.NoResultFound:
logger.warning(f"[verify_internal_auth] Пользователь с ID {payload.user_id} не найден в БД или не активен")
@@ -257,7 +249,7 @@ async def authenticate(request: Any) -> AuthState:
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()
@@ -285,13 +277,13 @@ async def authenticate(request: Any) -> AuthState:
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
# Если запрос имеет атрибут auth, устанавливаем в него авторизационные данные
if hasattr(request, "auth") or hasattr(request, "__setattr__"):
try:
@@ -301,22 +293,20 @@ async def authenticate(request: Any) -> AuthState:
if author:
# Получаем разрешения из ролей
scopes = author.get_permissions()
# Создаем объект авторизации
auth_cred = AuthCredentials(
author_id=author.id,
scopes=scopes,
logged_in=True,
email=author.email,
token=token
author_id=author.id, scopes=scopes, logged_in=True, email=author.email, token=token
)
# Устанавливаем auth в request
setattr(request, "auth", auth_cred)
logger.debug(f"[auth.authenticate] Авторизационные данные установлены в request.auth для {payload.user_id}")
logger.debug(
f"[auth.authenticate] Авторизационные данные установлены в request.auth для {payload.user_id}"
)
except Exception as e:
logger.error(f"[auth.authenticate] Ошибка при установке auth в request: {e}")
logger.info(f"[auth.authenticate] Успешная аутентификация пользователя {state.author_id}")
return state

View File

@@ -1,12 +1,13 @@
from datetime import datetime, timezone, timedelta
from datetime import datetime, timedelta, timezone
from typing import Optional
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
from utils.logger import root_logger as logger
class TokenPayload(BaseModel):
user_id: str
@@ -28,14 +29,14 @@ class JWTCodec:
# Для объектов с атрибутами
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 чтобы гарантировать правильный формат
@@ -44,7 +45,7 @@ class JWTCodec:
# Если передано что-то другое, установим значение по умолчанию
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": username,
@@ -52,9 +53,9 @@ class JWTCodec:
"iat": datetime.now(tz=timezone.utc),
"iss": "discours",
}
logger.debug(f"[JWTCodec.encode] Сформирован payload: {payload}")
try:
token = jwt.encode(payload, JWT_SECRET_KEY, JWT_ALGORITHM)
logger.debug(f"[JWTCodec.encode] Токен успешно создан, длина: {len(token) if token else 0}")
@@ -66,11 +67,11 @@ class JWTCodec:
@staticmethod
def decode(token: str, verify_exp: bool = True):
logger.debug(f"[JWTCodec.decode] Начало декодирования токена длиной {len(token) if token else 0}")
if not token:
logger.error("[JWTCodec.decode] Пустой токен")
return None
try:
payload = jwt.decode(
token,
@@ -83,21 +84,23 @@ class JWTCodec:
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())
try:
r = TokenPayload(**payload)
logger.debug(f"[JWTCodec.decode] Создан объект TokenPayload: user_id={r.user_id}, username={r.username}")
logger.debug(
f"[JWTCodec.decode] Создан объект TokenPayload: user_id={r.user_id}, username={r.username}"
)
return r
except Exception as e:
logger.error(f"[JWTCodec.decode] Ошибка при создании TokenPayload: {e}")
return None
except jwt.InvalidIssuedAtError:
logger.error("[JWTCodec.decode] Недействительное время выпуска токена")
return None

View File

@@ -1,19 +1,29 @@
"""
Middleware для обработки авторизации в GraphQL запросах
"""
from typing import Any, Dict
from starlette.datastructures import Headers
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from starlette.datastructures import Headers
from starlette.types import ASGIApp, Scope, Receive, Send
from starlette.types import ASGIApp, Receive, Scope, Send
from settings import (
SESSION_COOKIE_HTTPONLY,
SESSION_COOKIE_MAX_AGE,
SESSION_COOKIE_NAME,
SESSION_COOKIE_SAMESITE,
SESSION_COOKIE_SECURE,
SESSION_TOKEN_HEADER,
)
from utils.logger import root_logger as logger
from settings import SESSION_COOKIE_HTTPONLY, SESSION_COOKIE_MAX_AGE, SESSION_COOKIE_SAMESITE, SESSION_COOKIE_SECURE, SESSION_TOKEN_HEADER, SESSION_COOKIE_NAME
class AuthMiddleware:
"""
Универсальный middleware для обработки авторизации и управления cookies.
Основные функции:
1. Извлечение Bearer токена из заголовка Authorization или cookie
2. Добавление токена в заголовки запроса для обработки AuthenticationMiddleware
@@ -23,7 +33,7 @@ class AuthMiddleware:
def __init__(self, app: ASGIApp):
self.app = app
self._context = None
async def __call__(self, scope: Scope, receive: Receive, send: Send):
"""Обработка ASGI запроса"""
if scope["type"] != "http":
@@ -93,33 +103,29 @@ class AuthMiddleware:
scope["headers"] = new_headers
# Также добавляем информацию о типе аутентификации для дальнейшего использования
scope["auth"] = {
"type": "bearer",
"token": token,
"source": token_source
}
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 в ответе
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:
@@ -128,7 +134,7 @@ class AuthMiddleware:
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:
@@ -137,20 +143,20 @@ class AuthMiddleware:
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 из ответа
Args:
key: Имя cookie для удаления
**options: Дополнительные параметры
"""
success = False
# Способ 1: Через response
if self._context and "response" in self._context and hasattr(self._context["response"], "delete_cookie"):
try:
@@ -159,7 +165,7 @@ class AuthMiddleware:
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:
@@ -168,7 +174,7 @@ class AuthMiddleware:
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 недоступны")
@@ -180,38 +186,41 @@ class AuthMiddleware:
try:
# Получаем доступ к контексту запроса
context = info.context
# Сохраняем ссылку на контекст
self.set_context(context)
# Добавляем себя как объект, содержащий утилитные методы
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")
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)}")
raise
async def process_result(self, request: Request, result: Any) -> Response:
"""
Обрабатывает результат GraphQL запроса, поддерживая установку cookie
Args:
request: Starlette Request объект
result: результат GraphQL запроса (dict или Response)
Returns:
Response: HTTP-ответ с результатом и cookie (если необходимо)
"""
# Проверяем, является ли result уже объектом Response
if isinstance(result, Response):
response = result
@@ -220,19 +229,20 @@ class AuthMiddleware:
if isinstance(result, JSONResponse):
try:
import json
result_data = json.loads(result.body.decode('utf-8'))
result_data = json.loads(result.body.decode("utf-8"))
except Exception as e:
logger.error(f"[process_result] Не удалось извлечь данные из JSONResponse: {str(e)}")
else:
response = JSONResponse(result)
result_data = result
# Проверяем, был ли токен в запросе или ответе
if request.method == "POST":
try:
data = await request.json()
op_name = data.get("operationName", "").lower()
# Если это операция логина или обновления токена, и в ответе есть токен
if op_name in ["login", "refreshtoken"]:
token = None
@@ -243,32 +253,35 @@ class AuthMiddleware:
op_result = data_obj.get(op_name, {})
if isinstance(op_result, dict) and "token" in op_result:
token = op_result.get("token")
if token:
# Устанавливаем cookie с токеном
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=token,
httponly=SESSION_COOKIE_HTTPONLY,
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}")
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
samesite=SESSION_COOKIE_SAMESITE,
)
logger.debug(f"[graphql_handler] Удалена cookie {SESSION_COOKIE_NAME} для операции {op_name}")
except Exception as e:
logger.error(f"[process_result] Ошибка при обработке POST запроса: {str(e)}")
return response
# Создаем единый экземпляр AuthMiddleware для использования с GraphQL
auth_middleware = AuthMiddleware(lambda scope, receive, send: None)
auth_middleware = AuthMiddleware(lambda scope, receive, send: None)

View File

@@ -1,11 +1,12 @@
import time
from secrets import token_urlsafe
from authlib.integrations.starlette_client import OAuth
from authlib.oauth2.rfc7636 import create_s256_code_challenge
from starlette.responses import RedirectResponse, JSONResponse
from secrets import token_urlsafe
import time
from starlette.responses import JSONResponse, RedirectResponse
from auth.tokenstorage import TokenStorage
from auth.orm import Author
from auth.tokenstorage import TokenStorage
from services.db import local_session
from settings import FRONTEND_URL, OAUTH_CLIENTS
@@ -129,9 +130,7 @@ async def oauth_callback(request):
return JSONResponse({"error": "Provider not configured"}, status_code=400)
# Получаем токен с PKCE verifier
token = await client.authorize_access_token(
request, code_verifier=request.session.get("code_verifier")
)
token = await client.authorize_access_token(request, code_verifier=request.session.get("code_verifier"))
# Получаем профиль пользователя
profile = await get_user_profile(provider, client, token)

View File

@@ -1,5 +1,6 @@
import time
from typing import Dict, Set
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String
from sqlalchemy.orm import relationship
@@ -180,7 +181,7 @@ class Author(Base):
# )
# Список защищенных полей, которые видны только владельцу и администраторам
_protected_fields = ['email', 'password', 'provider_access_token', 'provider_refresh_token']
_protected_fields = ["email", "password", "provider_access_token", "provider_refresh_token"]
@property
def is_authenticated(self) -> bool:
@@ -241,27 +242,27 @@ class Author(Base):
def dict(self, access=False) -> Dict:
"""
Сериализует объект Author в словарь с учетом прав доступа.
Args:
access (bool, optional): Флаг, указывающий, доступны ли защищенные поля
Returns:
dict: Словарь с атрибутами Author, отфильтрованный по правам доступа
"""
# Получаем все атрибуты объекта
result = {c.name: getattr(self, c.name) for c in self.__table__.columns}
# Добавляем роли как список идентификаторов и названий
if hasattr(self, 'roles'):
result['roles'] = []
if hasattr(self, "roles"):
result["roles"] = []
for role in self.roles:
if isinstance(role, dict):
result['roles'].append(role.get('id'))
result["roles"].append(role.get("id"))
# скрываем защищенные поля
if not access:
for field in self._protected_fields:
if field in result:
result[field] = None
return result

View File

@@ -9,9 +9,9 @@ from typing import List, Union
from sqlalchemy.orm import Session
from auth.orm import Author, Role, RolePermission, Permission
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
from auth.orm import Author, Permission, Role, RolePermission
from orm.community import Community, CommunityFollower, CommunityRole
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
@@ -110,9 +110,7 @@ class ContextualPermissionCheck:
return has_permission
@staticmethod
def get_user_community_roles(
session: Session, author_id: int, community_slug: str
) -> List[CommunityRole]:
def get_user_community_roles(session: Session, author_id: int, community_slug: str) -> List[CommunityRole]:
"""
Получает список ролей пользователя в сообществе.

View File

@@ -1,9 +1,10 @@
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any, List
from typing import Any, Dict, List, Optional
from pydantic import BaseModel
from services.redis import redis
from auth.jwtcodec import JWTCodec, TokenPayload
from services.redis import redis
from settings import SESSION_TOKEN_LIFE_SPAN
from utils.logger import root_logger as logger
@@ -28,11 +29,11 @@ class SessionManager:
def _make_session_key(user_id: str, token: str) -> str:
"""
Создаёт ключ для сессии в Redis.
Args:
user_id: ID пользователя
token: JWT токен сессии
Returns:
str: Ключ сессии
"""
@@ -44,10 +45,10 @@ class SessionManager:
def _make_user_sessions_key(user_id: str) -> str:
"""
Создаёт ключ для списка активных сессий пользователя.
Args:
user_id: ID пользователя
Returns:
str: Ключ списка сессий
"""
@@ -57,12 +58,12 @@ class SessionManager:
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: JWT токен сессии
"""
@@ -96,37 +97,37 @@ class SessionManager:
# Устанавливаем время жизни ключей (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: JWT токен
Returns:
Optional[TokenPayload]: Данные токена или None, если сессия недействительна
"""
logger.debug(f"[SessionManager.verify_session] Проверка сессии для токена: {token[:20]}...")
# Декодируем токен для получения payload
try:
payload = JWTCodec.decode(token)
if not payload:
logger.error("[SessionManager.verify_session] Не удалось декодировать токен")
return None
logger.debug(f"[SessionManager.verify_session] Успешно декодирован токен, user_id={payload.user_id}")
except Exception as e:
logger.error(f"[SessionManager.verify_session] Ошибка при декодировании токена: {str(e)}")
@@ -134,69 +135,71 @@ class SessionManager:
# Получаем данные из 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}")
# Проверяем, можно ли доверять токену напрямую
# Если токен валидный и не истек, мы можем доверять ему даже без записи в Redis
if payload and payload.exp and payload.exp > datetime.now(tz=timezone.utc):
logger.info(f"[SessionManager.verify_session] Токен валиден по JWT, создаем сессию для {user_id}")
# Создаем сессию на основе валидного токена
session_data = {
"user_id": user_id,
"username": payload.username,
"created_at": datetime.now(tz=timezone.utc).isoformat(),
"expires_at": payload.exp.isoformat() if isinstance(payload.exp, datetime) else datetime.fromtimestamp(payload.exp, tz=timezone.utc).isoformat(),
"expires_at": payload.exp.isoformat()
if isinstance(payload.exp, datetime)
else datetime.fromtimestamp(payload.exp, tz=timezone.utc).isoformat(),
}
# Сохраняем сессию в Redis
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
# Если сессии нет, возвращаем None
return None
# Если сессия найдена, возвращаем payload
logger.debug(f"[SessionManager.verify_session] Сессия найдена для пользователя {user_id}")
return payload
@@ -205,89 +208,89 @@ class SessionManager:
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

View File

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

View File

@@ -1,7 +1,7 @@
from datetime import datetime, timedelta, timezone
import json
import time
from typing import Dict, Any, Optional, Tuple, List
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple
from auth.jwtcodec import JWTCodec
from auth.validations import AuthInput
@@ -81,7 +81,7 @@ class TokenStorage:
# Формируем ключи для 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)
@@ -91,25 +91,25 @@ class TokenStorage:
"user_id": user_id,
"username": username,
"created_at": time.time(),
"expires_at": time.time() + 30 * 24 * 60 * 60 # 30 дней
"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}")
@@ -146,39 +146,39 @@ class TokenStorage:
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
@@ -200,30 +200,30 @@ class TokenStorage:
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
@@ -243,11 +243,11 @@ class TokenStorage:
# Получаем список сессий пользователя
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 токен
@@ -255,28 +255,28 @@ class TokenStorage:
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