tests-passed

This commit is contained in:
2025-07-31 18:55:59 +03:00
parent b7abb8d8a1
commit e7230ba63c
126 changed files with 8326 additions and 3207 deletions

View File

@@ -134,7 +134,7 @@ async def refresh_token(request: Request) -> JSONResponse:
# Получаем пользователя из базы данных
with local_session() as session:
author = session.query(Author).filter(Author.id == user_id).first()
author = session.query(Author).where(Author.id == user_id).first()
if not author:
logger.warning(f"[auth] refresh_token: Пользователь с ID {user_id} не найден")

View File

@@ -2,7 +2,7 @@ from typing import Any, Optional
from pydantic import BaseModel, Field
# from base.exceptions import Unauthorized
# from base.exceptions import UnauthorizedError
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
@@ -26,7 +26,7 @@ class AuthCredentials(BaseModel):
author_id: Optional[int] = Field(None, description="ID автора")
scopes: dict[str, set[str]] = Field(default_factory=dict, description="Разрешения пользователя")
logged_in: bool = Field(False, 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 токен авторизации")
@@ -88,7 +88,7 @@ class AuthCredentials(BaseModel):
async def permissions(self) -> list[Permission]:
if self.author_id is None:
# raise Unauthorized("Please login first")
# raise UnauthorizedError("Please login first")
return [] # Возвращаем пустой список вместо dict
# TODO: implement permissions logix
print(self.author_id)

View File

@@ -6,7 +6,7 @@ from graphql import GraphQLError, GraphQLResolveInfo
from sqlalchemy import exc
from auth.credentials import AuthCredentials
from auth.exceptions import OperationNotAllowed
from auth.exceptions import OperationNotAllowedError
from auth.internal import authenticate
from auth.orm import Author
from orm.community import CommunityAuthor
@@ -211,13 +211,13 @@ async def validate_graphql_context(info: GraphQLResolveInfo) -> None:
if not auth_state.logged_in:
error_msg = auth_state.error or "Invalid or expired token"
logger.warning(f"[validate_graphql_context] Недействительный токен: {error_msg}")
msg = f"Unauthorized - {error_msg}"
msg = f"UnauthorizedError - {error_msg}"
raise GraphQLError(msg)
# Если все проверки пройдены, создаем AuthCredentials и устанавливаем в request.scope
with local_session() as session:
try:
author = session.query(Author).filter(Author.id == auth_state.author_id).one()
author = session.query(Author).where(Author.id == auth_state.author_id).one()
logger.debug(f"[validate_graphql_context] Найден автор: id={author.id}, email={author.email}")
# Создаем объект авторизации с пустыми разрешениями
@@ -243,7 +243,7 @@ async def validate_graphql_context(info: GraphQLResolveInfo) -> None:
raise GraphQLError(msg)
except exc.NoResultFound:
logger.error(f"[validate_graphql_context] Пользователь с ID {auth_state.author_id} не найден в базе данных")
msg = "Unauthorized - user not found"
msg = "UnauthorizedError - user not found"
raise GraphQLError(msg) from None
return
@@ -314,7 +314,7 @@ def admin_auth_required(resolver: Callable) -> Callable:
if not auth or not getattr(auth, "logged_in", False):
logger.error("[admin_auth_required] Пользователь не авторизован после validate_graphql_context")
msg = "Unauthorized - please login"
msg = "UnauthorizedError - please login"
raise GraphQLError(msg)
# Проверяем, является ли пользователь администратором
@@ -324,10 +324,10 @@ def admin_auth_required(resolver: Callable) -> Callable:
author_id = int(auth.author_id) if auth and auth.author_id else None
if not author_id:
logger.error(f"[admin_auth_required] ID автора не определен: {auth}")
msg = "Unauthorized - invalid user ID"
msg = "UnauthorizedError - invalid user ID"
raise GraphQLError(msg)
author = session.query(Author).filter(Author.id == author_id).one()
author = session.query(Author).where(Author.id == author_id).one()
logger.debug(f"[admin_auth_required] Найден автор: {author.id}, {author.email}")
# Проверяем, является ли пользователь системным администратором
@@ -337,12 +337,12 @@ def admin_auth_required(resolver: Callable) -> Callable:
# Системный администратор определяется ТОЛЬКО по ADMIN_EMAILS
logger.warning(f"System admin access denied for {author.email} (ID: {author.id}). Not in ADMIN_EMAILS.")
msg = "Unauthorized - system admin access required"
msg = "UnauthorizedError - system admin access required"
raise GraphQLError(msg)
except exc.NoResultFound:
logger.error(f"[admin_auth_required] Пользователь с ID {auth.author_id} не найден в базе данных")
msg = "Unauthorized - user not found"
msg = "UnauthorizedError - user not found"
raise GraphQLError(msg) from None
except GraphQLError:
# Пробрасываем GraphQLError дальше
@@ -379,17 +379,17 @@ def permission_required(resource: str, operation: str, func: Callable) -> Callab
if not auth or not getattr(auth, "logged_in", False):
logger.error("[permission_required] Пользователь не авторизован после validate_graphql_context")
msg = "Требуются права доступа"
raise OperationNotAllowed(msg)
raise OperationNotAllowedError(msg)
# Проверяем разрешения
with local_session() as session:
try:
author = session.query(Author).filter(Author.id == auth.author_id).one()
author = session.query(Author).where(Author.id == auth.author_id).one()
# Проверяем базовые условия
if author.is_locked():
msg = "Account is locked"
raise OperationNotAllowed(msg)
raise OperationNotAllowedError(msg)
# Проверяем, является ли пользователь администратором (у них есть все разрешения)
if author.email in ADMIN_EMAILS:
@@ -399,10 +399,7 @@ def permission_required(resource: str, operation: str, func: Callable) -> Callab
# Проверяем роли пользователя
admin_roles = ["admin", "super"]
ca = session.query(CommunityAuthor).filter_by(author_id=author.id, community_id=1).first()
if ca:
user_roles = ca.role_list
else:
user_roles = []
user_roles = ca.role_list if ca else []
if any(role in admin_roles for role in user_roles):
logger.debug(
@@ -411,12 +408,20 @@ def permission_required(resource: str, operation: str, func: Callable) -> Callab
return await func(parent, info, *args, **kwargs)
# Проверяем разрешение
if not author.has_permission(resource, operation):
ca = session.query(CommunityAuthor).filter_by(author_id=author.id, community_id=1).first()
if ca:
user_roles = ca.role_list
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)
if not ca or not ca.has_permission(resource, operation):
logger.warning(
f"[permission_required] У пользователя {author.email} нет разрешения {operation} на {resource}"
)
msg = f"No permission for {operation} on {resource}"
raise OperationNotAllowed(msg)
raise OperationNotAllowedError(msg)
logger.debug(
f"[permission_required] Пользователь {author.email} имеет разрешение {operation} на {resource}"
@@ -425,7 +430,7 @@ def permission_required(resource: str, operation: str, func: Callable) -> Callab
except exc.NoResultFound:
logger.error(f"[permission_required] Пользователь с ID {auth.author_id} не найден в базе данных")
msg = "User not found"
raise OperationNotAllowed(msg) from None
raise OperationNotAllowedError(msg) from None
return wrap
@@ -494,7 +499,7 @@ def editor_or_admin_required(func: Callable) -> Callable:
# Проверяем роли пользователя
with local_session() as session:
author = session.query(Author).filter(Author.id == author_id).first()
author = session.query(Author).where(Author.id == author_id).first()
if not author:
logger.warning(f"[decorators] Автор с ID {author_id} не найден")
raise GraphQLError("Пользователь не найден")
@@ -506,10 +511,7 @@ def editor_or_admin_required(func: Callable) -> Callable:
# Получаем список ролей пользователя
ca = session.query(CommunityAuthor).filter_by(author_id=author.id, community_id=1).first()
if ca:
user_roles = ca.role_list
else:
user_roles = []
user_roles = ca.role_list if ca else []
logger.debug(f"[decorators] Роли пользователя {author_id}: {user_roles}")
# Проверяем наличие роли admin или editor

View File

@@ -3,36 +3,36 @@ from graphql.error import GraphQLError
# TODO: remove traceback from logs for defined exceptions
class BaseHttpException(GraphQLError):
class BaseHttpError(GraphQLError):
code = 500
message = "500 Server error"
class ExpiredToken(BaseHttpException):
class ExpiredTokenError(BaseHttpError):
code = 401
message = "401 Expired Token"
class InvalidToken(BaseHttpException):
class InvalidTokenError(BaseHttpError):
code = 401
message = "401 Invalid Token"
class Unauthorized(BaseHttpException):
class UnauthorizedError(BaseHttpError):
code = 401
message = "401 Unauthorized"
message = "401 UnauthorizedError"
class ObjectNotExist(BaseHttpException):
class ObjectNotExistError(BaseHttpError):
code = 404
message = "404 Object Does Not Exist"
class OperationNotAllowed(BaseHttpException):
class OperationNotAllowedError(BaseHttpError):
code = 403
message = "403 Operation Is Not Allowed"
class InvalidPassword(BaseHttpException):
class InvalidPasswordError(BaseHttpError):
code = 403
message = "403 Invalid Password"

View File

@@ -1,11 +1,8 @@
from binascii import hexlify
from hashlib import sha256
from typing import TYPE_CHECKING, Any, TypeVar
import bcrypt
from auth.exceptions import ExpiredToken, InvalidPassword, InvalidToken
from auth.exceptions import ExpiredTokenError, InvalidPasswordError, InvalidTokenError
from auth.jwtcodec import JWTCodec
from auth.password import Password
from services.db import local_session
from services.redis import redis
from utils.logger import root_logger as logger
@@ -17,54 +14,6 @@ if TYPE_CHECKING:
AuthorType = TypeVar("AuthorType", bound="Author")
class Password:
@staticmethod
def _to_bytes(data: str) -> bytes:
return bytes(data.encode())
@classmethod
def _get_sha256(cls, password: str) -> bytes:
bytes_password = cls._to_bytes(password)
return hexlify(sha256(bytes_password).digest())
@staticmethod
def encode(password: str) -> str:
"""
Кодирует пароль пользователя
Args:
password (str): Пароль пользователя
Returns:
str: Закодированный пароль
"""
password_sha256 = Password._get_sha256(password)
salt = bcrypt.gensalt(rounds=10)
return bcrypt.hashpw(password_sha256, salt).decode("utf-8")
@staticmethod
def verify(password: str, hashed: str) -> bool:
r"""
Verify that password hash is equal to specified hash. Hash format:
$2a$10$Ro0CUfOqk6cXEKf3dyaM7OhSCvnwM9s4wIX9JeLapehKK5YdLxKcm
\__/\/ \____________________/\_____________________________/
| | Salt Hash
| Cost
Version
More info: https://passlib.readthedocs.io/en/stable/lib/passlib.hash.bcrypt.html
:param password: clear text password
:param hashed: hash of the password
:return: True if clear text password matches specified hash
"""
hashed_bytes = Password._to_bytes(hashed)
password_sha256 = Password._get_sha256(password)
return bcrypt.checkpw(password_sha256, hashed_bytes) # Изменил verify на checkpw
class Identity:
@staticmethod
def password(orm_author: AuthorType, password: str) -> AuthorType:
@@ -79,23 +28,20 @@ class Identity:
Author: Объект автора при успешной проверке
Raises:
InvalidPassword: Если пароль не соответствует хешу или отсутствует
InvalidPasswordError: Если пароль не соответствует хешу или отсутствует
"""
# Импортируем внутри функции для избежания циклических импортов
from utils.logger import root_logger as logger
# Проверим исходный пароль в orm_author
if not orm_author.password:
logger.warning(f"[auth.identity] Пароль в исходном объекте автора пуст: email={orm_author.email}")
msg = "Пароль не установлен для данного пользователя"
raise InvalidPassword(msg)
raise InvalidPasswordError(msg)
# Проверяем пароль напрямую, не используя dict()
password_hash = str(orm_author.password) if orm_author.password else ""
if not password_hash or not Password.verify(password, password_hash):
logger.warning(f"[auth.identity] Неверный пароль для {orm_author.email}")
msg = "Неверный пароль пользователя"
raise InvalidPassword(msg)
raise InvalidPasswordError(msg)
# Возвращаем исходный объект, чтобы сохранить все связи
return orm_author
@@ -111,11 +57,11 @@ class Identity:
Returns:
Author: Объект пользователя
"""
# Импортируем внутри функции для избежания циклических импортов
# Поздний импорт для избежания циклических зависимостей
from auth.orm import Author
with local_session() as session:
author = session.query(Author).filter(Author.email == inp["email"]).first()
author = session.query(Author).where(Author.email == inp["email"]).first()
if not author:
author = Author(**inp)
author.email_verified = True # type: ignore[assignment]
@@ -135,9 +81,6 @@ class Identity:
Returns:
Author: Объект пользователя
"""
# Импортируем внутри функции для избежания циклических импортов
from auth.orm import Author
try:
print("[auth.identity] using one time token")
payload = JWTCodec.decode(token)
@@ -146,23 +89,32 @@ class Identity:
return {"error": "Invalid token"}
# Проверяем существование токена в хранилище
token_key = f"{payload.user_id}-{payload.username}-{token}"
user_id = payload.get("user_id")
username = payload.get("username")
if not user_id or not username:
logger.warning("[Identity.token] Нет user_id или username в токене")
return {"error": "Invalid token"}
token_key = f"{user_id}-{username}-{token}"
if not await redis.exists(token_key):
logger.warning(f"[Identity.token] Токен не найден в хранилище: {token_key}")
return {"error": "Token not found"}
# Если все проверки пройдены, ищем автора в базе данных
# Поздний импорт для избежания циклических зависимостей
from auth.orm import Author
with local_session() as session:
author = session.query(Author).filter_by(id=payload.user_id).first()
author = session.query(Author).filter_by(id=user_id).first()
if not author:
logger.warning(f"[Identity.token] Автор с ID {payload.user_id} не найден")
logger.warning(f"[Identity.token] Автор с ID {user_id} не найден")
return {"error": "User not found"}
logger.info(f"[Identity.token] Токен валиден для автора {author.id}")
return author
except ExpiredToken:
# raise InvalidToken("Login token has expired, please try again")
except ExpiredTokenError:
# raise InvalidTokenError("Login token has expired, please try again")
return {"error": "Token has expired"}
except InvalidToken:
# raise InvalidToken("token format error") from e
except InvalidTokenError:
# raise InvalidTokenError("token format error") from e
return {"error": "Token format error"}

View File

@@ -6,11 +6,12 @@
import time
from typing import Optional
from sqlalchemy.orm import exc
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 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
@@ -45,16 +46,11 @@ async def verify_internal_auth(token: str) -> tuple[int, list, bool]:
with local_session() as session:
try:
author = session.query(Author).filter(Author.id == payload.user_id).one()
author = session.query(Author).where(Author.id == payload.user_id).one()
# Получаем роли
from orm.community import CommunityAuthor
ca = session.query(CommunityAuthor).filter_by(author_id=author.id, community_id=1).first()
if ca:
roles = ca.role_list
else:
roles = []
roles = ca.role_list if ca else []
logger.debug(f"[verify_internal_auth] Роли пользователя: {roles}")
# Определяем, является ли пользователь администратором
@@ -64,7 +60,7 @@ async def verify_internal_auth(token: str) -> tuple[int, list, bool]:
)
return int(author.id), roles, is_admin
except exc.NoResultFound:
except NoResultFound:
logger.warning(f"[verify_internal_auth] Пользователь с ID {payload.user_id} не найден в БД или не активен")
return 0, [], False
@@ -104,9 +100,6 @@ async def authenticate(request) -> AuthState:
Returns:
AuthState: Состояние аутентификации
"""
from auth.decorators import get_auth_token
from utils.logger import root_logger as logger
logger.debug("[authenticate] Начало аутентификации")
# Создаем объект AuthState
@@ -117,12 +110,16 @@ async def authenticate(request) -> AuthState:
auth_state.token = None
# Получаем токен из запроса
token = get_auth_token(request)
token = request.headers.get("Authorization")
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)}")
# Проверяем токен

View File

@@ -1,123 +1,97 @@
from datetime import datetime, timedelta, timezone
from typing import Any, Optional, Union
import datetime
import logging
from typing import Any, Dict, Optional
import jwt
from pydantic import BaseModel
from settings import JWT_ALGORITHM, JWT_SECRET_KEY
from utils.logger import root_logger as logger
class TokenPayload(BaseModel):
user_id: str
username: str
exp: Optional[datetime] = None
iat: datetime
iss: str
from settings import JWT_ALGORITHM, JWT_ISSUER, JWT_REFRESH_TOKEN_EXPIRE_DAYS, JWT_SECRET_KEY
class JWTCodec:
"""
Кодировщик и декодировщик JWT токенов.
"""
@staticmethod
def encode(user: Union[dict[str, Any], Any], exp: Optional[datetime] = None) -> str:
# Поддержка как объектов, так и словарей
if isinstance(user, dict):
# В TokenStorage.create_session передается словарь {"user_id": user_id, "username": username}
user_id = str(user.get("user_id", "") or user.get("id", ""))
username = user.get("username", "") or user.get("email", "")
else:
# Для объектов с атрибутами
user_id = str(getattr(user, "id", ""))
username = getattr(user, "slug", "") or getattr(user, "email", "") or getattr(user, "phone", "") or ""
def encode(
payload: Dict[str, Any],
secret_key: Optional[str] = None,
algorithm: Optional[str] = None,
expiration: Optional[datetime.datetime] = None,
) -> str | bytes:
"""
Кодирует payload в JWT токен.
logger.debug(f"[JWTCodec.encode] Кодирование токена для user_id={user_id}, username={username}")
Args:
payload (Dict[str, Any]): Полезная нагрузка для кодирования
secret_key (Optional[str]): Секретный ключ. По умолчанию используется JWT_SECRET_KEY
algorithm (Optional[str]): Алгоритм шифрования. По умолчанию используется JWT_ALGORITHM
expiration (Optional[datetime.datetime]): Время истечения токена
# Если время истечения не указано, установим срок годности на 30 дней
if exp is None:
exp = datetime.now(tz=timezone.utc) + timedelta(days=30)
logger.debug(f"[JWTCodec.encode] Время истечения не указано, устанавливаем срок: {exp}")
Returns:
str: Закодированный JWT токен
"""
logger = logging.getLogger("root")
logger.debug(f"[JWTCodec.encode] Кодирование токена для payload: {payload}")
# Важно: убедимся, что exp всегда является либо datetime, либо целым числом от timestamp
if isinstance(exp, datetime):
# Преобразуем datetime в timestamp чтобы гарантировать правильный формат
exp_timestamp = int(exp.timestamp())
else:
# Если передано что-то другое, установим значение по умолчанию
logger.warning(f"[JWTCodec.encode] Некорректный формат exp: {exp}, используем значение по умолчанию")
exp_timestamp = int((datetime.now(tz=timezone.utc) + timedelta(days=30)).timestamp())
# Используем переданные или дефолтные значения
secret_key = secret_key or JWT_SECRET_KEY
algorithm = algorithm or JWT_ALGORITHM
payload = {
"user_id": user_id,
"username": username,
"exp": exp_timestamp, # Используем timestamp вместо datetime
"iat": datetime.now(tz=timezone.utc),
"iss": "discours",
}
# Если время истечения не указано, устанавливаем дефолтное
if not expiration:
expiration = datetime.datetime.now(datetime.timezone.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}
)
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}")
# Ensure we always return str, not bytes
if isinstance(token, bytes):
return token.decode("utf-8")
return str(token)
# Используем PyJWT для кодирования
encoded = jwt.encode(payload, secret_key, algorithm=algorithm)
token_str = encoded.decode("utf-8") if isinstance(encoded, bytes) else encoded
return token_str
except Exception as e:
logger.error(f"[JWTCodec.encode] Ошибка при кодировании JWT: {e}")
logger.warning(f"[JWTCodec.encode] Ошибка при кодировании JWT: {e}")
raise
@staticmethod
def decode(token: str, verify_exp: bool = True) -> Optional[TokenPayload]:
logger.debug(f"[JWTCodec.decode] Начало декодирования токена длиной {len(token) if token else 0}")
def decode(
token: str,
secret_key: Optional[str] = None,
algorithms: Optional[list] = None,
) -> Dict[str, Any]:
"""
Декодирует JWT токен.
if not token:
logger.error("[JWTCodec.decode] Пустой токен")
return None
Args:
token (str): JWT токен
secret_key (Optional[str]): Секретный ключ. По умолчанию используется JWT_SECRET_KEY
algorithms (Optional[list]): Список алгоритмов. По умолчанию используется [JWT_ALGORITHM]
Returns:
Dict[str, Any]: Декодированный payload
"""
logger = logging.getLogger("root")
logger.debug("[JWTCodec.decode] Декодирование токена")
# Используем переданные или дефолтные значения
secret_key = secret_key or JWT_SECRET_KEY
algorithms = algorithms or [JWT_ALGORITHM]
try:
payload = jwt.decode(
token,
key=JWT_SECRET_KEY,
options={
"verify_exp": verify_exp,
# "verify_signature": False
},
algorithms=[JWT_ALGORITHM],
issuer="discours",
)
logger.debug(f"[JWTCodec.decode] Декодирован payload: {payload}")
# Убедимся, что exp существует (добавим обработку если exp отсутствует)
if "exp" not in payload:
logger.warning("[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}"
)
return r
except Exception as e:
logger.error(f"[JWTCodec.decode] Ошибка при создании TokenPayload: {e}")
return None
except jwt.InvalidIssuedAtError:
logger.error("[JWTCodec.decode] Недействительное время выпуска токена")
return None
# Используем PyJWT для декодирования
decoded = jwt.decode(token, secret_key, algorithms=algorithms)
return decoded
except jwt.ExpiredSignatureError:
logger.error("[JWTCodec.decode] Истек срок действия токена")
return None
except jwt.InvalidSignatureError:
logger.error("[JWTCodec.decode] Недействительная подпись токена")
return None
except jwt.InvalidTokenError:
logger.error("[JWTCodec.decode] Недействительный токен")
return None
except jwt.InvalidKeyError:
logger.error("[JWTCodec.decode] Недействительный ключ")
return None
except Exception as e:
logger.error(f"[JWTCodec.decode] Неожиданная ошибка при декодировании: {e}")
return None
logger.warning("[JWTCodec.decode] Токен просрочен")
raise
except jwt.InvalidTokenError as e:
logger.warning(f"[JWTCodec.decode] Ошибка при декодировании JWT: {e}")
raise

View File

@@ -2,6 +2,7 @@
Единый middleware для обработки авторизации в GraphQL запросах
"""
import json
import time
from collections.abc import Awaitable, MutableMapping
from typing import Any, Callable, Optional
@@ -104,7 +105,7 @@ class AuthMiddleware:
with local_session() as session:
try:
author = session.query(Author).filter(Author.id == payload.user_id).one()
author = session.query(Author).where(Author.id == payload.user_id).one()
if author.is_locked():
logger.debug(f"[auth.authenticate] Аккаунт заблокирован: {author.id}")
@@ -123,10 +124,7 @@ class AuthMiddleware:
# Получаем роли для пользователя
ca = session.query(CommunityAuthor).filter_by(author_id=author.id, community_id=1).first()
if ca:
roles = ca.role_list
else:
roles = []
roles = ca.role_list if ca else []
# Обновляем last_seen
author.last_seen = int(time.time())
@@ -336,8 +334,6 @@ class AuthMiddleware:
# Проверяем наличие response в контексте
if "response" not in context or not context["response"]:
from starlette.responses import JSONResponse
context["response"] = JSONResponse({})
logger.debug("[middleware] Создан новый response объект в контексте GraphQL")
@@ -367,8 +363,6 @@ class AuthMiddleware:
result_data = {}
if isinstance(result, JSONResponse):
try:
import json
body_content = result.body
if isinstance(body_content, (bytes, memoryview)):
body_text = bytes(body_content).decode("utf-8")

View File

@@ -12,6 +12,7 @@ from starlette.responses import JSONResponse, RedirectResponse
from auth.orm import Author
from auth.tokens.storage import TokenStorage
from orm.community import Community, CommunityAuthor, CommunityFollower
from services.db import local_session
from services.redis import redis
from settings import (
@@ -531,7 +532,7 @@ async def _create_or_update_user(provider: str, profile: dict) -> Author:
# Ищем пользователя по email если есть настоящий email
author = None
if email and not email.endswith(TEMP_EMAIL_SUFFIX):
author = session.query(Author).filter(Author.email == email).first()
author = session.query(Author).where(Author.email == email).first()
if author:
# Пользователь найден по email - добавляем OAuth данные
@@ -559,9 +560,6 @@ def _update_author_profile(author: Author, profile: dict) -> None:
def _create_new_oauth_user(provider: str, profile: dict, email: str, session: Any) -> Author:
"""Создает нового пользователя из OAuth профиля"""
from orm.community import Community, CommunityAuthor, CommunityFollower
from utils.logger import root_logger as logger
slug = generate_unique_slug(profile["name"] or f"{provider}_{profile.get('id', 'user')}")
author = Author(
@@ -584,20 +582,32 @@ def _create_new_oauth_user(provider: str, profile: dict, email: str, session: An
target_community_id = 1 # Основное сообщество
# Получаем сообщество для назначения дефолтных ролей
community = session.query(Community).filter(Community.id == target_community_id).first()
community = session.query(Community).where(Community.id == target_community_id).first()
if community:
default_roles = community.get_default_roles()
# Создаем CommunityAuthor с дефолтными ролями
community_author = CommunityAuthor(
community_id=target_community_id, author_id=author.id, roles=",".join(default_roles)
# Проверяем, не существует ли уже запись CommunityAuthor
existing_ca = (
session.query(CommunityAuthor).filter_by(community_id=target_community_id, author_id=author.id).first()
)
session.add(community_author)
logger.info(f"Создана запись CommunityAuthor для OAuth пользователя {author.id} с ролями: {default_roles}")
# Добавляем пользователя в подписчики сообщества
follower = CommunityFollower(community=target_community_id, follower=int(author.id))
session.add(follower)
logger.info(f"OAuth пользователь {author.id} добавлен в подписчики сообщества {target_community_id}")
if not existing_ca:
# Создаем CommunityAuthor с дефолтными ролями
community_author = CommunityAuthor(
community_id=target_community_id, author_id=author.id, roles=",".join(default_roles)
)
session.add(community_author)
logger.info(f"Создана запись CommunityAuthor для OAuth пользователя {author.id} с ролями: {default_roles}")
# Проверяем, не существует ли уже запись подписчика
existing_follower = (
session.query(CommunityFollower).filter_by(community=target_community_id, follower=int(author.id)).first()
)
if not existing_follower:
# Добавляем пользователя в подписчики сообщества
follower = CommunityFollower(community=target_community_id, follower=int(author.id))
session.add(follower)
logger.info(f"OAuth пользователь {author.id} добавлен в подписчики сообщества {target_community_id}")
return author

View File

@@ -1,85 +1,24 @@
import time
from typing import Any, Dict, Optional
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String
from sqlalchemy.orm import Session
from sqlalchemy import (
JSON,
Boolean,
ForeignKey,
Index,
Integer,
PrimaryKeyConstraint,
String,
)
from sqlalchemy.orm import Mapped, Session, mapped_column
from auth.identity import Password
from services.db import BaseModel as Base
from auth.password import Password
from orm.base import BaseModel as Base
# Общие table_args для всех моделей
DEFAULT_TABLE_ARGS = {"extend_existing": True}
"""
Модель закладок автора
"""
class AuthorBookmark(Base):
"""
Закладка автора на публикацию.
Attributes:
author (int): ID автора
shout (int): ID публикации
"""
__tablename__ = "author_bookmark"
__table_args__ = (
Index("idx_author_bookmark_author", "author"),
Index("idx_author_bookmark_shout", "shout"),
{"extend_existing": True},
)
author = Column(ForeignKey("author.id"), primary_key=True)
shout = Column(ForeignKey("shout.id"), primary_key=True)
class AuthorRating(Base):
"""
Рейтинг автора от другого автора.
Attributes:
rater (int): ID оценивающего автора
author (int): ID оцениваемого автора
plus (bool): Положительная/отрицательная оценка
"""
__tablename__ = "author_rating"
__table_args__ = (
Index("idx_author_rating_author", "author"),
Index("idx_author_rating_rater", "rater"),
{"extend_existing": True},
)
rater = Column(ForeignKey("author.id"), primary_key=True)
author = Column(ForeignKey("author.id"), primary_key=True)
plus = Column(Boolean)
class AuthorFollower(Base):
"""
Подписка одного автора на другого.
Attributes:
follower (int): ID подписчика
author (int): ID автора, на которого подписываются
created_at (int): Время создания подписки
auto (bool): Признак автоматической подписки
"""
__tablename__ = "author_follower"
__table_args__ = (
Index("idx_author_follower_author", "author"),
Index("idx_author_follower_follower", "follower"),
{"extend_existing": True},
)
id = None # type: ignore[assignment]
follower = Column(ForeignKey("author.id"), primary_key=True)
author = Column(ForeignKey("author.id"), primary_key=True)
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
auto = Column(Boolean, nullable=False, default=False)
PROTECTED_FIELDS = ["email", "password", "provider_access_token", "provider_refresh_token"]
class Author(Base):
@@ -96,37 +35,42 @@ class Author(Base):
)
# Базовые поля автора
id = Column(Integer, primary_key=True)
name = Column(String, nullable=True, comment="Display name")
slug = Column(String, unique=True, comment="Author's slug")
bio = Column(String, nullable=True, comment="Bio") # короткое описание
about = Column(String, nullable=True, comment="About") # длинное форматированное описание
pic = Column(String, nullable=True, comment="Picture")
links = Column(JSON, nullable=True, comment="Links")
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str | None] = mapped_column(String, nullable=True, comment="Display name")
slug: Mapped[str] = mapped_column(String, unique=True, comment="Author's slug")
bio: Mapped[str | None] = mapped_column(String, nullable=True, comment="Bio") # короткое описание
about: Mapped[str | None] = mapped_column(
String, nullable=True, comment="About"
) # длинное форматированное описание
pic: Mapped[str | None] = mapped_column(String, nullable=True, comment="Picture")
links: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True, comment="Links")
# OAuth аккаунты - JSON с данными всех провайдеров
# Формат: {"google": {"id": "123", "email": "user@gmail.com"}, "github": {"id": "456"}}
oauth = Column(JSON, nullable=True, default=dict, comment="OAuth accounts data")
oauth: Mapped[dict[str, Any] | None] = mapped_column(
JSON, nullable=True, default=dict, comment="OAuth accounts data"
)
# Поля аутентификации
email = Column(String, unique=True, nullable=True, comment="Email")
phone = Column(String, unique=True, nullable=True, comment="Phone")
password = Column(String, nullable=True, comment="Password hash")
email_verified = Column(Boolean, default=False)
phone_verified = Column(Boolean, default=False)
failed_login_attempts = Column(Integer, default=0)
account_locked_until = Column(Integer, nullable=True)
email: Mapped[str | None] = mapped_column(String, unique=True, nullable=True, comment="Email")
phone: Mapped[str | None] = mapped_column(String, unique=True, nullable=True, comment="Phone")
password: Mapped[str | None] = mapped_column(String, nullable=True, comment="Password hash")
email_verified: Mapped[bool] = mapped_column(Boolean, default=False)
phone_verified: Mapped[bool] = mapped_column(Boolean, default=False)
failed_login_attempts: Mapped[int] = mapped_column(Integer, default=0)
account_locked_until: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Временные метки
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
updated_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
last_seen = Column(Integer, nullable=False, default=lambda: int(time.time()))
deleted_at = Column(Integer, nullable=True)
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
updated_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
last_seen: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
deleted_at: Mapped[int | None] = mapped_column(Integer, nullable=True)
oid = Column(String, nullable=True)
oid: Mapped[str | None] = mapped_column(String, nullable=True)
# Список защищенных полей, которые видны только владельцу и администраторам
_protected_fields = ["email", "password", "provider_access_token", "provider_refresh_token"]
@property
def protected_fields(self) -> list[str]:
return PROTECTED_FIELDS
@property
def is_authenticated(self) -> bool:
@@ -214,7 +158,7 @@ class Author(Base):
Author или None: Найденный автор или None если не найден
"""
# Ищем авторов, у которых есть данный провайдер с данным ID
authors = session.query(cls).filter(cls.oauth.isnot(None)).all()
authors = session.query(cls).where(cls.oauth.isnot(None)).all()
for author in authors:
if author.oauth and provider in author.oauth:
oauth_data = author.oauth[provider] # type: ignore[index]
@@ -266,3 +210,73 @@ class Author(Base):
"""
if self.oauth and provider in self.oauth:
del self.oauth[provider]
class AuthorBookmark(Base):
"""
Закладка автора на публикацию.
Attributes:
author (int): ID автора
shout (int): ID публикации
"""
__tablename__ = "author_bookmark"
author: Mapped[int] = mapped_column(ForeignKey(Author.id))
shout: Mapped[int] = mapped_column(ForeignKey("shout.id"))
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
__table_args__ = (
PrimaryKeyConstraint(author, shout),
Index("idx_author_bookmark_author", "author"),
Index("idx_author_bookmark_shout", "shout"),
{"extend_existing": True},
)
class AuthorRating(Base):
"""
Рейтинг автора от другого автора.
Attributes:
rater (int): ID оценивающего автора
author (int): ID оцениваемого автора
plus (bool): Положительная/отрицательная оценка
"""
__tablename__ = "author_rating"
rater: Mapped[int] = mapped_column(ForeignKey(Author.id))
author: Mapped[int] = mapped_column(ForeignKey(Author.id))
plus: Mapped[bool] = mapped_column(Boolean)
__table_args__ = (
PrimaryKeyConstraint(rater, author),
Index("idx_author_rating_author", "author"),
Index("idx_author_rating_rater", "rater"),
{"extend_existing": True},
)
class AuthorFollower(Base):
"""
Подписка одного автора на другого.
Attributes:
follower (int): ID подписчика
author (int): ID автора, на которого подписываются
created_at (int): Время создания подписки
auto (bool): Признак автоматической подписки
"""
__tablename__ = "author_follower"
follower: Mapped[int] = mapped_column(ForeignKey(Author.id))
author: Mapped[int] = mapped_column(ForeignKey(Author.id))
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
auto: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
__table_args__ = (
PrimaryKeyConstraint(follower, author),
Index("idx_author_follower_author", "author"),
Index("idx_author_follower_follower", "follower"),
{"extend_existing": True},
)

57
auth/password.py Normal file
View File

@@ -0,0 +1,57 @@
"""
Модуль для работы с паролями
Отдельный модуль для избежания циклических импортов
"""
from binascii import hexlify
from hashlib import sha256
import bcrypt
class Password:
@staticmethod
def _to_bytes(data: str) -> bytes:
return bytes(data.encode())
@classmethod
def _get_sha256(cls, password: str) -> bytes:
bytes_password = cls._to_bytes(password)
return hexlify(sha256(bytes_password).digest())
@staticmethod
def encode(password: str) -> str:
"""
Кодирует пароль пользователя
Args:
password (str): Пароль пользователя
Returns:
str: Закодированный пароль
"""
password_sha256 = Password._get_sha256(password)
salt = bcrypt.gensalt(rounds=10)
return bcrypt.hashpw(password_sha256, salt).decode("utf-8")
@staticmethod
def verify(password: str, hashed: str) -> bool:
r"""
Verify that password hash is equal to specified hash. Hash format:
$2a$10$Ro0CUfOqk6cXEKf3dyaM7OhSCvnwM9s4wIX9JeLapehKK5YdLxKcm
\__/\/ \____________________/\_____________________________/
| | Salt Hash
| Cost
Version
More info: https://passlib.readthedocs.io/en/stable/lib/passlib.hash.bcrypt.html
:param password: clear text password
:param hashed: hash of the password
:return: True if clear text password matches specified hash
"""
hashed_bytes = Password._to_bytes(hashed)
password_sha256 = Password._get_sha256(password)
return bcrypt.checkpw(password_sha256, hashed_bytes)

View File

@@ -22,9 +22,9 @@ class ContextualPermissionCheck:
учитывая как глобальные роли пользователя, так и его роли внутри сообщества.
"""
@staticmethod
@classmethod
async def check_community_permission(
session: Session, author_id: int, community_slug: str, resource: str, operation: str
cls, session: Session, author_id: int, community_slug: str, resource: str, operation: str
) -> bool:
"""
Проверяет наличие разрешения у пользователя в контексте сообщества.
@@ -40,7 +40,7 @@ class ContextualPermissionCheck:
bool: True, если пользователь имеет разрешение, иначе False
"""
# 1. Проверка глобальных разрешений (например, администратор)
author = session.query(Author).filter(Author.id == author_id).one_or_none()
author = session.query(Author).where(Author.id == author_id).one_or_none()
if not author:
return False
# Если это администратор (по списку email)
@@ -49,7 +49,7 @@ class ContextualPermissionCheck:
# 2. Проверка разрешений в контексте сообщества
# Получаем информацию о сообществе
community = session.query(Community).filter(Community.slug == community_slug).one_or_none()
community = session.query(Community).where(Community.slug == community_slug).one_or_none()
if not community:
return False
@@ -59,11 +59,11 @@ class ContextualPermissionCheck:
# Проверяем наличие разрешения для этих ролей
permission_id = f"{resource}:{operation}"
ca = CommunityAuthor.find_by_user_and_community(author_id, community.id, session)
return bool(await ca.has_permission(permission_id))
ca = CommunityAuthor.find_author_in_community(author_id, community.id, session)
return bool(ca.has_permission(permission_id)) if ca else False
@staticmethod
async def get_user_community_roles(session: Session, author_id: int, community_slug: str) -> list[str]:
@classmethod
def get_user_community_roles(cls, session: Session, author_id: int, community_slug: str) -> list[str]:
"""
Получает список ролей пользователя в сообществе.
@@ -73,10 +73,10 @@ class ContextualPermissionCheck:
community_slug: Slug сообщества
Returns:
List[CommunityRole]: Список ролей пользователя в сообществе
List[str]: Список ролей пользователя в сообществе
"""
# Получаем информацию о сообществе
community = session.query(Community).filter(Community.slug == community_slug).one_or_none()
community = session.query(Community).where(Community.slug == community_slug).one_or_none()
if not community:
return []
@@ -84,63 +84,80 @@ class ContextualPermissionCheck:
if community.created_by == author_id:
return ["editor", "author", "expert", "reader"]
ca = CommunityAuthor.find_by_user_and_community(author_id, community.id, session)
# Находим связь автор-сообщество
ca = CommunityAuthor.find_author_in_community(author_id, community.id, session)
return ca.role_list if ca else []
@staticmethod
async def assign_role_to_user(session: Session, author_id: int, community_slug: str, role: str) -> bool:
@classmethod
def check_permission(
cls, session: Session, author_id: int, community_slug: str, resource: str, operation: str
) -> bool:
"""
Назначает роль пользователю в сообществе.
Проверяет наличие разрешения у пользователя в контексте сообщества.
Синхронный метод для обратной совместимости.
Args:
session: Сессия SQLAlchemy
author_id: ID автора/пользователя
community_slug: Slug сообщества
role: Роль для назначения (CommunityRole или строковое представление)
resource: Ресурс для доступа
operation: Операция над ресурсом
Returns:
bool: True если роль успешно назначена, иначе False
bool: True, если пользователь имеет разрешение, иначе False
"""
# Используем тот же алгоритм, что и в асинхронной версии
author = session.query(Author).where(Author.id == author_id).one_or_none()
if not author:
return False
# Если это администратор (по списку email)
if author.email in ADMIN_EMAILS:
return True
# Получаем информацию о сообществе
community = session.query(Community).filter(Community.slug == community_slug).one_or_none()
community = session.query(Community).where(Community.slug == community_slug).one_or_none()
if not community:
return False
# Проверяем существование связи автор-сообщество
ca = CommunityAuthor.find_by_user_and_community(author_id, community.id, session)
if not ca:
return False
# Если автор является создателем сообщества, то у него есть полные права
if community.created_by == author_id:
return True
# Назначаем роль
ca.add_role(role)
return True
# Проверяем наличие разрешения для этих ролей
permission_id = f"{resource}:{operation}"
ca = CommunityAuthor.find_author_in_community(author_id, community.id, session)
@staticmethod
async def revoke_role_from_user(session: Session, author_id: int, community_slug: str, role: str) -> bool:
# Возвращаем результат проверки разрешения
return bool(ca and ca.has_permission(permission_id))
async def can_delete_community(self, user_id: int, community: Community, session: Session) -> bool:
"""
Отзывает роль у пользователя в сообществе.
Проверяет, может ли пользователь удалить сообщество.
Args:
user_id: ID пользователя
community: Объект сообщества
session: Сессия SQLAlchemy
author_id: ID автора/пользователя
community_slug: Slug сообщества
role: Роль для отзыва (CommunityRole или строковое представление)
Returns:
bool: True если роль успешно отозвана, иначе False
bool: True, если пользователь может удалить сообщество, иначе False
"""
# Если пользователь - создатель сообщества
if community.created_by == user_id:
return True
# Получаем информацию о сообществе
community = session.query(Community).filter(Community.slug == community_slug).one_or_none()
if not community:
# Проверяем, есть ли у пользователя роль администратора или редактора
author = session.query(Author).where(Author.id == user_id).first()
if not author:
return False
# Проверяем существование связи автор-сообщество
ca = CommunityAuthor.find_by_user_and_community(author_id, community.id, session)
if not ca:
return False
# Проверка по email (глобальные администраторы)
if author.email in ADMIN_EMAILS:
return True
# Отзываем роль
ca.remove_role(role)
return True
# Проверка ролей в сообществе
community_author = CommunityAuthor.find_author_in_community(user_id, community.id, session)
if community_author:
return "admin" in community_author.role_list or "editor" in community_author.role_list
return False

View File

@@ -9,6 +9,8 @@ from services.redis import redis as redis_adapter
from utils.logger import root_logger as logger
from .base import BaseTokenManager
from .batch import BatchTokenOperations
from .sessions import SessionTokenManager
from .types import SCAN_BATCH_SIZE
@@ -83,8 +85,6 @@ class TokenMonitoring(BaseTokenManager):
try:
# Очищаем истекшие токены
from .batch import BatchTokenOperations
batch_ops = BatchTokenOperations()
cleaned = await batch_ops.cleanup_expired_tokens()
results["cleaned_expired"] = cleaned
@@ -158,8 +158,6 @@ class TokenMonitoring(BaseTokenManager):
health["redis_connected"] = True
# Тестируем основные операции с токенами
from .sessions import SessionTokenManager
session_manager = SessionTokenManager()
test_user_id = "health_check_user"

View File

@@ -50,7 +50,7 @@ class SessionTokenManager(BaseTokenManager):
}
)
session_token = jwt_token
session_token = jwt_token.decode("utf-8") if isinstance(jwt_token, bytes) else str(jwt_token)
token_key = self._make_token_key("session", user_id, session_token)
user_tokens_key = self._make_user_tokens_key(user_id, "session")
ttl = DEFAULT_TTL["session"]
@@ -81,7 +81,7 @@ class SessionTokenManager(BaseTokenManager):
# Извлекаем user_id из JWT
payload = JWTCodec.decode(token)
if payload:
user_id = payload.user_id
user_id = payload.get("user_id")
else:
return None
@@ -107,7 +107,7 @@ class SessionTokenManager(BaseTokenManager):
if not payload:
return False, None
user_id = payload.user_id
user_id = payload.get("user_id")
token_key = self._make_token_key("session", user_id, token)
# Проверяем существование и получаем данные
@@ -129,7 +129,7 @@ class SessionTokenManager(BaseTokenManager):
if not payload:
return False
user_id = payload.user_id
user_id = payload.get("user_id")
# Используем новый метод execute_pipeline для избежания deprecated warnings
token_key = self._make_token_key("session", user_id, token)
@@ -243,18 +243,19 @@ class SessionTokenManager(BaseTokenManager):
logger.error("Не удалось декодировать токен")
return None
if not hasattr(payload, "user_id"):
user_id = payload.get("user_id")
if not user_id:
logger.error("В токене отсутствует user_id")
return None
logger.debug(f"Успешно декодирован токен, user_id={payload.user_id}")
logger.debug(f"Успешно декодирован токен, user_id={user_id}")
# Проверяем наличие сессии в Redis
token_key = self._make_token_key("session", str(payload.user_id), token)
token_key = self._make_token_key("session", str(user_id), token)
session_exists = await redis_adapter.exists(token_key)
if not session_exists:
logger.warning(f"Сессия не найдена в Redis для user_id={payload.user_id}")
logger.warning(f"Сессия не найдена в Redis для user_id={user_id}")
return None
# Обновляем last_activity