circular-fix
Some checks failed
Deploy on push / deploy (push) Failing after 17s

This commit is contained in:
2025-08-17 16:33:54 +03:00
parent bc8447a444
commit e78e12eeee
65 changed files with 3304 additions and 1051 deletions

View File

@@ -1,7 +1,8 @@
from starlette.requests import Request
from starlette.responses import JSONResponse, RedirectResponse, Response
from auth.internal import verify_internal_auth
# Импорт базовых функций из реструктурированных модулей
from auth.core import verify_internal_auth
from auth.orm import Author
from auth.tokens.storage import TokenStorage
from services.db import local_session

149
auth/core.py Normal file
View File

@@ -0,0 +1,149 @@
"""
Базовые функции аутентификации и верификации
Этот модуль содержит основные функции без циклических зависимостей
"""
import time
from sqlalchemy.orm.exc import NoResultFound
from auth.state import AuthState
from auth.tokens.storage import TokenStorage as TokenManager
from auth.orm import Author
from orm.community import CommunityAuthor
from services.db import local_session
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
from utils.logger import root_logger as logger
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
async def verify_internal_auth(token: str) -> tuple[int, list, bool]:
"""
Проверяет локальную авторизацию.
Возвращает user_id, список ролей и флаг администратора.
Args:
token: Токен авторизации (может быть как с Bearer, так и без)
Returns:
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()
# Проверяем сессию
payload = await TokenManager.verify_session(token)
if not payload:
logger.warning("[verify_internal_auth] Недействительный токен: payload не получен")
return 0, [], False
# payload может быть словарем или объектом, обрабатываем оба случая
user_id = payload.user_id if hasattr(payload, "user_id") else payload.get("user_id")
if not user_id:
logger.warning("[verify_internal_auth] user_id не найден в payload")
return 0, [], False
logger.debug(f"[verify_internal_auth] Токен действителен, user_id={user_id}")
with local_session() as session:
try:
# Author уже импортирован в начале файла
author = session.query(Author).where(Author.id == user_id).one()
# Получаем роли
ca = session.query(CommunityAuthor).filter_by(author_id=author.id, community_id=1).first()
roles = ca.role_list if ca else []
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 int(author.id), roles, is_admin
except NoResultFound:
logger.warning(f"[verify_internal_auth] Пользователь с ID {user_id} не найден в БД или не активен")
return 0, [], False
async def create_internal_session(author, device_info: dict | None = None) -> str:
"""
Создает новую сессию для автора
Args:
author: Объект автора
device_info: Информация об устройстве (опционально)
Returns:
str: Токен сессии
"""
# Сбрасываем счетчик неудачных попыток
author.reset_failed_login()
# Обновляем last_seen
author.last_seen = int(time.time()) # type: ignore[assignment]
# Создаем сессию, используя token для идентификации
return await TokenManager.create_session(
user_id=str(author.id),
username=str(author.slug or author.email or author.phone or ""),
device_info=device_info,
)
async def get_auth_token_from_request(request) -> str | None:
"""
Извлекает токен авторизации из запроса.
Порядок проверки:
1. Проверяет auth из middleware
2. Проверяет auth из scope
3. Проверяет заголовок Authorization
4. Проверяет cookie с именем auth_token
Args:
request: Объект запроса
Returns:
Optional[str]: Токен авторизации или None
"""
# Отложенный импорт для избежания циклических зависимостей
from auth.decorators import get_auth_token
return await get_auth_token(request)
async def authenticate(request) -> AuthState:
"""
Получает токен из запроса и проверяет авторизацию.
Args:
request: Объект запроса
Returns:
AuthState: Состояние аутентификации
"""
logger.debug("[authenticate] Начало аутентификации")
# Получаем токен из запроса используя безопасный метод
token = await get_auth_token_from_request(request)
if not token:
logger.info("[authenticate] Токен не найден в запросе")
return AuthState()
# Проверяем токен используя internal auth
user_id, roles, is_admin = await verify_internal_auth(token)
if not user_id:
logger.warning("[authenticate] Недействительный токен")
return AuthState()
logger.debug(f"[authenticate] Аутентификация успешна: user_id={user_id}, roles={roles}, is_admin={is_admin}")
auth_state = AuthState()
auth_state.logged_in = True
auth_state.author_id = str(user_id)
auth_state.is_admin = is_admin
return auth_state

View File

@@ -1,4 +1,4 @@
from typing import Any, Optional
from typing import Any
from pydantic import BaseModel, Field
@@ -24,12 +24,12 @@ class AuthCredentials(BaseModel):
Используется как часть механизма аутентификации Starlette.
"""
author_id: Optional[int] = Field(None, description="ID автора")
author_id: int | None = Field(None, description="ID автора")
scopes: dict[str, set[str]] = Field(default_factory=dict, description="Разрешения пользователя")
logged_in: bool = Field(default=False, description="Флаг, указывающий, авторизован ли пользователь")
error_message: str = Field("", description="Сообщение об ошибке аутентификации")
email: Optional[str] = Field(None, description="Email пользователя")
token: Optional[str] = Field(None, description="JWT токен авторизации")
email: str | None = Field(None, description="Email пользователя")
token: str | None = Field(None, description="JWT токен авторизации")
def get_permissions(self) -> list[str]:
"""

View File

@@ -1,200 +1,30 @@
from collections.abc import Callable
from functools import wraps
from typing import Any, Optional
from typing import Any
from graphql import GraphQLError, GraphQLResolveInfo
from sqlalchemy import exc
from auth.credentials import AuthCredentials
from auth.exceptions import OperationNotAllowedError
from auth.internal import authenticate
# Импорт базовых функций из реструктурированных модулей
from auth.core import authenticate
from auth.utils import get_auth_token
from auth.orm import Author
from orm.community import CommunityAuthor
from services.db import local_session
from services.redis import redis as redis_adapter
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(",")
def get_safe_headers(request: Any) -> dict[str, str]:
"""
Безопасно получает заголовки запроса.
Args:
request: Объект запроса
Returns:
Dict[str, str]: Словарь заголовков
"""
headers = {}
try:
# Первый приоритет: scope из ASGI (самый надежный источник)
if hasattr(request, "scope") and isinstance(request.scope, dict):
scope_headers = request.scope.get("headers", [])
if scope_headers:
headers.update({k.decode("utf-8").lower(): v.decode("utf-8") for k, v in scope_headers})
logger.debug(f"[decorators] Получены заголовки из request.scope: {len(headers)}")
logger.debug(f"[decorators] Заголовки из request.scope: {list(headers.keys())}")
# Второй приоритет: метод 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"[decorators] Ошибка при доступе к заголовкам: {e}")
return headers
# Импортируем get_safe_headers из utils
from auth.utils import get_safe_headers
async def get_auth_token(request: Any) -> Optional[str]:
"""
Извлекает токен авторизации из запроса.
Порядок проверки:
1. Проверяет auth из middleware
2. Проверяет auth из scope
3. Проверяет заголовок Authorization
4. Проверяет cookie с именем auth_token
Args:
request: Объект запроса
Returns:
Optional[str]: Токен авторизации или None
"""
try:
# 1. Проверяем auth из middleware (если middleware уже обработал токен)
if hasattr(request, "auth") and request.auth:
token = getattr(request.auth, "token", None)
if token:
token_len = len(token) if hasattr(token, "__len__") else "unknown"
logger.debug(f"[decorators] Токен получен из request.auth: {token_len}")
return token
logger.debug("[decorators] request.auth есть, но token НЕ найден")
else:
logger.debug("[decorators] request.auth НЕ найден")
# 2. Проверяем наличие auth_token в scope (приоритет)
if hasattr(request, "scope") and isinstance(request.scope, dict) and "auth_token" in request.scope:
token = request.scope.get("auth_token")
if token is not None:
token_len = len(token) if hasattr(token, "__len__") else "unknown"
logger.debug(f"[decorators] Токен получен из request.scope['auth_token']: {token_len}")
return token
logger.debug("[decorators] request.scope['auth_token'] НЕ найден")
# Стандартная система сессий уже обрабатывает кэширование
# Дополнительной проверки Redis кэша не требуется
# Отладка: детальная информация о запросе без токена в декораторе
if not token:
logger.warning(f"[decorators] ДЕКОРАТОР: ЗАПРОС БЕЗ ТОКЕНА: {request.method} {request.url.path}")
logger.warning(f"[decorators] User-Agent: {request.headers.get('user-agent', 'НЕ НАЙДЕН')}")
logger.warning(f"[decorators] Referer: {request.headers.get('referer', 'НЕ НАЙДЕН')}")
logger.warning(f"[decorators] Origin: {request.headers.get('origin', 'НЕ НАЙДЕН')}")
logger.warning(f"[decorators] Content-Type: {request.headers.get('content-type', 'НЕ НАЙДЕН')}")
logger.warning(f"[decorators] Все заголовки: {list(request.headers.keys())}")
# Проверяем, есть ли активные сессии в Redis
try:
from services.redis import redis as redis_adapter
# Получаем все активные сессии
session_keys = await redis_adapter.keys("session:*")
logger.debug(f"[decorators] Найдено активных сессий в Redis: {len(session_keys)}")
if session_keys:
# Пытаемся найти токен через активные сессии
for session_key in session_keys[:3]: # Проверяем первые 3 сессии
try:
session_data = await redis_adapter.hgetall(session_key)
if session_data:
logger.debug(f"[decorators] Найдена активная сессия: {session_key}")
# Извлекаем user_id из ключа сессии
user_id = (
session_key.decode("utf-8").split(":")[1]
if isinstance(session_key, bytes)
else session_key.split(":")[1]
)
logger.debug(f"[decorators] User ID из сессии: {user_id}")
break
except Exception as e:
logger.debug(f"[decorators] Ошибка чтения сессии {session_key}: {e}")
else:
logger.debug("[decorators] Активных сессий в Redis не найдено")
except Exception as e:
logger.debug(f"[decorators] Ошибка проверки сессий: {e}")
# 3. Проверяем наличие 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.get("token")
if token is not None:
token_len = len(token) if hasattr(token, "__len__") else "unknown"
logger.debug(f"[decorators] Токен получен из request.scope['auth']: {token_len}")
return token
# 4. Проверяем заголовок Authorization
headers = get_safe_headers(request)
# Сначала проверяем основной заголовок авторизации
auth_header = headers.get(SESSION_TOKEN_HEADER.lower(), "")
if auth_header:
if auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
token_len = len(token) if hasattr(token, "__len__") else "unknown"
logger.debug(f"[decorators] Токен получен из заголовка {SESSION_TOKEN_HEADER}: {token_len}")
return token
token = auth_header.strip()
if token:
token_len = len(token) if hasattr(token, "__len__") else "unknown"
logger.debug(f"[decorators] Прямой токен получен из заголовка {SESSION_TOKEN_HEADER}: {token_len}")
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()
if token:
token_len = len(token) if hasattr(token, "__len__") else "unknown"
logger.debug(f"[decorators] Токен получен из заголовка Authorization: {token_len}")
return token
# 5. Проверяем cookie
if hasattr(request, "cookies") and request.cookies:
token = request.cookies.get(SESSION_COOKIE_NAME)
if token:
token_len = len(token) if hasattr(token, "__len__") else "unknown"
logger.debug(f"[decorators] Токен получен из cookie {SESSION_COOKIE_NAME}: {token_len}")
return token
# Если токен не найден ни в одном из мест
logger.debug("[decorators] Токен авторизации не найден")
return None
except Exception as e:
logger.warning(f"[decorators] Ошибка при извлечении токена: {e}")
return None
# get_auth_token теперь импортирован из auth.utils
async def validate_graphql_context(info: GraphQLResolveInfo) -> None:
@@ -236,7 +66,7 @@ async def validate_graphql_context(info: GraphQLResolveInfo) -> None:
return
# Если аутентификации нет в request.auth, пробуем получить ее из scope
token: Optional[str] = None
token: str | None = None
if hasattr(request, "scope") and "auth" in request.scope:
auth_cred = request.scope.get("auth")
if isinstance(auth_cred, AuthCredentials) and getattr(auth_cred, "logged_in", False):
@@ -337,7 +167,7 @@ def admin_auth_required(resolver: Callable) -> Callable:
"""
@wraps(resolver)
async def wrapper(root: Any = None, info: Optional[GraphQLResolveInfo] = None, **kwargs: dict[str, Any]) -> Any:
async def wrapper(root: Any = None, info: GraphQLResolveInfo | None = None, **kwargs: dict[str, Any]) -> Any:
# Подробное логирование для диагностики
logger.debug(f"[admin_auth_required] Начало проверки авторизации для {resolver.__name__}")
@@ -483,7 +313,7 @@ def permission_required(resource: str, operation: str, func: Callable) -> Callab
f"[permission_required] Пользователь с ролью администратора {author.email} имеет все разрешения"
)
return await func(parent, info, *args, **kwargs)
if not ca or not ca.has_permission(resource, operation):
if not ca or not ca.has_permission(f"{resource}:{operation}"):
logger.warning(
f"[permission_required] У пользователя {author.email} нет разрешения {operation} на {resource}"
)

View File

@@ -70,7 +70,7 @@ class EnhancedGraphQLHTTPHandler(GraphQLHTTPHandler):
logger.debug(f"[graphql] Добавлены данные авторизации в контекст из scope: {type(auth_cred).__name__}")
# Проверяем, есть ли токен в auth_cred
if auth_cred is not None and hasattr(auth_cred, "token") and getattr(auth_cred, "token"):
if auth_cred is not None and hasattr(auth_cred, "token") and auth_cred.token:
token_val = auth_cred.token
token_len = len(token_val) if hasattr(token_val, "__len__") else 0
logger.debug(f"[graphql] Токен найден в auth_cred: {token_len}")
@@ -79,7 +79,7 @@ class EnhancedGraphQLHTTPHandler(GraphQLHTTPHandler):
# Добавляем author_id в контекст для RBAC
author_id = None
if auth_cred is not None and hasattr(auth_cred, "author_id") and getattr(auth_cred, "author_id"):
if auth_cred is not None and hasattr(auth_cred, "author_id") and auth_cred.author_id:
author_id = auth_cred.author_id
elif isinstance(auth_cred, dict) and "author_id" in auth_cred:
author_id = auth_cred["author_id"]

View File

@@ -1,17 +1,14 @@
from typing import TYPE_CHECKING, Any, TypeVar
from typing import Any, TypeVar
from auth.exceptions import ExpiredTokenError, InvalidPasswordError, InvalidTokenError
from auth.jwtcodec import JWTCodec
from auth.orm import Author
from auth.password import Password
from services.db import local_session
from services.redis import redis
from utils.logger import root_logger as logger
# Для типизации
if TYPE_CHECKING:
from auth.orm import Author
AuthorType = TypeVar("AuthorType", bound="Author")
AuthorType = TypeVar("AuthorType", bound=Author)
class Identity:
@@ -57,8 +54,7 @@ class Identity:
Returns:
Author: Объект пользователя
"""
# Поздний импорт для избежания циклических зависимостей
from auth.orm import Author
# Author уже импортирован в начале файла
with local_session() as session:
author = session.query(Author).where(Author.email == inp["email"]).first()
@@ -101,9 +97,7 @@ class Identity:
return {"error": "Token not found"}
# Если все проверки пройдены, ищем автора в базе данных
# Поздний импорт для избежания циклических зависимостей
from auth.orm import Author
# Author уже импортирован в начале файла
with local_session() as session:
author = session.query(Author).filter_by(id=user_id).first()
if not author:

View File

@@ -1,153 +1,13 @@
"""
Утилитные функции для внутренней аутентификации
Используются в GraphQL резолверах и декораторах
DEPRECATED: Этот модуль переносится в auth/core.py
Импорты оставлены для обратной совместимости
"""
import time
from typing import Optional
# Импорт базовых функций из core модуля
from auth.core import verify_internal_auth, create_internal_session, authenticate
from sqlalchemy.orm.exc import NoResultFound
from auth.orm import Author
from auth.state import AuthState
from auth.tokens.storage import TokenStorage as TokenManager
from services.db import local_session
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
from utils.logger import root_logger as logger
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
async def verify_internal_auth(token: str) -> tuple[int, list, bool]:
"""
Проверяет локальную авторизацию.
Возвращает user_id, список ролей и флаг администратора.
Args:
token: Токен авторизации (может быть как с Bearer, так и без)
Returns:
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()
# Проверяем сессию
payload = await TokenManager.verify_session(token)
if not payload:
logger.warning("[verify_internal_auth] Недействительный токен: payload не получен")
return 0, [], False
# payload может быть словарем или объектом, обрабатываем оба случая
user_id = payload.user_id if hasattr(payload, "user_id") else payload.get("user_id")
if not user_id:
logger.warning("[verify_internal_auth] user_id не найден в payload")
return 0, [], False
logger.debug(f"[verify_internal_auth] Токен действителен, user_id={user_id}")
with local_session() as session:
try:
author = session.query(Author).where(Author.id == user_id).one()
# Получаем роли
from orm.community import CommunityAuthor
ca = session.query(CommunityAuthor).filter_by(author_id=author.id, community_id=1).first()
roles = ca.role_list if ca else []
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 int(author.id), roles, is_admin
except NoResultFound:
logger.warning(f"[verify_internal_auth] Пользователь с ID {user_id} не найден в БД или не активен")
return 0, [], False
async def create_internal_session(author: Author, device_info: Optional[dict] = None) -> str:
"""
Создает новую сессию для автора
Args:
author: Объект автора
device_info: Информация об устройстве (опционально)
Returns:
str: Токен сессии
"""
# Сбрасываем счетчик неудачных попыток
author.reset_failed_login()
# Обновляем last_seen
author.last_seen = int(time.time()) # type: ignore[assignment]
# Создаем сессию, используя token для идентификации
return await TokenManager.create_session(
user_id=str(author.id),
username=str(author.slug or author.email or author.phone or ""),
device_info=device_info,
)
async def authenticate(request) -> AuthState:
"""
Аутентифицирует пользователя по токену из запроса.
Args:
request: Объект запроса
Returns:
AuthState: Состояние аутентификации
"""
logger.debug("[authenticate] Начало аутентификации")
# Создаем объект AuthState
auth_state = AuthState()
auth_state.logged_in = False
auth_state.author_id = None
auth_state.error = None
auth_state.token = None
# Получаем токен из запроса используя безопасный метод
from auth.decorators import get_auth_token
token = await get_auth_token(request)
if not token:
logger.info("[authenticate] Токен не найден в запросе")
auth_state.error = "No authentication token"
return auth_state
# Обработка формата "Bearer <token>" (если токен не был обработан ранее)
if token and token.startswith("Bearer "):
token = token.replace("Bearer ", "", 1).strip()
logger.debug(f"[authenticate] Токен найден, длина: {len(token)}")
# Проверяем токен
try:
# Используем TokenManager вместо прямого создания SessionTokenManager
auth_result = await TokenManager.verify_session(token)
if auth_result and hasattr(auth_result, "user_id") and auth_result.user_id:
logger.debug(f"[authenticate] Успешная аутентификация, user_id: {auth_result.user_id}")
auth_state.logged_in = True
auth_state.author_id = auth_result.user_id
auth_state.token = token
return auth_state
error_msg = "Invalid or expired token"
logger.warning(f"[authenticate] Недействительный токен: {error_msg}")
auth_state.error = error_msg
return auth_state
except Exception as e:
logger.error(f"[authenticate] Ошибка при проверке токена: {e}")
auth_state.error = f"Authentication error: {e!s}"
return auth_state
# Re-export для обратной совместимости
__all__ = ["verify_internal_auth", "create_internal_session", "authenticate"]

View File

@@ -1,6 +1,6 @@
import datetime
import logging
from typing import Any, Dict, Optional
from typing import Any, Dict
import jwt
@@ -15,9 +15,9 @@ class JWTCodec:
@staticmethod
def encode(
payload: Dict[str, Any],
secret_key: Optional[str] = None,
algorithm: Optional[str] = None,
expiration: Optional[datetime.datetime] = None,
secret_key: str | None = None,
algorithm: str | None = None,
expiration: datetime.datetime | None = None,
) -> str | bytes:
"""
Кодирует payload в JWT токен.
@@ -40,14 +40,14 @@ class JWTCodec:
# Если время истечения не указано, устанавливаем дефолтное
if not expiration:
expiration = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(
expiration = datetime.datetime.now(datetime.UTC) + datetime.timedelta(
days=JWT_REFRESH_TOKEN_EXPIRE_DAYS
)
logger.debug(f"[JWTCodec.encode] Время истечения не указано, устанавливаем срок: {expiration}")
# Формируем payload с временными метками
payload.update(
{"exp": int(expiration.timestamp()), "iat": datetime.datetime.now(datetime.timezone.utc), "iss": JWT_ISSUER}
{"exp": int(expiration.timestamp()), "iat": datetime.datetime.now(datetime.UTC), "iss": JWT_ISSUER}
)
logger.debug(f"[JWTCodec.encode] Сформирован payload: {payload}")
@@ -55,8 +55,7 @@ class JWTCodec:
try:
# Используем PyJWT для кодирования
encoded = jwt.encode(payload, secret_key, algorithm=algorithm)
token_str = encoded.decode("utf-8") if isinstance(encoded, bytes) else encoded
return token_str
return encoded.decode("utf-8") if isinstance(encoded, bytes) else encoded
except Exception as e:
logger.warning(f"[JWTCodec.encode] Ошибка при кодировании JWT: {e}")
raise
@@ -64,8 +63,8 @@ class JWTCodec:
@staticmethod
def decode(
token: str,
secret_key: Optional[str] = None,
algorithms: Optional[list] = None,
secret_key: str | None = None,
algorithms: list | None = None,
) -> Dict[str, Any]:
"""
Декодирует JWT токен.
@@ -87,8 +86,7 @@ class JWTCodec:
try:
# Используем PyJWT для декодирования
decoded = jwt.decode(token, secret_key, algorithms=algorithms)
return decoded
return jwt.decode(token, secret_key, algorithms=algorithms)
except jwt.ExpiredSignatureError:
logger.warning("[JWTCodec.decode] Токен просрочен")
raise

View File

@@ -5,7 +5,7 @@
import json
import time
from collections.abc import Awaitable, MutableMapping
from typing import Any, Callable, Optional
from typing import Any, Callable
from graphql import GraphQLResolveInfo
from sqlalchemy.orm import exc
@@ -18,6 +18,7 @@ from auth.credentials import AuthCredentials
from auth.orm import Author
from auth.tokens.storage import TokenStorage as TokenManager
from services.db import local_session
from services.redis import redis as redis_adapter
from settings import (
ADMIN_EMAILS as ADMIN_EMAILS_LIST,
)
@@ -41,9 +42,9 @@ class AuthenticatedUser:
self,
user_id: str,
username: str = "",
roles: Optional[list] = None,
permissions: Optional[dict] = None,
token: Optional[str] = None,
roles: list | None = None,
permissions: dict | None = None,
token: str | None = None,
) -> None:
self.user_id = user_id
self.username = username
@@ -254,8 +255,6 @@ class AuthMiddleware:
# Проверяем, есть ли активные сессии в Redis
try:
from services.redis import redis as redis_adapter
# Получаем все активные сессии
session_keys = await redis_adapter.keys("session:*")
logger.debug(f"[middleware] Найдено активных сессий в Redis: {len(session_keys)}")
@@ -457,7 +456,7 @@ class AuthMiddleware:
if isinstance(result, JSONResponse):
try:
body_content = result.body
if isinstance(body_content, (bytes, memoryview)):
if isinstance(body_content, bytes | memoryview):
body_text = bytes(body_content).decode("utf-8")
result_data = json.loads(body_text)
else:

View File

@@ -1,6 +1,6 @@
import time
from secrets import token_urlsafe
from typing import Any, Callable, Optional
from typing import Any, Callable
import orjson
from authlib.integrations.starlette_client import OAuth
@@ -395,7 +395,7 @@ async def store_oauth_state(state: str, data: dict) -> None:
await redis.execute("SETEX", key, OAUTH_STATE_TTL, orjson.dumps(data))
async def get_oauth_state(state: str) -> Optional[dict]:
async def get_oauth_state(state: str) -> dict | None:
"""Получает и удаляет OAuth состояние из Redis (one-time use)"""
key = f"oauth_state:{state}"
data = await redis.execute("GET", key)

View File

@@ -166,7 +166,7 @@ class Author(Base):
return author
return None
def set_oauth_account(self, provider: str, provider_id: str, email: Optional[str] = None) -> None:
def set_oauth_account(self, provider: str, provider_id: str, email: str | None = None) -> None:
"""
Устанавливает OAuth аккаунт для автора
@@ -184,7 +184,7 @@ class Author(Base):
self.oauth[provider] = oauth_data # type: ignore[index]
def get_oauth_account(self, provider: str) -> Optional[Dict[str, Any]]:
def get_oauth_account(self, provider: str) -> Dict[str, Any] | None:
"""
Получает OAuth аккаунт провайдера

80
auth/rbac_interface.py Normal file
View File

@@ -0,0 +1,80 @@
"""
Интерфейс для RBAC операций, исключающий циркулярные импорты.
Этот модуль содержит только типы и абстрактные интерфейсы,
не импортирует ORM модели и не создает циклических зависимостей.
"""
from abc import ABC, abstractmethod
from typing import Any, Protocol
class RBACOperations(Protocol):
"""
Протокол для RBAC операций, позволяющий ORM моделям
выполнять операции с правами без прямого импорта services.rbac
"""
async def get_permissions_for_role(self, role: str, community_id: int) -> list[str]:
"""Получает разрешения для роли в сообществе"""
...
async def initialize_community_permissions(self, community_id: int) -> None:
"""Инициализирует права для нового сообщества"""
...
async def user_has_permission(
self, author_id: int, permission: str, community_id: int, session: Any = None
) -> bool:
"""Проверяет разрешение пользователя в сообществе"""
...
async def _roles_have_permission(
self, role_slugs: list[str], permission: str, community_id: int
) -> bool:
"""Проверяет, есть ли у набора ролей конкретное разрешение в сообществе"""
...
class CommunityAuthorQueries(Protocol):
"""
Протокол для запросов CommunityAuthor, позволяющий RBAC
выполнять запросы без прямого импорта ORM моделей
"""
def get_user_roles_in_community(
self, author_id: int, community_id: int, session: Any = None
) -> list[str]:
"""Получает роли пользователя в сообществе"""
...
# Глобальные переменные для dependency injection
_rbac_operations: RBACOperations | None = None
_community_queries: CommunityAuthorQueries | None = None
def set_rbac_operations(ops: RBACOperations) -> None:
"""Устанавливает реализацию RBAC операций"""
global _rbac_operations
_rbac_operations = ops
def set_community_queries(queries: CommunityAuthorQueries) -> None:
"""Устанавливает реализацию запросов сообщества"""
global _community_queries
_community_queries = queries
def get_rbac_operations() -> RBACOperations:
"""Получает реализацию RBAC операций"""
if _rbac_operations is None:
raise RuntimeError("RBAC operations не инициализированы. Вызовите set_rbac_operations()")
return _rbac_operations
def get_community_queries() -> CommunityAuthorQueries:
"""Получает реализацию запросов сообщества"""
if _community_queries is None:
raise RuntimeError("Community queries не инициализированы. Вызовите set_community_queries()")
return _community_queries

View File

@@ -2,7 +2,6 @@
Классы состояния авторизации
"""
from typing import Optional
class AuthState:
@@ -13,12 +12,12 @@ class AuthState:
def __init__(self) -> None:
self.logged_in: bool = False
self.author_id: Optional[str] = None
self.token: Optional[str] = None
self.username: Optional[str] = None
self.author_id: str | None = None
self.token: str | None = None
self.username: str | None = None
self.is_admin: bool = False
self.is_editor: bool = False
self.error: Optional[str] = None
self.error: str | None = None
def __bool__(self) -> bool:
"""Возвращает True если пользователь авторизован"""

View File

@@ -4,7 +4,6 @@
import secrets
from functools import lru_cache
from typing import Optional
from .types import TokenType
@@ -16,7 +15,7 @@ class BaseTokenManager:
@staticmethod
@lru_cache(maxsize=1000)
def _make_token_key(token_type: TokenType, identifier: str, token: Optional[str] = None) -> str:
def _make_token_key(token_type: TokenType, identifier: str, token: str | None = None) -> str:
"""
Создает унифицированный ключ для токена с кэшированием

View File

@@ -3,7 +3,7 @@
"""
import asyncio
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
from auth.jwtcodec import JWTCodec
from services.redis import redis as redis_adapter
@@ -54,7 +54,7 @@ class BatchTokenOperations(BaseTokenManager):
token_keys = []
valid_tokens = []
for token, payload in zip(token_batch, decoded_payloads):
for token, payload in zip(token_batch, decoded_payloads, strict=False):
if isinstance(payload, Exception) or payload is None:
results[token] = False
continue
@@ -80,12 +80,12 @@ class BatchTokenOperations(BaseTokenManager):
await pipe.exists(key)
existence_results = await pipe.execute()
for token, exists in zip(valid_tokens, existence_results):
for token, exists in zip(valid_tokens, existence_results, strict=False):
results[token] = bool(exists)
return results
async def _safe_decode_token(self, token: str) -> Optional[Any]:
async def _safe_decode_token(self, token: str) -> Any | None:
"""Безопасное декодирование токена"""
try:
return JWTCodec.decode(token)
@@ -190,7 +190,7 @@ class BatchTokenOperations(BaseTokenManager):
await pipe.exists(session_key)
results = await pipe.execute()
for token, exists in zip(tokens, results):
for token, exists in zip(tokens, results, strict=False):
if exists:
active_tokens.append(token)
else:

View File

@@ -48,7 +48,7 @@ class TokenMonitoring(BaseTokenManager):
count_tasks = [self._count_keys_by_pattern(pattern) for pattern in patterns.values()]
counts = await asyncio.gather(*count_tasks)
for (stat_name, _), count in zip(patterns.items(), counts):
for (stat_name, _), count in zip(patterns.items(), counts, strict=False):
stats[stat_name] = count
# Получаем информацию о памяти Redis

View File

@@ -4,7 +4,6 @@
import json
import time
from typing import Optional
from services.redis import redis as redis_adapter
from utils.logger import root_logger as logger
@@ -23,9 +22,9 @@ class OAuthTokenManager(BaseTokenManager):
user_id: str,
provider: str,
access_token: str,
refresh_token: Optional[str] = None,
expires_in: Optional[int] = None,
additional_data: Optional[TokenData] = None,
refresh_token: str | None = None,
expires_in: int | None = None,
additional_data: TokenData | None = None,
) -> bool:
"""Сохраняет OAuth токены"""
try:
@@ -79,7 +78,7 @@ class OAuthTokenManager(BaseTokenManager):
logger.info(f"Создан {token_type} токен для пользователя {user_id}, провайдер {provider}")
return token_key
async def get_token(self, user_id: int, provider: str, token_type: TokenType) -> Optional[TokenData]:
async def get_token(self, user_id: int, provider: str, token_type: TokenType) -> TokenData | None:
"""Получает токен"""
if token_type.startswith("oauth_"):
return await self._get_oauth_data_optimized(token_type, str(user_id), provider)
@@ -87,7 +86,7 @@ class OAuthTokenManager(BaseTokenManager):
async def _get_oauth_data_optimized(
self, token_type: TokenType, user_id: str, provider: str
) -> Optional[TokenData]:
) -> TokenData | None:
"""Оптимизированное получение OAuth данных"""
if not user_id or not provider:
error_msg = "OAuth токены требуют user_id и provider"

View File

@@ -4,7 +4,7 @@
import json
import time
from typing import Any, List, Optional, Union
from typing import Any, List
from auth.jwtcodec import JWTCodec
from services.redis import redis as redis_adapter
@@ -22,9 +22,9 @@ class SessionTokenManager(BaseTokenManager):
async def create_session(
self,
user_id: str,
auth_data: Optional[dict] = None,
username: Optional[str] = None,
device_info: Optional[dict] = None,
auth_data: dict | None = None,
username: str | None = None,
device_info: dict | None = None,
) -> str:
"""Создает токен сессии"""
session_data = {}
@@ -75,7 +75,7 @@ class SessionTokenManager(BaseTokenManager):
logger.info(f"Создан токен сессии для пользователя {user_id}")
return session_token
async def get_session_data(self, token: str, user_id: Optional[str] = None) -> Optional[TokenData]:
async def get_session_data(self, token: str, user_id: str | None = None) -> TokenData | None:
"""Получение данных сессии"""
if not user_id:
# Извлекаем user_id из JWT
@@ -97,7 +97,7 @@ class SessionTokenManager(BaseTokenManager):
token_data = results[0] if results else None
return dict(token_data) if token_data else None
async def validate_session_token(self, token: str) -> tuple[bool, Optional[TokenData]]:
async def validate_session_token(self, token: str) -> tuple[bool, TokenData | None]:
"""
Проверяет валидность токена сессии
"""
@@ -163,7 +163,7 @@ class SessionTokenManager(BaseTokenManager):
return len(tokens)
async def get_user_sessions(self, user_id: Union[int, str]) -> List[TokenData]:
async def get_user_sessions(self, user_id: int | str) -> List[TokenData]:
"""Получение сессий пользователя"""
try:
user_tokens_key = self._make_user_tokens_key(str(user_id), "session")
@@ -180,7 +180,7 @@ class SessionTokenManager(BaseTokenManager):
await pipe.hgetall(self._make_token_key("session", str(user_id), token_str))
results = await pipe.execute()
for token, session_data in zip(tokens, results):
for token, session_data in zip(tokens, results, strict=False):
if session_data:
token_str = token if isinstance(token, str) else str(token)
session_dict = dict(session_data)
@@ -193,7 +193,7 @@ class SessionTokenManager(BaseTokenManager):
logger.error(f"Ошибка получения сессий пользователя: {e}")
return []
async def refresh_session(self, user_id: int, old_token: str, device_info: Optional[dict] = None) -> Optional[str]:
async def refresh_session(self, user_id: int, old_token: str, device_info: dict | None = None) -> str | None:
"""
Обновляет сессию пользователя, заменяя старый токен новым
"""
@@ -226,7 +226,7 @@ class SessionTokenManager(BaseTokenManager):
logger.error(f"Ошибка обновления сессии: {e}")
return None
async def verify_session(self, token: str) -> Optional[Any]:
async def verify_session(self, token: str) -> Any | None:
"""
Проверяет сессию по токену для совместимости с TokenStorage
"""

View File

@@ -2,7 +2,7 @@
Простой интерфейс для системы токенов
"""
from typing import Any, Optional
from typing import Any
from .batch import BatchTokenOperations
from .monitoring import TokenMonitoring
@@ -29,18 +29,18 @@ class _TokenStorageImpl:
async def create_session(
self,
user_id: str,
auth_data: Optional[dict] = None,
username: Optional[str] = None,
device_info: Optional[dict] = None,
auth_data: dict | None = None,
username: str | None = None,
device_info: dict | None = None,
) -> str:
"""Создание сессии пользователя"""
return await self._sessions.create_session(user_id, auth_data, username, device_info)
async def verify_session(self, token: str) -> Optional[Any]:
async def verify_session(self, token: str) -> Any | None:
"""Проверка сессии по токену"""
return await self._sessions.verify_session(token)
async def refresh_session(self, user_id: int, old_token: str, device_info: Optional[dict] = None) -> Optional[str]:
async def refresh_session(self, user_id: int, old_token: str, device_info: dict | None = None) -> str | None:
"""Обновление сессии пользователя"""
return await self._sessions.refresh_session(user_id, old_token, device_info)
@@ -76,20 +76,20 @@ class TokenStorage:
@staticmethod
async def create_session(
user_id: str,
auth_data: Optional[dict] = None,
username: Optional[str] = None,
device_info: Optional[dict] = None,
auth_data: dict | None = None,
username: str | None = None,
device_info: dict | None = None,
) -> str:
"""Создание сессии пользователя"""
return await _token_storage.create_session(user_id, auth_data, username, device_info)
@staticmethod
async def verify_session(token: str) -> Optional[Any]:
async def verify_session(token: str) -> Any | None:
"""Проверка сессии по токену"""
return await _token_storage.verify_session(token)
@staticmethod
async def refresh_session(user_id: int, old_token: str, device_info: Optional[dict] = None) -> Optional[str]:
async def refresh_session(user_id: int, old_token: str, device_info: dict | None = None) -> str | None:
"""Обновление сессии пользователя"""
return await _token_storage.refresh_session(user_id, old_token, device_info)

View File

@@ -5,7 +5,6 @@
import json
import secrets
import time
from typing import Optional
from services.redis import redis as redis_adapter
from utils.logger import root_logger as logger
@@ -24,7 +23,7 @@ class VerificationTokenManager(BaseTokenManager):
user_id: str,
verification_type: str,
data: TokenData,
ttl: Optional[int] = None,
ttl: int | None = None,
) -> str:
"""Создает токен подтверждения"""
token_data = {"verification_type": verification_type, **data}
@@ -41,7 +40,7 @@ class VerificationTokenManager(BaseTokenManager):
return await self._create_verification_token(user_id, token_data, ttl)
async def _create_verification_token(
self, user_id: str, token_data: TokenData, ttl: int, token: Optional[str] = None
self, user_id: str, token_data: TokenData, ttl: int, token: str | None = None
) -> str:
"""Оптимизированное создание токена подтверждения"""
verification_token = token or secrets.token_urlsafe(32)
@@ -61,12 +60,12 @@ class VerificationTokenManager(BaseTokenManager):
logger.info(f"Создан токен подтверждения {verification_type} для пользователя {user_id}")
return verification_token
async def get_verification_token_data(self, token: str) -> Optional[TokenData]:
async def get_verification_token_data(self, token: str) -> TokenData | None:
"""Получает данные токена подтверждения"""
token_key = self._make_token_key("verification", "", token)
return await redis_adapter.get_and_deserialize(token_key)
async def validate_verification_token(self, token_str: str) -> tuple[bool, Optional[TokenData]]:
async def validate_verification_token(self, token_str: str) -> tuple[bool, TokenData | None]:
"""Проверяет валидность токена подтверждения"""
token_key = self._make_token_key("verification", "", token_str)
token_data = await redis_adapter.get_and_deserialize(token_key)
@@ -74,7 +73,7 @@ class VerificationTokenManager(BaseTokenManager):
return True, token_data
return False, None
async def confirm_verification_token(self, token_str: str) -> Optional[TokenData]:
async def confirm_verification_token(self, token_str: str) -> TokenData | None:
"""Подтверждает и использует токен подтверждения (одноразовый)"""
token_data = await self.get_verification_token_data(token_str)
if token_data:
@@ -106,7 +105,7 @@ class VerificationTokenManager(BaseTokenManager):
await pipe.get(key)
results = await pipe.execute()
for key, data in zip(keys, results):
for key, data in zip(keys, results, strict=False):
if data:
try:
token_data = json.loads(data)
@@ -141,7 +140,7 @@ class VerificationTokenManager(BaseTokenManager):
results = await pipe.execute()
# Проверяем какие токены нужно удалить
for key, data in zip(keys, results):
for key, data in zip(keys, results, strict=False):
if data:
try:
token_data = json.loads(data)

179
auth/utils.py Normal file
View File

@@ -0,0 +1,179 @@
"""
Вспомогательные функции для аутентификации
Содержит функции для работы с токенами, заголовками и запросами
"""
from typing import Any
from settings import SESSION_COOKIE_NAME, SESSION_TOKEN_HEADER
from utils.logger import root_logger as logger
def get_safe_headers(request: Any) -> dict[str, str]:
"""
Безопасно получает заголовки запроса.
Args:
request: Объект запроса
Returns:
Dict[str, str]: Словарь заголовков
"""
headers = {}
try:
# Первый приоритет: scope из ASGI (самый надежный источник)
if hasattr(request, "scope") and isinstance(request.scope, dict):
scope_headers = request.scope.get("headers", [])
if scope_headers:
headers.update({k.decode("utf-8").lower(): v.decode("utf-8") for k, v in scope_headers})
logger.debug(f"[decorators] Получены заголовки из request.scope: {len(headers)}")
logger.debug(f"[decorators] Заголовки из request.scope: {list(headers.keys())}")
# Второй приоритет: метод 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"[decorators] Ошибка при доступе к заголовкам: {e}")
return headers
async def get_auth_token(request: Any) -> str | None:
"""
Извлекает токен авторизации из запроса.
Порядок проверки:
1. Проверяет auth из middleware
2. Проверяет auth из scope
3. Проверяет заголовок Authorization
4. Проверяет cookie с именем auth_token
Args:
request: Объект запроса
Returns:
Optional[str]: Токен авторизации или None
"""
try:
# 1. Проверяем auth из middleware (если middleware уже обработал токен)
if hasattr(request, "auth") and request.auth:
token = getattr(request.auth, "token", None)
if token:
token_len = len(token) if hasattr(token, "__len__") else "unknown"
logger.debug(f"[decorators] Токен получен из request.auth: {token_len}")
return token
logger.debug("[decorators] request.auth есть, но token НЕ найден")
else:
logger.debug("[decorators] request.auth НЕ найден")
# 2. Проверяем наличие auth_token в scope (приоритет)
if hasattr(request, "scope") and isinstance(request.scope, dict) and "auth_token" in request.scope:
token = request.scope.get("auth_token")
if token is not None:
token_len = len(token) if hasattr(token, "__len__") else "unknown"
logger.debug(f"[decorators] Токен получен из scope.auth_token: {token_len}")
return token
# 3. Получаем заголовки запроса безопасным способом
headers = get_safe_headers(request)
logger.debug(f"[decorators] Получены заголовки: {list(headers.keys())}")
# 4. Проверяем кастомный заголовок авторизации
auth_header_key = SESSION_TOKEN_HEADER.lower()
if auth_header_key in headers:
token = headers[auth_header_key]
logger.debug(f"[decorators] Токен найден в заголовке {SESSION_TOKEN_HEADER}")
# Убираем префикс Bearer если есть
if token.startswith("Bearer "):
token = token.replace("Bearer ", "", 1).strip()
logger.debug(f"[decorators] Обработанный токен: {len(token)}")
return token
# 5. Проверяем стандартный заголовок Authorization
if "authorization" in headers:
auth_header = headers["authorization"]
logger.debug(f"[decorators] Найден заголовок Authorization: {auth_header[:20]}...")
if auth_header.startswith("Bearer "):
token = auth_header.replace("Bearer ", "", 1).strip()
logger.debug(f"[decorators] Извлечен Bearer токен: {len(token)}")
return token
else:
logger.debug("[decorators] Authorization заголовок не содержит Bearer токен")
# 6. Проверяем cookies
if hasattr(request, "cookies") and request.cookies:
if isinstance(request.cookies, dict):
cookies = request.cookies
elif hasattr(request.cookies, "get"):
cookies = {k: request.cookies.get(k) for k in getattr(request.cookies, "keys", lambda: [])()}
else:
cookies = {}
logger.debug(f"[decorators] Доступные cookies: {list(cookies.keys())}")
# Проверяем кастомную cookie
if SESSION_COOKIE_NAME in cookies:
token = cookies[SESSION_COOKIE_NAME]
logger.debug(f"[decorators] Токен найден в cookie {SESSION_COOKIE_NAME}: {len(token)}")
return token
# Проверяем стандартную cookie
if "auth_token" in cookies:
token = cookies["auth_token"]
logger.debug(f"[decorators] Токен найден в cookie auth_token: {len(token)}")
return token
logger.debug("[decorators] Токен НЕ найден ни в одном источнике")
return None
except Exception as e:
logger.error(f"[decorators] Критическая ошибка при извлечении токена: {e}")
return None
def extract_bearer_token(auth_header: str) -> str | None:
"""
Извлекает токен из заголовка Authorization с Bearer схемой.
Args:
auth_header: Заголовок Authorization
Returns:
Optional[str]: Извлеченный токен или None
"""
if not auth_header:
return None
if auth_header.startswith("Bearer "):
return auth_header[7:].strip()
return None
def format_auth_header(token: str) -> str:
"""
Форматирует токен в заголовок Authorization.
Args:
token: Токен авторизации
Returns:
str: Отформатированный заголовок
"""
return f"Bearer {token}"

View File

@@ -1,6 +1,5 @@
import re
from datetime import datetime
from typing import Optional, Union
from pydantic import BaseModel, Field, field_validator
@@ -81,7 +80,7 @@ class TokenPayload(BaseModel):
username: str
exp: datetime
iat: datetime
scopes: Optional[list[str]] = []
scopes: list[str] | None = []
class OAuthInput(BaseModel):
@@ -89,7 +88,7 @@ class OAuthInput(BaseModel):
provider: str = Field(pattern="^(google|github|facebook)$")
code: str
redirect_uri: Optional[str] = None
redirect_uri: str | None = None
@field_validator("provider")
@classmethod
@@ -105,13 +104,13 @@ class AuthResponse(BaseModel):
"""Validation model for authentication responses"""
success: bool
token: Optional[str] = None
error: Optional[str] = None
user: Optional[dict[str, Union[str, int, bool]]] = None
token: str | None = None
error: str | None = None
user: dict[str, str | int | bool] | None = None
@field_validator("error")
@classmethod
def validate_error_if_not_success(cls, v: Optional[str], info) -> Optional[str]:
def validate_error_if_not_success(cls, v: str | None, info) -> str | None:
if not info.data.get("success") and not v:
msg = "Error message required when success is False"
raise ValueError(msg)
@@ -119,7 +118,7 @@ class AuthResponse(BaseModel):
@field_validator("token")
@classmethod
def validate_token_if_success(cls, v: Optional[str], info) -> Optional[str]:
def validate_token_if_success(cls, v: str | None, info) -> str | None:
if info.data.get("success") and not v:
msg = "Token required when success is True"
raise ValueError(msg)