tests-passed
This commit is contained in:
@@ -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} не найден")
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
|
@@ -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"
|
||||
|
@@ -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"}
|
||||
|
@@ -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)}")
|
||||
|
||||
# Проверяем токен
|
||||
|
174
auth/jwtcodec.py
174
auth/jwtcodec.py
@@ -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
|
||||
|
@@ -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")
|
||||
|
@@ -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
|
||||
|
208
auth/orm.py
208
auth/orm.py
@@ -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
57
auth/password.py
Normal 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)
|
@@ -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
|
||||
|
@@ -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"
|
||||
|
@@ -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
|
||||
|
Reference in New Issue
Block a user