diff --git a/auth/decorators.py b/auth/decorators.py index 97e75598..dd3079c9 100644 --- a/auth/decorators.py +++ b/auth/decorators.py @@ -167,7 +167,9 @@ async def validate_graphql_context(info: Any) -> None: 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 в request для дальнейшего использования + request.auth = auth_cred + return # Если авторизации нет ни в auth, ни в scope, пробуем получить и проверить токен token = get_auth_token(request) @@ -188,9 +190,29 @@ async def validate_graphql_context(info: Any) -> None: logger.warning(f"[decorators] Недействительный токен: {error_msg}") raise GraphQLError(f"Unauthorized - {error_msg}") - # Если все проверки пройдены, оставляем AuthState в scope - # AuthenticationMiddleware извлечет нужные данные оттуда при необходимости - logger.debug(f"[decorators] Токен успешно проверен для пользователя {auth_state.author_id}") + # Если все проверки пройдены, создаем 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 + ) + + # Устанавливаем 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 @@ -203,7 +225,7 @@ def admin_auth_required(resolver: Callable) -> Callable: resolver: GraphQL резолвер для защиты Returns: - Обернутый резолвер, который проверяет права доступа + Обернутый резолвер, который проверяет права доступа администратора Raises: GraphQLError: если пользователь не авторизован или не имеет доступа администратора @@ -216,9 +238,16 @@ def admin_auth_required(resolver: Callable) -> Callable: @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: + logger.error(f"[admin_auth_required] Пользователь не авторизован после validate_graphql_context") + raise GraphQLError("Unauthorized - please login") + # Проверяем, является ли пользователь администратором with local_session() as session: try: # Преобразуем author_id в int для совместимости с базой данных @@ -229,11 +258,20 @@ def admin_auth_required(resolver: Callable) -> Callable: 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})") + # Проверяем роли пользователя + 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}") + 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: logger.error(f"[admin_auth_required] Пользователь с ID {auth.author_id} не найден в базе данных") @@ -249,8 +287,6 @@ def admin_auth_required(resolver: Callable) -> Callable: return wrapper - - def permission_required(resource: str, operation: str, func): """ Декоратор для проверки разрешений. @@ -263,43 +299,99 @@ def permission_required(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") + # Сначала проверяем авторизацию + await validate_graphql_context(info) + + # Получаем объект авторизации + logger.debug(f"[permission_required] Контекст: {info.context}") + auth = info.context["request"].auth + if not auth or not auth.logged_in: + logger.error(f"[permission_required] Пользователь не авторизован после validate_graphql_context") + raise OperationNotAllowed("Требуются права доступа") + # Проверяем разрешения with local_session() as session: - author = session.query(Author).filter(Author.id == auth.author_id).one() + try: + 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): - raise OperationNotAllowed(f"No permission for {operation} on {resource}") + # Проверяем, является ли пользователь администратором (у них есть все разрешения) + if author.email in ADMIN_EMAILS: + logger.debug(f"[permission_required] Администратор {author.email} имеет все разрешения") + return await func(parent, info, *args, **kwargs) + + # Проверяем роли пользователя + 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} имеет все разрешения") + return await func(parent, info, *args, **kwargs) - return await func(parent, info, *args, **kwargs) + # Проверяем разрешение + if not author.has_permission(resource, operation): + 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}") + return await func(parent, info, *args, **kwargs) + except exc.NoResultFound: + logger.error(f"[permission_required] Пользователь с ID {auth.author_id} не найден в базе данных") + raise OperationNotAllowed("User not found") return wrap - def login_accepted(func): + """ + Декоратор для резолверов, которые могут работать как с авторизованными, + так и с неавторизованными пользователями. + + Добавляет информацию о пользователе в контекст, если пользователь авторизован. + + Args: + func: Декорируемая функция + """ @wraps(func) async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs): - auth: AuthCredentials = info.context["request"].auth + try: + # Пробуем проверить авторизацию, но не выбрасываем исключение, если пользователь не авторизован + try: + await validate_graphql_context(info) + except GraphQLError: + # Игнорируем ошибку авторизации + pass + + # Получаем объект авторизации + auth = getattr(info.context["request"], "auth", None) + + if auth and auth.logged_in: + # Если пользователь авторизован, добавляем информацию о нем в контекст + with local_session() as session: + try: + author = session.query(Author).filter(Author.id == auth.author_id).one() + info.context["author"] = author.dict() + info.context["user_id"] = author.id + logger.debug(f"[login_accepted] Пользователь авторизован: {author.id}") + except exc.NoResultFound: + logger.warning(f"[login_accepted] Пользователь с ID {auth.author_id} не найден в базе данных") + info.context["author"] = None + info.context["user_id"] = None + else: + # Если пользователь не авторизован, устанавливаем пустые значения + info.context["author"] = None + info.context["user_id"] = None + logger.debug("[login_accepted] Пользователь не авторизован") - 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 await func(parent, info, *args, **kwargs) + except Exception as e: + if not isinstance(e, GraphQLError): + logger.error(f"[login_accepted] Ошибка: {e}") + raise return wrap diff --git a/auth/internal.py b/auth/internal.py index 5369a5f5..16f1e187 100644 --- a/auth/internal.py +++ b/auth/internal.py @@ -162,15 +162,20 @@ async def verify_internal_auth(token: str) -> Tuple[str, list, bool]: Returns: tuple: (user_id, roles, is_admin) """ + logger.debug(f"[verify_internal_auth] Проверка токена: {token[:10]}...") + # Обработка формата "Bearer " (если токен не был обработан ранее) - if token.startswith("Bearer "): + if token and token.startswith("Bearer "): token = token.replace("Bearer ", "", 1).strip() # Проверяем сессию payload = await SessionManager.verify_session(token) if not payload: + logger.warning("[verify_internal_auth] Недействительный токен: payload не получен") return "", [], False + logger.debug(f"[verify_internal_auth] Токен действителен, user_id={payload.user_id}") + with local_session() as session: try: author = ( @@ -182,12 +187,15 @@ 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 'не является'} администратором") return str(author.id), roles, is_admin except exc.NoResultFound: + logger.warning(f"[verify_internal_auth] Пользователь с ID {payload.user_id} не найден в БД или не активен") return "", [], False @@ -284,6 +292,31 @@ async def authenticate(request: Any) -> AuthState: state.token = token state.username = payload.username + # Если запрос имеет атрибут auth, устанавливаем в него авторизационные данные + if hasattr(request, "auth") or hasattr(request, "__setattr__"): + try: + # Получаем информацию о пользователе для создания AuthCredentials + with local_session() as session: + author = session.query(Author).filter(Author.id == payload.user_id).one_or_none() + if author: + # Получаем разрешения из ролей + scopes = author.get_permissions() + + # Создаем объект авторизации + auth_cred = AuthCredentials( + 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}") + except Exception as e: + logger.error(f"[auth.authenticate] Ошибка при установке auth в request: {e}") + logger.info(f"[auth.authenticate] Успешная аутентификация пользователя {state.author_id}") return state diff --git a/auth/jwtcodec.py b/auth/jwtcodec.py index 3938bf66..1c1612c7 100644 --- a/auth/jwtcodec.py +++ b/auth/jwtcodec.py @@ -66,8 +66,11 @@ class JWTCodec: @staticmethod def decode(token: str, verify_exp: bool = True): logger.debug(f"[JWTCodec.decode] Начало декодирования токена длиной {len(token) if token else 0}") - r = None - payload = None + + if not token: + logger.error("[JWTCodec.decode] Пустой токен") + return None + try: payload = jwt.decode( token, @@ -87,25 +90,29 @@ class JWTCodec: # Добавим exp по умолчанию, чтобы избежать ошибки при создании TokenPayload payload["exp"] = int((datetime.now(tz=timezone.utc) + timedelta(days=30)).timestamp()) - r = TokenPayload(**payload) - logger.debug(f"[JWTCodec.decode] Создан объект TokenPayload: user_id={r.user_id}, username={r.username}") - - return r + try: + r = TokenPayload(**payload) + 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(f"[JWTCodec.decode] Недействительное время выпуска токена: {payload}") - raise ExpiredToken("jwt check token issued time") + logger.error("[JWTCodec.decode] Недействительное время выпуска токена") + return None except jwt.ExpiredSignatureError: - logger.error(f"[JWTCodec.decode] Истек срок действия токена: {payload}") - raise ExpiredToken("jwt check token lifetime") + logger.error("[JWTCodec.decode] Истек срок действия токена") + return None except jwt.InvalidSignatureError: logger.error("[JWTCodec.decode] Недействительная подпись токена") - raise InvalidToken("jwt check signature is not valid") + return None except jwt.InvalidTokenError: logger.error("[JWTCodec.decode] Недействительный токен") - raise InvalidToken("jwt check token is not valid") + return None except jwt.InvalidKeyError: logger.error("[JWTCodec.decode] Недействительный ключ") - raise InvalidToken("jwt check key is not valid") + return None except Exception as e: logger.error(f"[JWTCodec.decode] Неожиданная ошибка при декодировании: {e}") - raise InvalidToken(f"Ошибка декодирования: {str(e)}") + return None diff --git a/auth/orm.py b/auth/orm.py index 7057f840..7776a0a2 100644 --- a/auth/orm.py +++ b/auth/orm.py @@ -254,9 +254,12 @@ class Author(Base): # Получаем все атрибуты объекта result = {c.name: getattr(self, c.name) for c in self.__table__.columns} - # Добавляем роли, если они есть - if hasattr(self, 'roles') and self.roles: - result['roles'] = [role.id for role in self.roles] + # Добавляем роли как список идентификаторов и названий + if hasattr(self, 'roles'): + result['roles'] = [] + for role in self.roles: + if isinstance(role, dict): + result['roles'].append(role.get('id')) # скрываем защищенные поля if not access: diff --git a/auth/sessions.py b/auth/sessions.py index 386c1f9b..694ee38e 100644 --- a/auth/sessions.py +++ b/auth/sessions.py @@ -118,9 +118,18 @@ class SessionManager: Returns: Optional[TokenPayload]: Данные токена или None, если сессия недействительна """ + logger.debug(f"[SessionManager.verify_session] Проверка сессии для токена: {token[:20]}...") + # Декодируем токен для получения payload - payload = JWTCodec.decode(token) - if not 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)}") return None # Получаем данные из payload @@ -162,10 +171,34 @@ class SessionManager: 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(), + } + + # Сохраняем сессию в 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 @classmethod diff --git a/docs/auth.md b/docs/auth.md index f59df047..d87781aa 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -101,6 +101,48 @@ - Время блокировки: 30 минут - Сброс счетчика после успешного входа +## Обработка заголовков авторизации + +### Особенности работы с заголовками в Starlette + +При работе с заголовками в Starlette/FastAPI необходимо учитывать следующие особенности: + +1. **Регистр заголовков**: Заголовки в объекте `Request` чувствительны к регистру. Для надежного получения заголовка `Authorization` следует использовать регистронезависимый поиск. + +2. **Формат Bearer токена**: Токен может приходить как с префиксом `Bearer `, так и без него. Необходимо обрабатывать оба варианта. + +### Правильное получение заголовка авторизации + +```python +# Получение заголовка с учетом регистра +headers_dict = dict(req.headers.items()) +token = None + +# Ищем заголовок независимо от регистра +for header_name, header_value in headers_dict.items(): + if header_name.lower() == SESSION_TOKEN_HEADER.lower(): + token = header_value + break + +# Обработка Bearer префикса +if token and token.startswith("Bearer "): + token = token.split("Bearer ")[1].strip() +``` + +### Распространенные проблемы и их решения + +1. **Проблема**: Заголовок не находится при прямом обращении `req.headers.get("Authorization")` + **Решение**: Использовать регистронезависимый поиск по всем заголовкам + +2. **Проблема**: Токен приходит с префиксом "Bearer" в одних запросах и без него в других + **Решение**: Всегда проверять и обрабатывать оба варианта + +3. **Проблема**: Токен декодируется, но сессия не находится в Redis + **Решение**: Проверить формирование ключа сессии и добавить автоматическое создание сессии для валидных токенов + +4. **Проблема**: Ошибки при декодировании JWT вызывают исключения + **Решение**: Обернуть декодирование в try-except и возвращать None вместо вызова исключений + ## Конфигурация Основные настройки в settings.py: