INTERNAL AUTH FIX

This commit is contained in:
Untone 2025-05-21 18:29:32 +03:00
parent f6156ccfa3
commit ebf9dfcf62
6 changed files with 263 additions and 53 deletions

View File

@ -167,7 +167,9 @@ async def validate_graphql_context(info: Any) -> None:
auth_cred = request.scope.get("auth") auth_cred = request.scope.get("auth")
if isinstance(auth_cred, AuthCredentials) and auth_cred.logged_in: if isinstance(auth_cred, AuthCredentials) and auth_cred.logged_in:
logger.debug(f"[decorators] Пользователь авторизован через scope: {auth_cred.author_id}") logger.debug(f"[decorators] Пользователь авторизован через scope: {auth_cred.author_id}")
# В этом случае мы не делаем return, чтобы также проверить токен если нужно # Устанавливаем auth в request для дальнейшего использования
request.auth = auth_cred
return
# Если авторизации нет ни в auth, ни в scope, пробуем получить и проверить токен # Если авторизации нет ни в auth, ни в scope, пробуем получить и проверить токен
token = get_auth_token(request) token = get_auth_token(request)
@ -188,9 +190,29 @@ async def validate_graphql_context(info: Any) -> None:
logger.warning(f"[decorators] Недействительный токен: {error_msg}") logger.warning(f"[decorators] Недействительный токен: {error_msg}")
raise GraphQLError(f"Unauthorized - {error_msg}") raise GraphQLError(f"Unauthorized - {error_msg}")
# Если все проверки пройдены, оставляем AuthState в scope # Если все проверки пройдены, создаем AuthCredentials и устанавливаем в request.auth
# AuthenticationMiddleware извлечет нужные данные оттуда при необходимости with local_session() as session:
logger.debug(f"[decorators] Токен успешно проверен для пользователя {auth_state.author_id}") 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 return
@ -203,7 +225,7 @@ def admin_auth_required(resolver: Callable) -> Callable:
resolver: GraphQL резолвер для защиты resolver: GraphQL резолвер для защиты
Returns: Returns:
Обернутый резолвер, который проверяет права доступа Обернутый резолвер, который проверяет права доступа администратора
Raises: Raises:
GraphQLError: если пользователь не авторизован или не имеет доступа администратора GraphQLError: если пользователь не авторизован или не имеет доступа администратора
@ -216,9 +238,16 @@ def admin_auth_required(resolver: Callable) -> Callable:
@wraps(resolver) @wraps(resolver)
async def wrapper(root: Any = None, info: Any = None, **kwargs): async def wrapper(root: Any = None, info: Any = None, **kwargs):
try: try:
# Проверяем авторизацию пользователя
await validate_graphql_context(info) await validate_graphql_context(info)
# Получаем объект авторизации
auth = info.context["request"].auth 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: with local_session() as session:
try: try:
# Преобразуем author_id в int для совместимости с базой данных # Преобразуем author_id в int для совместимости с базой данных
@ -229,11 +258,20 @@ def admin_auth_required(resolver: Callable) -> Callable:
author = session.query(Author).filter(Author.id == author_id).one() author = session.query(Author).filter(Author.id == author_id).one()
# Проверяем, является ли пользователь администратором
if author.email in ADMIN_EMAILS: if author.email in ADMIN_EMAILS:
logger.info(f"Admin access granted for {author.email} (ID: {author.id})") logger.info(f"Admin access granted for {author.email} (ID: {author.id})")
return await resolver(root, info, **kwargs) 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") raise GraphQLError("Unauthorized - not an admin")
except exc.NoResultFound: except exc.NoResultFound:
logger.error(f"[admin_auth_required] Пользователь с ID {auth.author_id} не найден в базе данных") logger.error(f"[admin_auth_required] Пользователь с ID {auth.author_id} не найден в базе данных")
@ -249,8 +287,6 @@ def admin_auth_required(resolver: Callable) -> Callable:
return wrapper return wrapper
def permission_required(resource: str, operation: str, func): def permission_required(resource: str, operation: str, func):
""" """
Декоратор для проверки разрешений. Декоратор для проверки разрешений.
@ -263,43 +299,99 @@ def permission_required(resource: str, operation: str, func):
@wraps(func) @wraps(func)
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs): async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
auth: AuthCredentials = info.context["request"].auth # Сначала проверяем авторизацию
if not auth.logged_in: await validate_graphql_context(info)
raise OperationNotAllowed(auth.error_message or "Please login")
# Получаем объект авторизации
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: 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: if not author.is_active:
raise OperationNotAllowed("Account is not active") raise OperationNotAllowed("Account is not active")
if author.is_locked(): if author.is_locked():
raise OperationNotAllowed("Account is locked") raise OperationNotAllowed("Account is locked")
# Проверяем разрешение # Проверяем, является ли пользователь администратором (у них есть все разрешения)
if not author.has_permission(resource, operation): if author.email in ADMIN_EMAILS:
raise OperationNotAllowed(f"No permission for {operation} on {resource}") 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 return wrap
def login_accepted(func): def login_accepted(func):
"""
Декоратор для резолверов, которые могут работать как с авторизованными,
так и с неавторизованными пользователями.
Добавляет информацию о пользователе в контекст, если пользователь авторизован.
Args:
func: Декорируемая функция
"""
@wraps(func) @wraps(func)
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs): 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: return await func(parent, info, *args, **kwargs)
with local_session() as session: except Exception as e:
author = session.query(Author).filter(Author.id == auth.author_id).one() if not isinstance(e, GraphQLError):
info.context["author"] = author.dict() logger.error(f"[login_accepted] Ошибка: {e}")
info.context["user_id"] = author.id raise
else:
info.context["author"] = None
info.context["user_id"] = None
return await func(parent, info, *args, **kwargs)
return wrap return wrap

View File

@ -162,15 +162,20 @@ async def verify_internal_auth(token: str) -> Tuple[str, list, bool]:
Returns: Returns:
tuple: (user_id, roles, is_admin) tuple: (user_id, roles, is_admin)
""" """
logger.debug(f"[verify_internal_auth] Проверка токена: {token[:10]}...")
# Обработка формата "Bearer <token>" (если токен не был обработан ранее) # Обработка формата "Bearer <token>" (если токен не был обработан ранее)
if token.startswith("Bearer "): if token and token.startswith("Bearer "):
token = token.replace("Bearer ", "", 1).strip() token = token.replace("Bearer ", "", 1).strip()
# Проверяем сессию # Проверяем сессию
payload = await SessionManager.verify_session(token) payload = await SessionManager.verify_session(token)
if not payload: if not payload:
logger.warning("[verify_internal_auth] Недействительный токен: payload не получен")
return "", [], False return "", [], False
logger.debug(f"[verify_internal_auth] Токен действителен, user_id={payload.user_id}")
with local_session() as session: with local_session() as session:
try: try:
author = ( author = (
@ -182,12 +187,15 @@ async def verify_internal_auth(token: str) -> Tuple[str, list, bool]:
# Получаем роли # Получаем роли
roles = [role.id for role in author.roles] 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 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 return str(author.id), roles, is_admin
except exc.NoResultFound: except exc.NoResultFound:
logger.warning(f"[verify_internal_auth] Пользователь с ID {payload.user_id} не найден в БД или не активен")
return "", [], False return "", [], False
@ -284,6 +292,31 @@ async def authenticate(request: Any) -> AuthState:
state.token = token state.token = token
state.username = payload.username 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}") logger.info(f"[auth.authenticate] Успешная аутентификация пользователя {state.author_id}")
return state return state

View File

@ -66,8 +66,11 @@ class JWTCodec:
@staticmethod @staticmethod
def decode(token: str, verify_exp: bool = True): def decode(token: str, verify_exp: bool = True):
logger.debug(f"[JWTCodec.decode] Начало декодирования токена длиной {len(token) if token else 0}") 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: try:
payload = jwt.decode( payload = jwt.decode(
token, token,
@ -87,25 +90,29 @@ class JWTCodec:
# Добавим exp по умолчанию, чтобы избежать ошибки при создании TokenPayload # Добавим exp по умолчанию, чтобы избежать ошибки при создании TokenPayload
payload["exp"] = int((datetime.now(tz=timezone.utc) + timedelta(days=30)).timestamp()) payload["exp"] = int((datetime.now(tz=timezone.utc) + timedelta(days=30)).timestamp())
r = TokenPayload(**payload) try:
logger.debug(f"[JWTCodec.decode] Создан объект TokenPayload: user_id={r.user_id}, username={r.username}") r = TokenPayload(**payload)
logger.debug(f"[JWTCodec.decode] Создан объект TokenPayload: user_id={r.user_id}, username={r.username}")
return r return r
except Exception as e:
logger.error(f"[JWTCodec.decode] Ошибка при создании TokenPayload: {e}")
return None
except jwt.InvalidIssuedAtError: except jwt.InvalidIssuedAtError:
logger.error(f"[JWTCodec.decode] Недействительное время выпуска токена: {payload}") logger.error("[JWTCodec.decode] Недействительное время выпуска токена")
raise ExpiredToken("jwt check token issued time") return None
except jwt.ExpiredSignatureError: except jwt.ExpiredSignatureError:
logger.error(f"[JWTCodec.decode] Истек срок действия токена: {payload}") logger.error("[JWTCodec.decode] Истек срок действия токена")
raise ExpiredToken("jwt check token lifetime") return None
except jwt.InvalidSignatureError: except jwt.InvalidSignatureError:
logger.error("[JWTCodec.decode] Недействительная подпись токена") logger.error("[JWTCodec.decode] Недействительная подпись токена")
raise InvalidToken("jwt check signature is not valid") return None
except jwt.InvalidTokenError: except jwt.InvalidTokenError:
logger.error("[JWTCodec.decode] Недействительный токен") logger.error("[JWTCodec.decode] Недействительный токен")
raise InvalidToken("jwt check token is not valid") return None
except jwt.InvalidKeyError: except jwt.InvalidKeyError:
logger.error("[JWTCodec.decode] Недействительный ключ") logger.error("[JWTCodec.decode] Недействительный ключ")
raise InvalidToken("jwt check key is not valid") return None
except Exception as e: except Exception as e:
logger.error(f"[JWTCodec.decode] Неожиданная ошибка при декодировании: {e}") logger.error(f"[JWTCodec.decode] Неожиданная ошибка при декодировании: {e}")
raise InvalidToken(f"Ошибка декодирования: {str(e)}") return None

View File

@ -254,9 +254,12 @@ class Author(Base):
# Получаем все атрибуты объекта # Получаем все атрибуты объекта
result = {c.name: getattr(self, c.name) for c in self.__table__.columns} result = {c.name: getattr(self, c.name) for c in self.__table__.columns}
# Добавляем роли, если они есть # Добавляем роли как список идентификаторов и названий
if hasattr(self, 'roles') and self.roles: if hasattr(self, 'roles'):
result['roles'] = [role.id for role in self.roles] result['roles'] = []
for role in self.roles:
if isinstance(role, dict):
result['roles'].append(role.get('id'))
# скрываем защищенные поля # скрываем защищенные поля
if not access: if not access:

View File

@ -118,9 +118,18 @@ class SessionManager:
Returns: Returns:
Optional[TokenPayload]: Данные токена или None, если сессия недействительна Optional[TokenPayload]: Данные токена или None, если сессия недействительна
""" """
logger.debug(f"[SessionManager.verify_session] Проверка сессии для токена: {token[:20]}...")
# Декодируем токен для получения payload # Декодируем токен для получения payload
payload = JWTCodec.decode(token) try:
if not payload: 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 return None
# Получаем данные из payload # Получаем данные из payload
@ -162,10 +171,34 @@ class SessionManager:
keys = await redis.keys("session:*") keys = await redis.keys("session:*")
logger.debug(f"[SessionManager.verify_session] Все ключи сессий в Redis: {keys}") 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 # Если сессии нет, возвращаем None
return None return None
# Если сессия найдена, возвращаем payload # Если сессия найдена, возвращаем payload
logger.debug(f"[SessionManager.verify_session] Сессия найдена для пользователя {user_id}")
return payload return payload
@classmethod @classmethod

View File

@ -101,6 +101,48 @@
- Время блокировки: 30 минут - Время блокировки: 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: Основные настройки в settings.py: