upgrade schema, resolvers, panel added

This commit is contained in:
2025-05-16 09:23:48 +03:00
parent 8a60bec73a
commit 2d382be794
80 changed files with 8641 additions and 1100 deletions

122
auth/__init__.py Normal file
View File

@@ -0,0 +1,122 @@
from starlette.requests import Request
from starlette.responses import JSONResponse, RedirectResponse
from starlette.routing import Route
from auth.sessions import SessionManager
from auth.internal import verify_internal_auth
from auth.orm import Author
from services.db import local_session
from utils.logger import root_logger as logger
from settings import (
SESSION_COOKIE_NAME,
SESSION_COOKIE_HTTPONLY,
SESSION_COOKIE_SECURE,
SESSION_COOKIE_SAMESITE,
SESSION_COOKIE_MAX_AGE,
)
async def logout(request: Request):
"""
Выход из системы с удалением сессии и cookie.
"""
# Получаем токен из cookie или заголовка
token = request.cookies.get(SESSION_COOKIE_NAME)
if not token:
# Проверяем заголовок авторизации
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:] # Отрезаем "Bearer "
# Если токен найден, отзываем его
if token:
try:
# Декодируем токен для получения user_id
user_id, _ = await verify_internal_auth(token)
if user_id:
# Отзываем сессию
await SessionManager.revoke_session(user_id, token)
logger.info(f"[auth] logout: Токен успешно отозван для пользователя {user_id}")
else:
logger.warning("[auth] logout: Не удалось получить user_id из токена")
except Exception as e:
logger.error(f"[auth] logout: Ошибка при отзыве токена: {e}")
# Создаем ответ с редиректом на страницу входа
response = RedirectResponse(url="/login")
# Удаляем cookie с токеном
response.delete_cookie(SESSION_COOKIE_NAME)
logger.info("[auth] logout: Cookie успешно удалена")
return response
async def refresh_token(request: Request):
"""
Обновление токена аутентификации.
"""
# Получаем текущий токен из cookie или заголовка
token = request.cookies.get(SESSION_COOKIE_NAME)
if not token:
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:] # Отрезаем "Bearer "
if not token:
return JSONResponse({"success": False, "error": "Токен не найден"}, status_code=401)
try:
# Получаем информацию о пользователе из токена
user_id, _ = await verify_internal_auth(token)
if not user_id:
return JSONResponse({"success": False, "error": "Недействительный токен"}, status_code=401)
# Получаем пользователя из базы данных
with local_session() as session:
author = session.query(Author).filter(Author.id == user_id).first()
if not author:
return JSONResponse({"success": False, "error": "Пользователь не найден"}, status_code=404)
# Обновляем сессию (создаем новую и отзываем старую)
device_info = {"ip": request.client.host, "user_agent": request.headers.get("user-agent")}
new_token = await SessionManager.refresh_session(user_id, token, device_info)
if not new_token:
return JSONResponse(
{"success": False, "error": "Не удалось обновить токен"}, status_code=500
)
# Создаем ответ
response = JSONResponse(
{
"success": True,
"token": new_token,
"author": {"id": author.id, "email": author.email, "name": author.name},
}
)
# Устанавливаем cookie с новым токеном
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=new_token,
httponly=SESSION_COOKIE_HTTPONLY,
secure=SESSION_COOKIE_SECURE,
samesite=SESSION_COOKIE_SAMESITE,
max_age=SESSION_COOKIE_MAX_AGE,
)
logger.info(f"[auth] refresh_token: Токен успешно обновлен для пользователя {user_id}")
return response
except Exception as e:
logger.error(f"[auth] refresh_token: Ошибка при обновлении токена: {e}")
return JSONResponse({"success": False, "error": str(e)}, status_code=401)
# Маршруты для авторизации
routes = [
Route("/auth/logout", logout, methods=["GET", "POST"]),
Route("/auth/refresh", refresh_token, methods=["POST"]),
]

View File

@@ -1,54 +1,72 @@
from functools import wraps
from typing import Optional, Tuple
from typing import Optional
from graphql.type import GraphQLResolveInfo
from sqlalchemy.orm import exc, joinedload
from sqlalchemy.orm import exc
from starlette.authentication import AuthenticationBackend
from starlette.requests import HTTPConnection
from auth.credentials import AuthCredentials, AuthUser
from auth.credentials import AuthCredentials
from auth.exceptions import OperationNotAllowed
from auth.tokenstorage import SessionToken
from auth.usermodel import Role, User
from auth.sessions import SessionManager
from auth.orm import Author
from services.db import local_session
from settings import SESSION_TOKEN_HEADER
class JWTAuthenticate(AuthenticationBackend):
async def authenticate(self, request: HTTPConnection) -> Optional[Tuple[AuthCredentials, AuthUser]]:
async def authenticate(self, request: HTTPConnection) -> Optional[AuthCredentials]:
"""
Аутентификация пользователя по JWT токену.
Args:
request: HTTP запрос
Returns:
AuthCredentials при успешной аутентификации или None при ошибке
"""
if SESSION_TOKEN_HEADER not in request.headers:
return AuthCredentials(scopes={}), AuthUser(user_id=None, username="")
return None
token = request.headers.get(SESSION_TOKEN_HEADER)
if not token:
auth_header = request.headers.get(SESSION_TOKEN_HEADER)
if not auth_header:
print("[auth.authenticate] no token in header %s" % SESSION_TOKEN_HEADER)
return AuthCredentials(scopes={}, error_message=str("no token")), AuthUser(user_id=None, username="")
return None
if len(token.split(".")) > 1:
payload = await SessionToken.verify(token)
# Обработка формата "Bearer <token>"
token = auth_header
if auth_header.startswith("Bearer "):
token = auth_header.replace("Bearer ", "", 1).strip()
with local_session() as session:
try:
user = (
session.query(User)
.options(
joinedload(User.roles).options(joinedload(Role.permissions)),
joinedload(User.ratings),
)
.filter(User.id == payload.user_id)
.one()
)
if not token:
print("[auth.authenticate] empty token after Bearer prefix removal")
return None
scopes = {} # TODO: integrate await user.get_permission()
# Проверяем сессию в Redis
payload = await SessionManager.verify_session(token)
if not payload:
return None
return (
AuthCredentials(user_id=payload.user_id, scopes=scopes, logged_in=True),
AuthUser(user_id=user.id, username=""),
)
except exc.NoResultFound:
pass
with local_session() as session:
try:
author = (
session.query(Author)
.filter(Author.id == payload.user_id)
.filter(Author.is_active == True) # noqa
.one()
)
return AuthCredentials(scopes={}, error_message=str("Invalid token")), AuthUser(user_id=None, username="")
if author.is_locked():
return None
# Получаем разрешения из ролей
scopes = author.get_permissions()
return AuthCredentials(
author_id=author.id, scopes=scopes, logged_in=True, email=author.email
)
except exc.NoResultFound:
return None
def login_required(func):
@@ -62,15 +80,34 @@ def login_required(func):
return wrap
def permission_required(resource, operation, func):
def permission_required(resource: str, operation: str, func):
"""
Декоратор для проверки разрешений.
Args:
resource (str): Ресурс для проверки
operation (str): Операция для проверки
func: Декорируемая функция
"""
@wraps(func)
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
print("[auth.authenticate] permission_required for %r with info %r" % (func, info)) # debug only
auth: AuthCredentials = info.context["request"].auth
if not auth.logged_in:
raise OperationNotAllowed(auth.error_message or "Please login")
# TODO: add actual check permission logix here
with local_session() as session:
author = session.query(Author).filter(Author.id == auth.author_id).one()
# Проверяем базовые условия
if not author.is_active:
raise OperationNotAllowed("Account is not active")
if author.is_locked():
raise OperationNotAllowed("Account is locked")
# Проверяем разрешение
if not author.has_permission(resource, operation):
raise OperationNotAllowed(f"No permission for {operation} on {resource}")
return await func(parent, info, *args, **kwargs)
@@ -82,12 +119,12 @@ def login_accepted(func):
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
auth: AuthCredentials = info.context["request"].auth
# Если есть авторизация, добавляем данные автора в контекст
if auth and auth.logged_in:
info.context["author"] = auth.author
info.context["user_id"] = auth.author.get("id")
with local_session() as session:
author = session.query(Author).filter(Author.id == auth.author_id).one()
info.context["author"] = author.dict()
info.context["user_id"] = author.id
else:
# Очищаем данные автора из контекста если авторизация отсутствует
info.context["author"] = None
info.context["user_id"] = None

View File

@@ -1,43 +1,94 @@
from typing import List, Optional, Text
from typing import Dict, List, Optional, Set, Any
from pydantic import BaseModel
from pydantic import BaseModel, Field
# from base.exceptions import Unauthorized
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
class Permission(BaseModel):
name: Text
"""Модель разрешения для RBAC"""
resource: str
operation: str
def __str__(self) -> str:
return f"{self.resource}:{self.operation}"
class AuthCredentials(BaseModel):
user_id: Optional[int] = None
scopes: Optional[dict] = {}
logged_in: bool = False
error_message: str = ""
"""
Модель учетных данных авторизации.
Используется как часть механизма аутентификации Starlette.
"""
author_id: Optional[int] = Field(None, description="ID автора")
scopes: Dict[str, Set[str]] = Field(default_factory=dict, description="Разрешения пользователя")
logged_in: bool = Field(False, description="Флаг, указывающий, авторизован ли пользователь")
error_message: str = Field("", description="Сообщение об ошибке аутентификации")
email: Optional[str] = Field(None, description="Email пользователя")
def get_permissions(self) -> List[str]:
"""
Возвращает список строковых представлений разрешений.
Например: ["posts:read", "posts:write", "comments:create"].
Returns:
List[str]: Список разрешений
"""
result = []
for resource, operations in self.scopes.items():
for operation in operations:
result.append(f"{resource}:{operation}")
return result
def has_permission(self, resource: str, operation: str) -> bool:
"""
Проверяет наличие определенного разрешения.
Args:
resource: Ресурс (например, "posts")
operation: Операция (например, "read")
Returns:
bool: True, если пользователь имеет указанное разрешение
"""
if not self.logged_in:
return False
return resource in self.scopes and operation in self.scopes[resource]
@property
def is_admin(self):
# TODO: check admin logix
return True
def is_admin(self) -> bool:
"""
Проверяет, является ли пользователь администратором.
Returns:
bool: True, если email пользователя находится в списке ADMIN_EMAILS
"""
return self.email in ADMIN_EMAILS if self.email else False
def to_dict(self) -> Dict[str, Any]:
"""
Преобразует учетные данные в словарь
Returns:
Dict[str, Any]: Словарь с данными учетных данных
"""
return {
"author_id": self.author_id,
"logged_in": self.logged_in,
"is_admin": self.is_admin,
"permissions": self.get_permissions(),
}
async def permissions(self) -> List[Permission]:
if self.user_id is None:
if self.author_id is None:
# raise Unauthorized("Please login first")
return {"error": "Please login first"}
else:
# TODO: implement permissions logix
print(self.user_id)
print(self.author_id)
return NotImplemented
class AuthUser(BaseModel):
user_id: Optional[int]
username: Optional[str]
@property
def is_authenticated(self) -> bool:
return self.user_id is not None
# @property
# def display_id(self) -> int:
# return self.user_id

142
auth/decorators.py Normal file
View File

@@ -0,0 +1,142 @@
from functools import wraps
from typing import Callable, Any
from graphql import GraphQLError
from services.db import local_session
from auth.orm import Author
from auth.exceptions import OperationNotAllowed
from utils.logger import root_logger as logger
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
def admin_auth_required(resolver: Callable) -> Callable:
"""
Декоратор для защиты админских эндпоинтов.
Проверяет принадлежность к списку разрешенных email-адресов.
Args:
resolver: GraphQL резолвер для защиты
Returns:
Обернутый резолвер, который проверяет права доступа
Raises:
GraphQLError: если пользователь не авторизован или не имеет доступа администратора
"""
@wraps(resolver)
async def wrapper(root: Any = None, info: Any = None, **kwargs):
try:
# Проверяем наличие info и контекста
if info is None or not hasattr(info, "context"):
logger.error("Missing GraphQL context information")
raise GraphQLError("Internal server error: missing context")
# Получаем ID пользователя из контекста запроса
request = info.context.get("request")
if not request or not hasattr(request, "auth"):
logger.error("Missing request or auth object in context")
raise GraphQLError("Internal server error: missing auth")
auth = request.auth
if not auth or not auth.logged_in:
client_info = {
"ip": request.client.host if hasattr(request, "client") else "unknown",
"headers": dict(request.headers),
}
logger.error(f"Unauthorized access attempt for admin endpoint: {client_info}")
raise GraphQLError("Unauthorized")
# Проверяем принадлежность к списку админов
with local_session() as session:
try:
author = session.query(Author).filter(Author.id == auth.author_id).one()
# Проверка по email
if author.email in ADMIN_EMAILS:
logger.info(
f"Admin access granted for {author.email} (special admin, ID: {author.id})"
)
return await resolver(root, info, **kwargs)
else:
logger.warning(
f"Admin access denied for {author.email} (ID: {author.id}) - not in admin list"
)
raise GraphQLError("Unauthorized - not an admin")
except Exception as db_error:
logger.error(f"Error fetching author with ID {auth.author_id}: {str(db_error)}")
raise GraphQLError("Unauthorized - user not found")
except Exception as e:
# Если ошибка уже GraphQLError, просто перебрасываем её
if isinstance(e, GraphQLError):
logger.error(f"GraphQL error in admin_auth_required: {str(e)}")
raise e
# Иначе, создаем новую GraphQLError
logger.error(f"Error in admin_auth_required: {str(e)}")
raise GraphQLError(f"Admin access error: {str(e)}")
return wrapper
def require_permission(permission_string: str):
"""
Декоратор для проверки наличия указанного разрешения.
Принимает строку в формате "resource:permission".
Args:
permission_string: Строка в формате "resource:permission"
Returns:
Декоратор, проверяющий наличие указанного разрешения
Raises:
ValueError: если строка разрешения имеет неверный формат
"""
if ":" not in permission_string:
raise ValueError('Permission string must be in format "resource:permission"')
resource, operation = permission_string.split(":", 1)
def decorator(func: Callable) -> Callable:
@wraps(func)
async def wrapper(parent, info: Any = None, *args, **kwargs):
# Проверяем наличие info и контекста
if info is None or not hasattr(info, "context"):
logger.error("Missing GraphQL context information in require_permission")
raise OperationNotAllowed("Internal server error: missing context")
auth = info.context["request"].auth
if not auth or not auth.logged_in:
raise OperationNotAllowed("Unauthorized - please login")
with local_session() as session:
try:
author = session.query(Author).filter(Author.id == auth.author_id).one()
# Проверяем базовые условия
if not author.is_active:
raise OperationNotAllowed("Account is not active")
if author.is_locked():
raise OperationNotAllowed("Account is locked")
# Проверяем разрешение
if not author.has_permission(resource, operation):
logger.warning(
f"Access denied for user {auth.author_id} - no permission {resource}:{operation}"
)
raise OperationNotAllowed(f"No permission for {operation} on {resource}")
# Пользователь аутентифицирован и имеет необходимое разрешение
return await func(parent, info, *args, **kwargs)
except Exception as e:
logger.error(f"Error in require_permission: {e}")
if isinstance(e, OperationNotAllowed):
raise e
raise OperationNotAllowed(str(e))
return wrapper
return decorator

View File

@@ -1,16 +1,21 @@
from binascii import hexlify
from hashlib import sha256
from typing import Any, Dict, TypeVar, TYPE_CHECKING
from passlib.hash import bcrypt
from auth.exceptions import ExpiredToken, InvalidToken
from auth.exceptions import ExpiredToken, InvalidToken, InvalidPassword
from auth.jwtcodec import JWTCodec
from auth.tokenstorage import TokenStorage
from orm.user import User
# from base.exceptions import InvalidPassword, InvalidToken
from services.db import local_session
# Для типизации
if TYPE_CHECKING:
from auth.orm import Author
AuthorType = TypeVar("AuthorType", bound="Author")
class Password:
@staticmethod
@@ -24,6 +29,15 @@ class Password:
@staticmethod
def encode(password: str) -> str:
"""
Кодирует пароль пользователя
Args:
password (str): Пароль пользователя
Returns:
str: Закодированный пароль
"""
password_sha256 = Password._get_sha256(password)
return bcrypt.using(rounds=10).hash(password_sha256)
@@ -52,28 +66,93 @@ class Password:
class Identity:
@staticmethod
def password(orm_user: User, password: str) -> User:
user = User(**orm_user.dict())
if not user.password:
# raise InvalidPassword("User password is empty")
return {"error": "User password is empty"}
if not Password.verify(password, user.password):
# raise InvalidPassword("Wrong user password")
return {"error": "Wrong user password"}
return user
def password(orm_author: Any, password: str) -> Any:
"""
Проверяет пароль пользователя
Args:
orm_author (Author): Объект пользователя
password (str): Пароль пользователя
Returns:
Author: Объект автора при успешной проверке
Raises:
InvalidPassword: Если пароль не соответствует хешу или отсутствует
"""
# Импортируем внутри функции для избежания циклических импортов
from auth.orm import Author
from utils.logger import root_logger as logger
# Проверим исходный пароль в orm_author
if not orm_author.password:
logger.warning(
f"[auth.identity] Пароль в исходном объекте автора пуст: email={orm_author.email}"
)
raise InvalidPassword("Пароль не установлен для данного пользователя")
# Проверим словарь до создания нового объекта
author_dict = orm_author.dict()
if "password" not in author_dict or not author_dict["password"]:
logger.warning(
f"[auth.identity] Пароль отсутствует в dict() или пуст: email={orm_author.email}"
)
raise InvalidPassword("Пароль отсутствует в данных пользователя")
# Создаем новый объект автора
author = Author(**author_dict)
if not author.password:
logger.warning(
f"[auth.identity] Пароль в созданном объекте автора пуст: email={orm_author.email}"
)
raise InvalidPassword("Пароль не установлен для данного пользователя")
# Проверяем пароль
if not Password.verify(password, author.password):
logger.warning(f"[auth.identity] Неверный пароль для {orm_author.email}")
raise InvalidPassword("Неверный пароль пользователя")
# Возвращаем исходный объект, чтобы сохранить все связи
return orm_author
@staticmethod
def oauth(inp) -> User:
def oauth(inp: Dict[str, Any]) -> Any:
"""
Создает нового пользователя OAuth, если он не существует
Args:
inp (dict): Данные OAuth пользователя
Returns:
Author: Объект пользователя
"""
# Импортируем внутри функции для избежания циклических импортов
from auth.orm import Author
with local_session() as session:
user = session.query(User).filter(User.email == inp["email"]).first()
if not user:
user = User.create(**inp, emailConfirmed=True)
author = session.query(Author).filter(Author.email == inp["email"]).first()
if not author:
author = Author(**inp)
author.email_verified = True
session.add(author)
session.commit()
return user
return author
@staticmethod
async def onetime(token: str) -> User:
async def onetime(token: str) -> Any:
"""
Проверяет одноразовый токен
Args:
token (str): Одноразовый токен
Returns:
Author: Объект пользователя
"""
# Импортируем внутри функции для избежания циклических импортов
from auth.orm import Author
try:
print("[auth.identity] using one time token")
payload = JWTCodec.decode(token)
@@ -87,11 +166,11 @@ class Identity:
# raise InvalidToken("token format error") from e
return {"error": "Token format error"}
with local_session() as session:
user = session.query(User).filter_by(id=payload.user_id).first()
if not user:
author = session.query(Author).filter_by(id=payload.user_id).first()
if not author:
# raise Exception("user not exist")
return {"error": "User does not exist"}
if not user.emailConfirmed:
user.emailConfirmed = True
return {"error": "Author does not exist"}
if not author.email_verified:
author.email_verified = True
session.commit()
return user
return author

168
auth/internal.py Normal file
View File

@@ -0,0 +1,168 @@
from typing import Optional, Tuple
import time
from sqlalchemy.orm import exc
from starlette.authentication import AuthenticationBackend, BaseUser, UnauthenticatedUser
from starlette.requests import HTTPConnection
from auth.credentials import AuthCredentials
from auth.orm import Author
from auth.sessions import SessionManager
from services.db import local_session
from settings import SESSION_TOKEN_HEADER
from utils.logger import root_logger as logger
class AuthenticatedUser(BaseUser):
"""Аутентифицированный пользователь для Starlette"""
def __init__(self, user_id: str, username: str = "", roles: list = None, permissions: dict = None):
self.user_id = user_id
self.username = username
self.roles = roles or []
self.permissions = permissions or {}
@property
def is_authenticated(self) -> bool:
return True
@property
def display_name(self) -> str:
return self.username
@property
def identity(self) -> str:
return self.user_id
class InternalAuthentication(AuthenticationBackend):
"""Внутренняя аутентификация через базу данных и Redis"""
async def authenticate(self, request: HTTPConnection):
"""
Аутентифицирует пользователя по токену из заголовка.
Токен должен быть обработан заранее AuthorizationMiddleware,
который извлекает Bearer токен и преобразует его в чистый токен.
Возвращает:
tuple: (AuthCredentials, BaseUser)
"""
if SESSION_TOKEN_HEADER not in request.headers:
return AuthCredentials(scopes={}), UnauthenticatedUser()
token = request.headers.get(SESSION_TOKEN_HEADER)
if not token:
logger.debug("[auth.authenticate] Пустой токен в заголовке")
return AuthCredentials(scopes={}, error_message="no token"), UnauthenticatedUser()
# Проверяем сессию в Redis
payload = await SessionManager.verify_session(token)
if not payload:
logger.debug("[auth.authenticate] Недействительный токен")
return AuthCredentials(scopes={}, error_message="Invalid token"), UnauthenticatedUser()
with local_session() as session:
try:
author = (
session.query(Author)
.filter(Author.id == payload.user_id)
.filter(Author.is_active == True) # noqa
.one()
)
if author.is_locked():
logger.debug(f"[auth.authenticate] Аккаунт заблокирован: {author.id}")
return AuthCredentials(
scopes={}, error_message="Account is locked"
), UnauthenticatedUser()
# Получаем разрешения из ролей
scopes = author.get_permissions()
# Получаем роли для пользователя
roles = [role.id for role in author.roles] if author.roles else []
# Обновляем last_seen
author.last_seen = int(time.time())
session.commit()
# Создаем объекты авторизации
credentials = AuthCredentials(
author_id=author.id, scopes=scopes, logged_in=True, email=author.email
)
user = AuthenticatedUser(
user_id=str(author.id),
username=author.slug or author.email or "",
roles=roles,
permissions=scopes,
)
logger.debug(f"[auth.authenticate] Успешная аутентификация: {author.email}")
return credentials, user
except exc.NoResultFound:
logger.debug("[auth.authenticate] Пользователь не найден")
return AuthCredentials(scopes={}, error_message="User not found"), UnauthenticatedUser()
async def verify_internal_auth(token: str) -> Tuple[str, list]:
"""
Проверяет локальную авторизацию.
Возвращает user_id и список ролей.
Args:
token: Токен авторизации (может быть как с Bearer, так и без)
Returns:
tuple: (user_id, roles)
"""
# Обработка формата "Bearer <token>" (если токен не был обработан ранее)
if token.startswith("Bearer "):
token = token.replace("Bearer ", "", 1).strip()
# Проверяем сессию
payload = await SessionManager.verify_session(token)
if not payload:
return "", []
with local_session() as session:
try:
author = (
session.query(Author)
.filter(Author.id == payload.user_id)
.filter(Author.is_active == True) # noqa
.one()
)
# Получаем роли
roles = [role.id for role in author.roles]
return str(author.id), roles
except exc.NoResultFound:
return "", []
async def create_internal_session(author: Author, device_info: Optional[dict] = None) -> str:
"""
Создает новую сессию для автора
Args:
author: Объект автора
device_info: Информация об устройстве (опционально)
Returns:
str: Токен сессии
"""
# Сбрасываем счетчик неудачных попыток
author.reset_failed_login()
# Обновляем last_login
author.last_login = int(time.time())
# Создаем сессию, используя token для идентификации
return await SessionManager.create_session(
user_id=str(author.id),
username=author.slug or author.email or author.phone or "",
device_info=device_info,
)

View File

@@ -20,7 +20,7 @@ class JWTCodec:
def encode(user, exp: datetime) -> str:
payload = {
"user_id": user.id,
"username": user.email or user.phone,
"username": user.slug or user.email or user.phone or "",
"exp": exp,
"iat": datetime.now(tz=timezone.utc),
"iss": "discours",
@@ -50,11 +50,13 @@ class JWTCodec:
return r
except jwt.InvalidIssuedAtError:
print("[auth.jwtcodec] invalid issued at: %r" % payload)
raise ExpiredToken("check token issued time")
raise ExpiredToken("jwt check token issued time")
except jwt.ExpiredSignatureError:
print("[auth.jwtcodec] expired signature %r" % payload)
raise ExpiredToken("check token lifetime")
except jwt.InvalidTokenError:
raise InvalidToken("token is not valid")
raise ExpiredToken("jwt check token lifetime")
except jwt.InvalidSignatureError:
raise InvalidToken("token is not valid")
raise InvalidToken("jwt check signature is not valid")
except jwt.InvalidTokenError:
raise InvalidToken("jwt check token is not valid")
except jwt.InvalidKeyError:
raise InvalidToken("jwt check key is not valid")

110
auth/middleware.py Normal file
View File

@@ -0,0 +1,110 @@
"""
Middleware для обработки авторизации в GraphQL запросах
"""
from starlette.datastructures import Headers
from starlette.types import ASGIApp, Scope, Receive, Send
from utils.logger import root_logger as logger
from settings import SESSION_TOKEN_HEADER, SESSION_COOKIE_NAME
class AuthorizationMiddleware:
"""
Middleware для обработки заголовка Authorization и cookie авторизации.
Извлекает Bearer токен из заголовка или cookie и добавляет его в заголовки
запроса для обработки стандартным AuthenticationMiddleware Starlette.
"""
def __init__(self, app: ASGIApp):
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
# Извлекаем заголовки
headers = Headers(scope=scope)
auth_header = headers.get(SESSION_TOKEN_HEADER)
token = None
# Сначала пробуем получить токен из заголовка Authorization
if auth_header:
if auth_header.startswith("Bearer "):
token = auth_header.replace("Bearer ", "", 1).strip()
logger.debug(
f"[middleware] Извлечен Bearer токен из заголовка, длина: {len(token) if token else 0}"
)
# Если токен не получен из заголовка, пробуем взять из cookie
if not token:
cookies = headers.get("cookie", "")
cookie_items = cookies.split(";")
for item in cookie_items:
if "=" in item:
name, value = item.split("=", 1)
if name.strip() == SESSION_COOKIE_NAME:
token = value.strip()
logger.debug(
f"[middleware] Извлечен токен из cookie, длина: {len(token) if token else 0}"
)
break
# Если токен получен, обновляем заголовки в scope
if token:
# Создаем новый список заголовков
new_headers = []
for name, value in scope["headers"]:
# Пропускаем оригинальный заголовок авторизации
if name.decode("latin1").lower() != SESSION_TOKEN_HEADER.lower():
new_headers.append((name, value))
# Добавляем заголовок с чистым токеном
new_headers.append((SESSION_TOKEN_HEADER.encode("latin1"), token.encode("latin1")))
# Обновляем заголовки в scope
scope["headers"] = new_headers
# Также добавляем информацию о типе аутентификации для дальнейшего использования
if "auth" not in scope:
scope["auth"] = {"type": "bearer", "token": token}
await self.app(scope, receive, send)
class GraphQLExtensionsMiddleware:
"""
Утилиты для расширения контекста GraphQL запросов
"""
def set_cookie(self, key, value, **options):
"""Устанавливает cookie в ответе"""
context = getattr(self, "_context", None)
if context and "response" in context and hasattr(context["response"], "set_cookie"):
context["response"].set_cookie(key, value, **options)
def delete_cookie(self, key, **options):
"""Удаляет cookie из ответа"""
context = getattr(self, "_context", None)
if context and "response" in context and hasattr(context["response"], "delete_cookie"):
context["response"].delete_cookie(key, **options)
async def resolve(self, next, root, info, *args, **kwargs):
"""
Middleware для обработки запросов GraphQL.
Добавляет методы для установки cookie в контекст.
"""
try:
# Получаем доступ к контексту запроса
context = info.context
# Сохраняем ссылку на контекст
self._context = context
# Добавляем себя как объект, содержащий утилитные методы
context["extensions"] = self
return await next(root, info, *args, **kwargs)
except Exception as e:
logger.error(f"[GraphQLExtensionsMiddleware] Ошибка: {str(e)}")
raise

View File

@@ -1,98 +1,189 @@
from authlib.integrations.starlette_client import OAuth
from starlette.responses import RedirectResponse
from authlib.oauth2.rfc7636 import create_s256_code_challenge
from starlette.responses import RedirectResponse, JSONResponse
from secrets import token_urlsafe
import time
from auth.identity import Identity
from auth.tokenstorage import TokenStorage
from auth.orm import Author
from services.db import local_session
from settings import FRONTEND_URL, OAUTH_CLIENTS
oauth = OAuth()
oauth.register(
name="facebook",
client_id=OAUTH_CLIENTS["FACEBOOK"]["id"],
client_secret=OAUTH_CLIENTS["FACEBOOK"]["key"],
access_token_url="https://graph.facebook.com/v11.0/oauth/access_token",
access_token_params=None,
authorize_url="https://www.facebook.com/v11.0/dialog/oauth",
authorize_params=None,
api_base_url="https://graph.facebook.com/",
client_kwargs={"scope": "public_profile email"},
)
oauth.register(
name="github",
client_id=OAUTH_CLIENTS["GITHUB"]["id"],
client_secret=OAUTH_CLIENTS["GITHUB"]["key"],
access_token_url="https://github.com/login/oauth/access_token",
access_token_params=None,
authorize_url="https://github.com/login/oauth/authorize",
authorize_params=None,
api_base_url="https://api.github.com/",
client_kwargs={"scope": "user:email"},
)
oauth.register(
name="google",
client_id=OAUTH_CLIENTS["GOOGLE"]["id"],
client_secret=OAUTH_CLIENTS["GOOGLE"]["key"],
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_kwargs={"scope": "openid email profile"},
authorize_state="test",
)
async def google_profile(client, request, token):
userinfo = token["userinfo"]
profile = {"name": userinfo["name"], "email": userinfo["email"], "id": userinfo["sub"]}
if userinfo["picture"]:
userpic = userinfo["picture"].replace("=s96", "=s600")
profile["userpic"] = userpic
return profile
async def facebook_profile(client, request, token):
profile = await client.get("me?fields=name,id,email", token=token)
return profile.json()
async def github_profile(client, request, token):
profile = await client.get("user", token=token)
return profile.json()
profile_callbacks = {
"google": google_profile,
"facebook": facebook_profile,
"github": github_profile,
# Конфигурация провайдеров
PROVIDERS = {
"google": {
"name": "google",
"server_metadata_url": "https://accounts.google.com/.well-known/openid-configuration",
"client_kwargs": {"scope": "openid email profile", "prompt": "select_account"},
},
"github": {
"name": "github",
"access_token_url": "https://github.com/login/oauth/access_token",
"authorize_url": "https://github.com/login/oauth/authorize",
"api_base_url": "https://api.github.com/",
"client_kwargs": {"scope": "user:email"},
},
"facebook": {
"name": "facebook",
"access_token_url": "https://graph.facebook.com/v13.0/oauth/access_token",
"authorize_url": "https://www.facebook.com/v13.0/dialog/oauth",
"api_base_url": "https://graph.facebook.com/",
"client_kwargs": {"scope": "public_profile email"},
},
}
# Регистрация провайдеров
for provider, config in PROVIDERS.items():
if provider in OAUTH_CLIENTS:
oauth.register(
name=config["name"],
client_id=OAUTH_CLIENTS[provider.upper()]["id"],
client_secret=OAUTH_CLIENTS[provider.upper()]["key"],
**config,
)
async def get_user_profile(provider: str, client, token) -> dict:
"""Получает профиль пользователя от провайдера OAuth"""
if provider == "google":
userinfo = token.get("userinfo", {})
return {
"id": userinfo.get("sub"),
"email": userinfo.get("email"),
"name": userinfo.get("name"),
"picture": userinfo.get("picture", "").replace("=s96", "=s600"),
}
elif provider == "github":
profile = await client.get("user", token=token)
profile_data = profile.json()
emails = await client.get("user/emails", token=token)
emails_data = emails.json()
primary_email = next((email["email"] for email in emails_data if email["primary"]), None)
return {
"id": str(profile_data["id"]),
"email": primary_email or profile_data.get("email"),
"name": profile_data.get("name") or profile_data.get("login"),
"picture": profile_data.get("avatar_url"),
}
elif provider == "facebook":
profile = await client.get("me?fields=id,name,email,picture.width(600)", token=token)
profile_data = profile.json()
return {
"id": profile_data["id"],
"email": profile_data.get("email"),
"name": profile_data.get("name"),
"picture": profile_data.get("picture", {}).get("data", {}).get("url"),
}
return {}
async def oauth_login(request):
"""Начинает процесс OAuth авторизации"""
provider = request.path_params["provider"]
if provider not in PROVIDERS:
return JSONResponse({"error": "Invalid provider"}, status_code=400)
client = oauth.create_client(provider)
if not client:
return JSONResponse({"error": "Provider not configured"}, status_code=400)
# Генерируем PKCE challenge
code_verifier = token_urlsafe(32)
code_challenge = create_s256_code_challenge(code_verifier)
# Сохраняем code_verifier в сессии
request.session["code_verifier"] = code_verifier
request.session["provider"] = provider
client = oauth.create_client(provider)
redirect_uri = "https://v2.discours.io/oauth-authorize"
return await client.authorize_redirect(request, redirect_uri)
request.session["state"] = token_urlsafe(16)
redirect_uri = f"{FRONTEND_URL}/oauth/callback"
try:
return await client.authorize_redirect(
request,
redirect_uri,
code_challenge=code_challenge,
code_challenge_method="S256",
state=request.session["state"],
)
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
async def oauth_authorize(request):
provider = request.session["provider"]
client = oauth.create_client(provider)
token = await client.authorize_access_token(request)
get_profile = profile_callbacks[provider]
profile = await get_profile(client, request, token)
user_oauth_info = "%s:%s" % (provider, profile["id"])
user_input = {
"oauth": user_oauth_info,
"email": profile["email"],
"username": profile["name"],
"userpic": profile["userpic"],
}
user = Identity.oauth(user_input)
session_token = await TokenStorage.create_session(user)
response = RedirectResponse(url=FRONTEND_URL + "/confirm")
response.set_cookie("token", session_token)
return response
async def oauth_callback(request):
"""Обрабатывает callback от OAuth провайдера"""
try:
provider = request.session.get("provider")
if not provider:
return JSONResponse({"error": "No active OAuth session"}, status_code=400)
# Проверяем state
state = request.query_params.get("state")
if state != request.session.get("state"):
return JSONResponse({"error": "Invalid state"}, status_code=400)
client = oauth.create_client(provider)
if not client:
return JSONResponse({"error": "Provider not configured"}, status_code=400)
# Получаем токен с PKCE verifier
token = await client.authorize_access_token(
request, code_verifier=request.session.get("code_verifier")
)
# Получаем профиль пользователя
profile = await get_user_profile(provider, client, token)
if not profile.get("email"):
return JSONResponse({"error": "Email not provided"}, status_code=400)
# Создаем или обновляем пользователя
with local_session() as session:
author = session.query(Author).filter(Author.email == profile["email"]).first()
if not author:
author = Author(
email=profile["email"],
name=profile["name"],
username=profile["name"],
pic=profile.get("picture"),
oauth=f"{provider}:{profile['id']}",
email_verified=True,
created_at=int(time.time()),
updated_at=int(time.time()),
last_seen=int(time.time()),
)
session.add(author)
else:
author.name = profile["name"]
author.pic = profile.get("picture") or author.pic
author.oauth = f"{provider}:{profile['id']}"
author.email_verified = True
author.updated_at = int(time.time())
author.last_seen = int(time.time())
session.commit()
# Создаем сессию
session_token = await TokenStorage.create_session(author)
# Очищаем сессию OAuth
request.session.pop("code_verifier", None)
request.session.pop("provider", None)
request.session.pop("state", None)
# Возвращаем токен через cookie
response = RedirectResponse(url=f"{FRONTEND_URL}/auth/success")
response.set_cookie(
"session_token",
session_token,
httponly=True,
secure=True,
samesite="lax",
max_age=30 * 24 * 60 * 60, # 30 days
)
return response
except Exception as e:
return RedirectResponse(url=f"{FRONTEND_URL}/auth/error?message={str(e)}")

259
auth/orm.py Normal file
View File

@@ -0,0 +1,259 @@
import time
from typing import Dict, Set
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String
from sqlalchemy.orm import relationship
from auth.identity import Password
from services.db import Base
# from sqlalchemy_utils import TSVectorType
# Общие 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},
)
id = None # type: ignore
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},
)
id = None # type: ignore
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
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)
class RolePermission(Base):
"""Связь роли с разрешениями"""
__tablename__ = "role_permission"
__table_args__ = {"extend_existing": True}
id = None
role = Column(ForeignKey("role.id"), primary_key=True, index=True)
permission = Column(ForeignKey("permission.id"), primary_key=True, index=True)
class Permission(Base):
"""Модель разрешения в системе RBAC"""
__tablename__ = "permission"
__table_args__ = {"extend_existing": True}
id = Column(String, primary_key=True, unique=True, nullable=False, default=None)
resource = Column(String, nullable=False)
operation = Column(String, nullable=False)
class Role(Base):
"""Модель роли в системе RBAC"""
__tablename__ = "role"
__table_args__ = {"extend_existing": True}
id = Column(String, primary_key=True, unique=True, nullable=False, default=None)
name = Column(String, nullable=False)
permissions = relationship(Permission, secondary="role_permission", lazy="joined")
class AuthorRole(Base):
"""Связь автора с ролями"""
__tablename__ = "author_role"
__table_args__ = {"extend_existing": True}
id = None
community = Column(ForeignKey("community.id"), primary_key=True, index=True)
author = Column(ForeignKey("author.id"), primary_key=True, index=True)
role = Column(ForeignKey("role.id"), primary_key=True, index=True)
class Author(Base):
"""
Расширенная модель автора с функциями аутентификации и авторизации
"""
__tablename__ = "author"
__table_args__ = (
Index("idx_author_slug", "slug"),
Index("idx_author_email", "email"),
Index("idx_author_phone", "phone"),
{"extend_existing": True},
)
# Базовые поля автора
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")
# Дополнительные поля из User
oauth = Column(String, nullable=True, comment="OAuth provider")
oid = Column(String, nullable=True, comment="OAuth ID")
muted = Column(Boolean, default=False, comment="Is author muted")
# Поля аутентификации
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")
is_active = Column(Boolean, default=True, nullable=False)
email_verified = Column(Boolean, default=False)
phone_verified = Column(Boolean, default=False)
last_login = Column(Integer, nullable=True)
failed_login_attempts = Column(Integer, default=0)
account_locked_until = 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)
# Связи с ролями
roles = relationship(Role, secondary="author_role", lazy="joined")
# search_vector = Column(
# TSVectorType("name", "slug", "bio", "about", regconfig="pg_catalog.russian")
# )
@property
def is_authenticated(self) -> bool:
"""Проверяет, аутентифицирован ли пользователь"""
return self.id is not None
def get_permissions(self) -> Dict[str, Set[str]]:
"""Получает все разрешения пользователя"""
permissions: Dict[str, Set[str]] = {}
for role in self.roles:
for permission in role.permissions:
if permission.resource not in permissions:
permissions[permission.resource] = set()
permissions[permission.resource].add(permission.operation)
return permissions
def has_permission(self, resource: str, operation: str) -> bool:
"""Проверяет наличие разрешения у пользователя"""
permissions = self.get_permissions()
return resource in permissions and operation in permissions[resource]
def verify_password(self, password: str) -> bool:
"""Проверяет пароль пользователя"""
return Password.verify(password, self.password) if self.password else False
def set_password(self, password: str):
"""Устанавливает пароль пользователя"""
self.password = Password.encode(password)
def increment_failed_login(self):
"""Увеличивает счетчик неудачных попыток входа"""
self.failed_login_attempts += 1
if self.failed_login_attempts >= 5:
self.account_locked_until = int(time.time()) + 300 # 5 минут
def reset_failed_login(self):
"""Сбрасывает счетчик неудачных попыток входа"""
self.failed_login_attempts = 0
self.account_locked_until = None
def is_locked(self) -> bool:
"""Проверяет, заблокирован ли аккаунт"""
if not self.account_locked_until:
return False
return self.account_locked_until > int(time.time())
@property
def username(self) -> str:
"""
Возвращает имя пользователя для использования в токенах.
Необходимо для совместимости с TokenStorage и JWTCodec.
Returns:
str: slug, email или phone пользователя
"""
return self.slug or self.email or self.phone or ""
def dict(self) -> Dict:
"""Преобразует объект Author в словарь"""
return {
"id": self.id,
"slug": self.slug,
"name": self.name,
"bio": self.bio,
"about": self.about,
"pic": self.pic,
"links": self.links,
"email": self.email,
"password": self.password,
"created_at": self.created_at,
"updated_at": self.updated_at,
"last_seen": self.last_seen,
"deleted_at": self.deleted_at,
"roles": [role.id for role in self.roles],
"email_verified": self.email_verified,
}

242
auth/permissions.py Normal file
View File

@@ -0,0 +1,242 @@
"""
Модуль для проверки разрешений пользователей в контексте сообществ.
Позволяет проверять доступ пользователя к определенным операциям в сообществе
на основе его роли в этом сообществе.
"""
from typing import List, Union
from sqlalchemy.orm import Session
from auth.orm import Author, Role, RolePermission, Permission
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
from orm.community import Community, CommunityFollower, CommunityRole
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
class ContextualPermissionCheck:
"""
Класс для проверки контекстно-зависимых разрешений.
Позволяет проверять разрешения пользователя в контексте сообщества,
учитывая как глобальные роли пользователя, так и его роли внутри сообщества.
"""
# Маппинг из ролей сообщества в системные роли RBAC
COMMUNITY_ROLE_MAP = {
CommunityRole.READER: "community_reader",
CommunityRole.AUTHOR: "community_author",
CommunityRole.EXPERT: "community_expert",
CommunityRole.EDITOR: "community_editor",
}
# Обратное отображение для отображения системных ролей в роли сообщества
RBAC_TO_COMMUNITY_ROLE = {v: k for k, v in COMMUNITY_ROLE_MAP.items()}
@staticmethod
def check_community_permission(
session: Session, author_id: int, community_slug: str, resource: str, operation: str
) -> bool:
"""
Проверяет наличие разрешения у пользователя в контексте сообщества.
Args:
session: Сессия SQLAlchemy
author_id: ID автора/пользователя
community_slug: Slug сообщества
resource: Ресурс для доступа
operation: Операция над ресурсом
Returns:
bool: True, если пользователь имеет разрешение, иначе False
"""
# 1. Проверка глобальных разрешений (например, администратор)
author = session.query(Author).filter(Author.id == author_id).one_or_none()
if not author:
return False
# Если это администратор (по списку email) или у него есть глобальное разрешение
if author.has_permission(resource, operation) or author.email in ADMIN_EMAILS:
return True
# 2. Проверка разрешений в контексте сообщества
# Получаем информацию о сообществе
community = session.query(Community).filter(Community.slug == community_slug).one_or_none()
if not community:
return False
# Если автор является создателем сообщества, то у него есть полные права
if community.created_by == author_id:
return True
# Получаем роли пользователя в этом сообществе
community_follower = (
session.query(CommunityFollower)
.filter(CommunityFollower.author == author_id, CommunityFollower.community == community.id)
.one_or_none()
)
if not community_follower or not community_follower.roles:
# Пользователь не является членом сообщества или у него нет ролей
return False
# Преобразуем роли сообщества в RBAC роли
rbac_roles = []
community_roles = community_follower.get_roles()
for role in community_roles:
if role in ContextualPermissionCheck.COMMUNITY_ROLE_MAP:
rbac_role_id = ContextualPermissionCheck.COMMUNITY_ROLE_MAP[role]
rbac_roles.append(rbac_role_id)
if not rbac_roles:
return False
# Проверяем наличие разрешения для этих ролей
permission_id = f"{resource}:{operation}"
# Запрос на проверку разрешений для указанных ролей
has_permission = (
session.query(RolePermission)
.join(Role, Role.id == RolePermission.role)
.join(Permission, Permission.id == RolePermission.permission)
.filter(Role.id.in_(rbac_roles), Permission.id == permission_id)
.first()
is not None
)
return has_permission
@staticmethod
def get_user_community_roles(
session: Session, author_id: int, community_slug: str
) -> List[CommunityRole]:
"""
Получает список ролей пользователя в сообществе.
Args:
session: Сессия SQLAlchemy
author_id: ID автора/пользователя
community_slug: Slug сообщества
Returns:
List[CommunityRole]: Список ролей пользователя в сообществе
"""
# Получаем информацию о сообществе
community = session.query(Community).filter(Community.slug == community_slug).one_or_none()
if not community:
return []
# Если автор является создателем сообщества, то у него есть роль владельца
if community.created_by == author_id:
return [CommunityRole.EDITOR] # Владелец имеет роль редактора по умолчанию
# Получаем роли пользователя в этом сообществе
community_follower = (
session.query(CommunityFollower)
.filter(CommunityFollower.author == author_id, CommunityFollower.community == community.id)
.one_or_none()
)
if not community_follower or not community_follower.roles:
return []
return community_follower.get_roles()
@staticmethod
def assign_role_to_user(
session: Session, author_id: int, community_slug: str, role: Union[CommunityRole, str]
) -> bool:
"""
Назначает роль пользователю в сообществе.
Args:
session: Сессия SQLAlchemy
author_id: ID автора/пользователя
community_slug: Slug сообщества
role: Роль для назначения (CommunityRole или строковое представление)
Returns:
bool: True если роль успешно назначена, иначе False
"""
# Преобразуем строковую роль в CommunityRole если нужно
if isinstance(role, str):
try:
role = CommunityRole(role)
except ValueError:
return False
# Получаем информацию о сообществе
community = session.query(Community).filter(Community.slug == community_slug).one_or_none()
if not community:
return False
# Проверяем существование связи автор-сообщество
community_follower = (
session.query(CommunityFollower)
.filter(CommunityFollower.author == author_id, CommunityFollower.community == community.id)
.one_or_none()
)
if not community_follower:
# Создаем новую запись CommunityFollower
community_follower = CommunityFollower(author=author_id, community=community.id)
session.add(community_follower)
# Назначаем роль
current_roles = community_follower.get_roles() if community_follower.roles else []
if role not in current_roles:
current_roles.append(role)
community_follower.set_roles(current_roles)
session.commit()
return True
@staticmethod
def revoke_role_from_user(
session: Session, author_id: int, community_slug: str, role: Union[CommunityRole, str]
) -> bool:
"""
Отзывает роль у пользователя в сообществе.
Args:
session: Сессия SQLAlchemy
author_id: ID автора/пользователя
community_slug: Slug сообщества
role: Роль для отзыва (CommunityRole или строковое представление)
Returns:
bool: True если роль успешно отозвана, иначе False
"""
# Преобразуем строковую роль в CommunityRole если нужно
if isinstance(role, str):
try:
role = CommunityRole(role)
except ValueError:
return False
# Получаем информацию о сообществе
community = session.query(Community).filter(Community.slug == community_slug).one_or_none()
if not community:
return False
# Проверяем существование связи автор-сообщество
community_follower = (
session.query(CommunityFollower)
.filter(CommunityFollower.author == author_id, CommunityFollower.community == community.id)
.one_or_none()
)
if not community_follower or not community_follower.roles:
return False
# Отзываем роль
current_roles = community_follower.get_roles()
if role in current_roles:
current_roles.remove(role)
community_follower.set_roles(current_roles)
session.commit()
return True

View File

@@ -1,22 +1,34 @@
# -*- coding: utf-8 -*-
import re
from datetime import datetime, timezone
from urllib.parse import quote_plus
import time
import traceback
from utils.logger import root_logger as logger
from graphql.type import GraphQLResolveInfo
# import asyncio # Убираем, так как резолвер будет синхронным
from auth.authenticate import login_required
from auth.credentials import AuthCredentials
from auth.decorators import admin_auth_required
from auth.email import send_auth_email
from auth.exceptions import InvalidPassword, InvalidToken, ObjectNotExist, Unauthorized
from auth.exceptions import InvalidToken, ObjectNotExist
from auth.identity import Identity, Password
from auth.jwtcodec import JWTCodec
from auth.tokenstorage import TokenStorage
from orm import Role, User
from auth.orm import Author, Role
from services.db import local_session
from services.schema import mutation, query
from settings import SESSION_TOKEN_HEADER
from settings import (
SESSION_TOKEN_HEADER,
SESSION_COOKIE_NAME,
SESSION_COOKIE_SECURE,
SESSION_COOKIE_SAMESITE,
SESSION_COOKIE_MAX_AGE,
SESSION_COOKIE_HTTPONLY,
)
from utils.generate_slug import generate_unique_slug
from graphql.error import GraphQLError
from math import ceil
from sqlalchemy import or_
@mutation.field("getSession")
@@ -26,129 +38,138 @@ async def get_current_user(_, info):
token = info.context["request"].headers.get(SESSION_TOKEN_HEADER)
with local_session() as session:
user = session.query(User).where(User.id == auth.user_id).one()
user.lastSeen = datetime.now(tz=timezone.utc)
author = session.query(Author).where(Author.id == auth.author_id).one()
author.last_seen = int(time.time())
session.commit()
return {"token": token, "user": user}
return {"token": token, "author": author}
@mutation.field("confirmEmail")
async def confirm_email(_, info, token):
"""confirm owning email address"""
try:
print("[resolvers.auth] confirm email by token")
logger.info("[auth] confirmEmail: Начало подтверждения email по токену.")
payload = JWTCodec.decode(token)
user_id = payload.user_id
# Если TokenStorage.get асинхронный, это нужно будет переделать или вызывать синхронно
# Для теста пока оставим, но это потенциальная точка отказа в синхронном резолвере
await TokenStorage.get(f"{user_id}-{payload.username}-{token}")
with local_session() as session:
user = session.query(User).where(User.id == user_id).first()
user = session.query(Author).where(Author.id == user_id).first()
if not user:
logger.warning(f"[auth] confirmEmail: Пользователь с ID {user_id} не найден.")
return {"success": False, "error": "Пользователь не найден"}
# Если TokenStorage.create_session асинхронный...
session_token = await TokenStorage.create_session(user)
user.emailConfirmed = True
user.lastSeen = datetime.now(tz=timezone.utc)
user.email_verified = True
user.last_seen = int(time.time())
session.add(user)
session.commit()
return {"token": session_token, "user": user}
logger.info(f"[auth] confirmEmail: Email для пользователя {user_id} успешно подтвержден.")
return {"success": True, "token": session_token, "author": user, "error": None}
except InvalidToken as e:
raise InvalidToken(e.message)
logger.warning(f"[auth] confirmEmail: Невалидный токен - {e.message}")
return {"success": False, "token": None, "author": None, "error": f"Невалидный токен: {e.message}"}
except Exception as e:
print(e) # FIXME: debug only
return {"error": "email is not confirmed"}
logger.error(f"[auth] confirmEmail: Общая ошибка - {str(e)}\n{traceback.format_exc()}")
return {
"success": False,
"token": None,
"author": None,
"error": f"Ошибка подтверждения email: {str(e)}",
}
def create_user(user_dict):
user = User(**user_dict)
user = Author(**user_dict)
with local_session() as session:
user.roles.append(session.query(Role).first())
# Добавляем пользователя в БД
session.add(user)
session.flush() # Получаем ID пользователя
# Получаем или создаём стандартную роль "reader"
reader_role = session.query(Role).filter(Role.id == "reader").first()
if not reader_role:
reader_role = Role(id="reader", name="Читатель")
session.add(reader_role)
session.flush()
# Получаем основное сообщество
from orm.community import Community
main_community = session.query(Community).filter(Community.id == 1).first()
if not main_community:
main_community = Community(
id=1,
name="Discours",
slug="discours",
desc="Cообщество Discours",
created_by=user.id,
)
session.add(main_community)
session.flush()
# Создаём связь автор-роль-сообщество
from auth.orm import AuthorRole
author_role = AuthorRole(author=user.id, role=reader_role.id, community=main_community.id)
session.add(author_role)
session.commit()
return user
def replace_translit(src):
ruchars = "абвгдеёжзийклмнопрстуфхцчшщъыьэюя."
enchars = [
"a",
"b",
"v",
"g",
"d",
"e",
"yo",
"zh",
"z",
"i",
"y",
"k",
"l",
"m",
"n",
"o",
"p",
"r",
"s",
"t",
"u",
"f",
"h",
"c",
"ch",
"sh",
"sch",
"",
"y",
"'",
"e",
"yu",
"ya",
"-",
]
return src.translate(str.maketrans(ruchars, enchars))
def generate_unique_slug(src):
print("[resolvers.auth] generating slug from: " + src)
slug = replace_translit(src.lower())
slug = re.sub("[^0-9a-zA-Z]+", "-", slug)
if slug != src:
print("[resolvers.auth] translited name: " + slug)
c = 1
with local_session() as session:
user = session.query(User).where(User.slug == slug).first()
while user:
user = session.query(User).where(User.slug == slug).first()
slug = slug + "-" + str(c)
c += 1
if not user:
unique_slug = slug
print("[resolvers.auth] " + unique_slug)
return quote_plus(unique_slug.replace("'", "")).replace("+", "-")
@mutation.field("registerUser")
async def register_by_email(_, _info, email: str, password: str = "", name: str = ""):
email = email.lower()
"""creates new user account"""
logger.info(f"[auth] registerUser: Попытка регистрации для {email}")
with local_session() as session:
user = session.query(User).filter(User.email == email).first()
user = session.query(Author).filter(Author.email == email).first()
if user:
raise Unauthorized("User already exist")
else:
slug = generate_unique_slug(name)
user = session.query(User).where(User.slug == slug).first()
if user:
slug = generate_unique_slug(email.split("@")[0])
user_dict = {
"email": email,
"username": email, # will be used to store phone number or some messenger network id
"name": name,
"slug": slug,
logger.warning(f"[auth] registerUser: Пользователь {email} уже существует.")
# raise Unauthorized("User already exist") # Это вызовет ошибку GraphQL, но не "cannot return null"
return {"success": False, "token": None, "author": None, "error": "Пользователь уже существует"}
slug = generate_unique_slug(name if name else email.split("@")[0])
user_dict = {
"email": email,
"username": email,
"name": name if name else email.split("@")[0],
"slug": slug,
}
if password:
user_dict["password"] = Password.encode(password)
new_user = create_user(user_dict)
# Предполагается, что auth_send_link вернет объект Author или вызовет исключение
# Для AuthResult нам также нужен токен и статус.
# После регистрации обычно либо сразу логинят, либо просто сообщают об успехе.
# Сейчас auth_send_link используется, что не логично для AuthResult.
# Вернем успешную регистрацию без токена, предполагая, что пользователь должен будет залогиниться или подтвердить email.
# Попытка отправить ссылку для подтверждения email
try:
# Если auth_send_link асинхронный...
await auth_send_link(_, _info, email)
logger.info(
f"[auth] registerUser: Пользователь {email} зарегистрирован, ссылка для подтверждения отправлена."
)
return {
"success": True,
"token": None,
"author": new_user,
"error": "Требуется подтверждение email.",
}
except Exception as e:
logger.error(f"[auth] registerUser: Ошибка при отправке ссылки подтверждения для {email}: {str(e)}")
return {
"success": True,
"token": None,
"author": new_user,
"error": f"Пользователь зарегистрирован, но произошла ошибка при отправке ссылки подтверждения: {str(e)}",
}
if password:
user_dict["password"] = Password.encode(password)
user = create_user(user_dict)
user = await auth_send_link(_, _info, email)
return {"user": user}
@mutation.field("sendLink")
@@ -156,53 +177,168 @@ async def auth_send_link(_, _info, email, lang="ru", template="email_confirmatio
email = email.lower()
"""send link with confirm code to email"""
with local_session() as session:
user = session.query(User).filter(User.email == email).first()
user = session.query(Author).filter(Author.email == email).first()
if not user:
raise ObjectNotExist("User not found")
else:
# Если TokenStorage.create_onetime асинхронный...
token = await TokenStorage.create_onetime(user)
# Если send_auth_email асинхронный...
await send_auth_email(user, token, lang, template)
return user
@query.field("signIn")
async def login(_, info, email: str, password: str = "", lang: str = "ru"):
email = email.lower()
with local_session() as session:
orm_user = session.query(User).filter(User.email == email).first()
if orm_user is None:
print(f"[auth] {email}: email not found")
# return {"error": "email not found"}
raise ObjectNotExist("User not found") # contains webserver status
@mutation.field("login")
async def login_mutation(_, info, email: str, password: str):
"""
Авторизация пользователя с помощью email и пароля.
if not password:
print(f"[auth] send confirm link to {email}")
token = await TokenStorage.create_onetime(orm_user)
await send_auth_email(orm_user, token, lang)
# FIXME: not an error, warning
return {"error": "no password, email link was sent"}
Args:
info: Контекст GraphQL запроса
email: Email пользователя
password: Пароль пользователя
else:
# sign in using password
if not orm_user.emailConfirmed:
# not an error, warns users
return {"error": "please, confirm email"}
else:
Returns:
AuthResult с данными пользователя и токеном или сообщением об ошибке
"""
logger.info(f"[auth] login: Попытка входа для {email}")
# Гарантируем, что всегда возвращаем непустой объект AuthResult
default_response = {"success": False, "token": None, "author": None, "error": "Неизвестная ошибка"}
try:
# Нормализуем email
email = email.lower()
# Получаем пользователя из базы
with local_session() as session:
author = session.query(Author).filter(Author.email == email).first()
if not author:
logger.warning(f"[auth] login: Пользователь {email} не найден")
return {
"success": False,
"token": None,
"author": None,
"error": "Пользователь с таким email не найден",
}
# Логируем информацию о найденном авторе
logger.info(
f"[auth] login: Найден автор {email}, id={author.id}, имя={author.name}, пароль есть: {bool(author.password)}"
)
# Проверяем пароль
logger.info(f"[auth] login: НАЧАЛО ПРОВЕРКИ ПАРОЛЯ для {email}")
verify_result = Identity.password(author, password)
logger.info(
f"[auth] login: РЕЗУЛЬТАТ ПРОВЕРКИ ПАРОЛЯ: {verify_result if isinstance(verify_result, dict) else 'успешно'}"
)
if isinstance(verify_result, dict) and verify_result.get("error"):
logger.warning(f"[auth] login: Неверный пароль для {email}: {verify_result.get('error')}")
return {
"success": False,
"token": None,
"author": None,
"error": verify_result.get("error", "Ошибка авторизации"),
}
# Получаем правильный объект автора - результат verify_result
valid_author = verify_result if not isinstance(verify_result, dict) else author
# Создаем токен через правильную функцию вместо прямого кодирования
try:
# Убедимся, что у автора есть нужные поля для создания токена
if (
not hasattr(valid_author, "id")
or not hasattr(valid_author, "username")
and not hasattr(valid_author, "email")
):
logger.error(
f"[auth] login: Объект автора не содержит необходимых атрибутов: {valid_author}"
)
return {
"success": False,
"token": None,
"author": None,
"error": "Внутренняя ошибка: некорректный объект автора",
}
# Создаем сессионный токен
logger.info(f"[auth] login: СОЗДАНИЕ ТОКЕНА для {email}, id={valid_author.id}")
token = await TokenStorage.create_session(valid_author)
logger.info(f"[auth] login: токен успешно создан, длина: {len(token) if token else 0}")
# Обновляем время последнего входа
valid_author.last_seen = int(time.time())
session.commit()
# Устанавливаем httponly cookie с помощью GraphQLExtensionsMiddleware
try:
user = Identity.password(orm_user, password)
session_token = await TokenStorage.create_session(user)
print(f"[auth] user {email} authorized")
return {"token": session_token, "user": user}
except InvalidPassword:
print(f"[auth] {email}: invalid password")
raise InvalidPassword("invalid password") # contains webserver status
# return {"error": "invalid password"}
# Используем extensions для установки cookie
if hasattr(info.context, "extensions") and hasattr(
info.context.extensions, "set_cookie"
):
logger.info("[auth] login: Устанавливаем httponly cookie через extensions")
info.context.extensions.set_cookie(
SESSION_COOKIE_NAME,
token,
httponly=SESSION_COOKIE_HTTPONLY,
secure=SESSION_COOKIE_SECURE,
samesite=SESSION_COOKIE_SAMESITE,
max_age=SESSION_COOKIE_MAX_AGE,
)
elif hasattr(info.context, "response") and hasattr(info.context.response, "set_cookie"):
logger.info("[auth] login: Устанавливаем httponly cookie через response")
info.context.response.set_cookie(
key=SESSION_COOKIE_NAME,
value=token,
httponly=SESSION_COOKIE_HTTPONLY,
secure=SESSION_COOKIE_SECURE,
samesite=SESSION_COOKIE_SAMESITE,
max_age=SESSION_COOKIE_MAX_AGE,
)
else:
logger.warning(
"[auth] login: Невозможно установить cookie - объекты extensions/response недоступны"
)
except Exception as e:
# В случае ошибки при установке cookie просто логируем, но продолжаем авторизацию
logger.error(f"[auth] login: Ошибка при установке cookie: {str(e)}")
logger.debug(traceback.format_exc())
# Возвращаем успешный результат
logger.info(f"[auth] login: Успешный вход для {email}")
result = {"success": True, "token": token, "author": valid_author, "error": None}
logger.info(
f"[auth] login: Возвращаемый результат: {{success: {result['success']}, token_length: {len(token) if token else 0}}}"
)
return result
except Exception as token_error:
logger.error(f"[auth] login: Ошибка при создании токена: {str(token_error)}")
logger.error(traceback.format_exc())
return {
"success": False,
"token": None,
"author": None,
"error": f"Ошибка авторизации: {str(token_error)}",
}
except Exception as e:
logger.error(f"[auth] login: Ошибка при авторизации {email}: {str(e)}")
logger.error(traceback.format_exc())
return {"success": False, "token": None, "author": None, "error": str(e)}
# Если по какой-то причине мы дошли до этой точки, вернем безопасный результат
return default_response
@query.field("signOut")
@login_required
async def sign_out(_, info: GraphQLResolveInfo):
token = info.context["request"].headers.get(SESSION_TOKEN_HEADER, "")
# Если TokenStorage.revoke асинхронный...
status = await TokenStorage.revoke(token)
return status
@@ -211,5 +347,117 @@ async def sign_out(_, info: GraphQLResolveInfo):
async def is_email_used(_, _info, email):
email = email.lower()
with local_session() as session:
user = session.query(User).filter(User.email == email).first()
user = session.query(Author).filter(Author.email == email).first()
return user is not None
@query.field("adminGetUsers")
@admin_auth_required
async def admin_get_users(_, info, limit=10, offset=0, search=None):
"""
Получает список пользователей для админ-панели с поддержкой пагинации и поиска
Args:
info: Контекст GraphQL запроса
limit: Максимальное количество записей для получения
offset: Смещение в списке результатов
search: Строка поиска (по email, имени или ID)
Returns:
Пагинированный список пользователей
"""
try:
# Нормализуем параметры
limit = max(1, min(100, limit or 10)) # Ограничиваем количество записей от 1 до 100
offset = max(0, offset or 0) # Смещение не может быть отрицательным
with local_session() as session:
# Базовый запрос
query = session.query(Author)
# Применяем фильтр поиска, если указан
if search and search.strip():
search_term = f"%{search.strip().lower()}%"
query = query.filter(
or_(
Author.email.ilike(search_term),
Author.name.ilike(search_term),
Author.id.cast(str).ilike(search_term),
)
)
# Получаем общее количество записей
total_count = query.count()
# Вычисляем информацию о пагинации
per_page = limit
total_pages = ceil(total_count / per_page)
current_page = (offset // per_page) + 1 if per_page > 0 else 1
# Применяем пагинацию
users = query.order_by(Author.id).offset(offset).limit(limit).all()
# Преобразуем в формат для API
result = {
"users": [
{
"id": user.id,
"email": user.email,
"name": user.name,
"slug": user.slug,
"roles": [role.role for role in user.roles]
if hasattr(user, "roles") and user.roles
else [],
"created_at": user.created_at,
"last_seen": user.last_seen,
"muted": user.muted or False,
"is_active": not user.blocked if hasattr(user, "blocked") else True,
}
for user in users
],
"total": total_count,
"page": current_page,
"perPage": per_page,
"totalPages": total_pages,
}
return result
except Exception as e:
logger.error(f"Ошибка при получении списка пользователей: {str(e)}")
logger.error(traceback.format_exc())
raise GraphQLError(f"Не удалось получить список пользователей: {str(e)}")
@query.field("adminGetRoles")
@admin_auth_required
async def admin_get_roles(_, info):
"""
Получает список всех ролей для админ-панели
Args:
info: Контекст GraphQL запроса
Returns:
Список ролей с их описаниями
"""
try:
with local_session() as session:
# Получаем все роли из базы данных
roles = session.query(Role).all()
# Преобразуем их в формат для API
result = [
{
"id": role.id,
"name": role.name,
"description": f"Роль с правами: {', '.join(p.resource + ':' + p.operation for p in role.permissions)}"
if role.permissions
else "Роль без особых прав",
}
for role in roles
]
return result
except Exception as e:
logger.error(f"Ошибка при получении списка ролей: {str(e)}")
raise GraphQLError(f"Не удалось получить список ролей: {str(e)}")

228
auth/sessions.py Normal file
View File

@@ -0,0 +1,228 @@
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any
from pydantic import BaseModel
from services.redis import redis
from auth.jwtcodec import JWTCodec, TokenPayload
from settings import SESSION_TOKEN_LIFE_SPAN
from utils.logger import root_logger as logger
class SessionData(BaseModel):
"""Модель данных сессии"""
user_id: str
username: str
created_at: datetime
expires_at: datetime
device_info: Optional[dict] = None
class SessionManager:
"""
Менеджер сессий в Redis.
Управляет созданием, проверкой и отзывом сессий пользователей.
"""
@staticmethod
def _make_session_key(user_id: str, token: str) -> str:
"""Формирует ключ сессии в Redis"""
return f"session:{user_id}:{token}"
@staticmethod
def _make_user_sessions_key(user_id: str) -> str:
"""Формирует ключ для списка сессий пользователя в Redis"""
return f"user_sessions:{user_id}"
@classmethod
async def create_session(cls, user_id: str, username: str, device_info: dict = None) -> str:
"""
Создает новую сессию для пользователя.
Args:
user_id: ID пользователя
username: Имя пользователя/логин
device_info: Информация об устройстве (опционально)
Returns:
str: Токен сессии
"""
try:
# Создаем JWT токен
exp = datetime.now(tz=timezone.utc) + timedelta(seconds=SESSION_TOKEN_LIFE_SPAN)
session_token = JWTCodec.encode({"id": user_id, "email": username}, exp)
# Создаем данные сессии
session_data = SessionData(
user_id=user_id,
username=username,
created_at=datetime.now(tz=timezone.utc),
expires_at=exp,
device_info=device_info,
)
# Ключи в Redis
session_key = cls._make_session_key(user_id, session_token)
user_sessions_key = cls._make_user_sessions_key(user_id)
# Сохраняем в Redis
pipe = redis.pipeline()
await pipe.hset(session_key, mapping=session_data.dict())
await pipe.expire(session_key, SESSION_TOKEN_LIFE_SPAN)
await pipe.sadd(user_sessions_key, session_token)
await pipe.expire(user_sessions_key, SESSION_TOKEN_LIFE_SPAN)
await pipe.execute()
return session_token
except Exception as e:
logger.error(f"[SessionManager.create_session] Ошибка: {str(e)}")
raise
@classmethod
async def verify_session(cls, token: str) -> Optional[TokenPayload]:
"""
Проверяет валидность сессии.
Args:
token: Токен сессии
Returns:
TokenPayload: Данные токена или None, если токен недействителен
"""
try:
# Декодируем JWT
payload = JWTCodec.decode(token)
# Формируем ключ сессии
session_key = cls._make_session_key(payload.user_id, token)
# Проверяем существование сессии в Redis
session_exists = await redis.exists(session_key)
if not session_exists:
logger.debug(f"[SessionManager.verify_session] Сессия не найдена: {payload.user_id}")
return None
return payload
except Exception as e:
logger.error(f"[SessionManager.verify_session] Ошибка: {str(e)}")
return None
@classmethod
async def get_session_data(cls, user_id: str, token: str) -> Optional[Dict[str, Any]]:
"""
Получает данные сессии.
Args:
user_id: ID пользователя
token: Токен сессии
Returns:
dict: Данные сессии или None, если сессия не найдена
"""
try:
session_key = cls._make_session_key(user_id, token)
session_data = await redis.hgetall(session_key)
return session_data if session_data else None
except Exception as e:
logger.error(f"[SessionManager.get_session_data] Ошибка: {str(e)}")
return None
@classmethod
async def revoke_session(cls, user_id: str, token: str) -> bool:
"""
Отзывает конкретную сессию.
Args:
user_id: ID пользователя
token: Токен сессии
Returns:
bool: True, если сессия успешно отозвана
"""
try:
session_key = cls._make_session_key(user_id, token)
user_sessions_key = cls._make_user_sessions_key(user_id)
# Удаляем сессию и запись из списка сессий пользователя
pipe = redis.pipeline()
await pipe.delete(session_key)
await pipe.srem(user_sessions_key, token)
await pipe.execute()
return True
except Exception as e:
logger.error(f"[SessionManager.revoke_session] Ошибка: {str(e)}")
return False
@classmethod
async def revoke_all_sessions(cls, user_id: str) -> bool:
"""
Отзывает все сессии пользователя.
Args:
user_id: ID пользователя
Returns:
bool: True, если все сессии успешно отозваны
"""
try:
user_sessions_key = cls._make_user_sessions_key(user_id)
# Получаем все токены пользователя
tokens = await redis.smembers(user_sessions_key)
if not tokens:
return True
# Создаем команды для удаления всех сессий
pipe = redis.pipeline()
# Формируем список ключей для удаления
for token in tokens:
session_key = cls._make_session_key(user_id, token)
await pipe.delete(session_key)
# Удаляем список сессий
await pipe.delete(user_sessions_key)
await pipe.execute()
return True
except Exception as e:
logger.error(f"[SessionManager.revoke_all_sessions] Ошибка: {str(e)}")
return False
@classmethod
async def refresh_session(cls, user_id: str, old_token: str, device_info: dict = None) -> Optional[str]:
"""
Обновляет сессию пользователя, заменяя старый токен новым.
Args:
user_id: ID пользователя
old_token: Старый токен сессии
device_info: Информация об устройстве (опционально)
Returns:
str: Новый токен сессии или None в случае ошибки
"""
try:
# Получаем данные старой сессии
old_session_key = cls._make_session_key(user_id, old_token)
old_session_data = await redis.hgetall(old_session_key)
if not old_session_data:
logger.warning(f"[SessionManager.refresh_session] Сессия не найдена: {user_id}")
return None
# Используем старые данные устройства, если новые не предоставлены
if not device_info and "device_info" in old_session_data:
device_info = old_session_data.get("device_info")
# Создаем новую сессию
new_token = await cls.create_session(user_id, old_session_data.get("username", ""), device_info)
# Отзываем старую сессию
await cls.revoke_session(user_id, old_token)
return new_token
except Exception as e:
logger.error(f"[SessionManager.refresh_session] Ошибка: {str(e)}")
return None

View File

@@ -1,73 +1,193 @@
from datetime import datetime, timedelta, timezone
import json
from typing import Dict, Any, Optional
from auth.jwtcodec import JWTCodec
from auth.validations import AuthInput
from services.redis import redis
from settings import ONETIME_TOKEN_LIFE_SPAN, SESSION_TOKEN_LIFE_SPAN
async def save(token_key, life_span, auto_delete=True):
await redis.execute("SET", token_key, "True")
if auto_delete:
expire_at = (datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)).timestamp()
await redis.execute("EXPIREAT", token_key, int(expire_at))
class SessionToken:
@classmethod
async def verify(cls, token: str):
"""
Rules for a token to be valid.
- token format is legal
- token exists in redis database
- token is not expired
"""
try:
return JWTCodec.decode(token)
except Exception as e:
raise e
@classmethod
async def get(cls, payload, token):
return await TokenStorage.get(f"{payload.user_id}-{payload.username}-{token}")
from utils.logger import root_logger as logger
class TokenStorage:
"""
Хранилище токенов в Redis.
Обеспечивает создание, проверку и отзыв токенов.
"""
@staticmethod
async def get(token_key):
print("[tokenstorage.get] " + token_key)
# 2041-user@domain.zn-eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoyMDQxLCJ1c2VybmFtZSI6ImFudG9uLnJld2luK3Rlc3QtbG9hZGNoYXRAZ21haWwuY29tIiwiZXhwIjoxNjcxNzgwNjE2LCJpYXQiOjE2NjkxODg2MTYsImlzcyI6ImRpc2NvdXJzIn0.Nml4oV6iMjMmc6xwM7lTKEZJKBXvJFEIZ-Up1C1rITQ
return await redis.execute("GET", token_key)
async def get(token_key: str) -> Optional[str]:
"""
Получает токен из хранилища.
Args:
token_key: Ключ токена
Returns:
str или None, если токен не найден
"""
logger.debug(f"[tokenstorage.get] Запрос токена: {token_key}")
return await redis.get(token_key)
@staticmethod
async def exists(token_key: str) -> bool:
"""
Проверяет наличие токена в хранилище.
Args:
token_key: Ключ токена
Returns:
bool: True, если токен существует
"""
return bool(await redis.execute("EXISTS", token_key))
@staticmethod
async def save_token(token_key: str, data: Dict[str, Any], life_span: int) -> bool:
"""
Сохраняет токен в хранилище с указанным временем жизни.
Args:
token_key: Ключ токена
data: Данные токена
life_span: Время жизни токена в секундах
Returns:
bool: True, если токен успешно сохранен
"""
try:
# Если данные не строка, преобразуем их в JSON
value = json.dumps(data) if isinstance(data, dict) else data
# Сохраняем токен и устанавливаем время жизни
await redis.set(token_key, value, ex=life_span)
return True
except Exception as e:
logger.error(f"[tokenstorage.save_token] Ошибка сохранения токена: {str(e)}")
return False
@staticmethod
async def create_onetime(user: AuthInput) -> str:
"""
Создает одноразовый токен для пользователя.
Args:
user: Объект пользователя
Returns:
str: Сгенерированный токен
"""
life_span = ONETIME_TOKEN_LIFE_SPAN
exp = datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)
one_time_token = JWTCodec.encode(user, exp)
await save(f"{user.id}-{user.username}-{one_time_token}", life_span)
# Сохраняем токен в Redis
token_key = f"{user.id}-{user.username}-{one_time_token}"
await TokenStorage.save_token(token_key, "TRUE", life_span)
return one_time_token
@staticmethod
async def create_session(user: AuthInput) -> str:
"""
Создает сессионный токен для пользователя.
Args:
user: Объект пользователя
Returns:
str: Сгенерированный токен
"""
life_span = SESSION_TOKEN_LIFE_SPAN
exp = datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)
session_token = JWTCodec.encode(user, exp)
await save(f"{user.id}-{user.username}-{session_token}", life_span)
# Сохраняем токен в Redis
token_key = f"{user.id}-{user.username}-{session_token}"
user_sessions_key = f"user_sessions:{user.id}"
# Создаем данные сессии
session_data = {
"user_id": str(user.id),
"username": user.username,
"created_at": datetime.now(tz=timezone.utc).timestamp(),
"expires_at": exp.timestamp(),
}
# Сохраняем токен и добавляем его в список сессий пользователя
pipe = redis.pipeline()
await pipe.hmset(token_key, session_data)
await pipe.expire(token_key, life_span)
await pipe.sadd(user_sessions_key, session_token)
await pipe.expire(user_sessions_key, life_span)
await pipe.execute()
return session_token
@staticmethod
async def revoke(token: str) -> bool:
payload = None
"""
Отзывает токен.
Args:
token: Токен для отзыва
Returns:
bool: True, если токен успешно отозван
"""
try:
print("[auth.tokenstorage] revoke token")
logger.debug("[tokenstorage.revoke] Отзыв токена")
# Декодируем токен
payload = JWTCodec.decode(token)
except: # noqa
pass
else:
await redis.execute("DEL", f"{payload.user_id}-{payload.username}-{token}")
return True
if not payload:
logger.warning("[tokenstorage.revoke] Невозможно декодировать токен")
return False
# Формируем ключи
token_key = f"{payload.user_id}-{payload.username}-{token}"
user_sessions_key = f"user_sessions:{payload.user_id}"
# Удаляем токен и запись из списка сессий пользователя
pipe = redis.pipeline()
await pipe.delete(token_key)
await pipe.srem(user_sessions_key, token)
await pipe.execute()
return True
except Exception as e:
logger.error(f"[tokenstorage.revoke] Ошибка отзыва токена: {str(e)}")
return False
@staticmethod
async def revoke_all(user: AuthInput):
tokens = await redis.execute("KEYS", f"{user.id}-*")
await redis.execute("DEL", *tokens)
async def revoke_all(user: AuthInput) -> bool:
"""
Отзывает все токены пользователя.
Args:
user: Объект пользователя
Returns:
bool: True, если все токены успешно отозваны
"""
try:
# Формируем ключи
user_sessions_key = f"user_sessions:{user.id}"
# Получаем все токены пользователя
tokens = await redis.smembers(user_sessions_key)
if not tokens:
return True
# Формируем список ключей для удаления
keys_to_delete = [f"{user.id}-{user.username}-{token}" for token in tokens]
keys_to_delete.append(user_sessions_key)
# Удаляем все токены и список сессий
await redis.delete(*keys_to_delete)
return True
except Exception as e:
logger.error(f"[tokenstorage.revoke_all] Ошибка отзыва всех токенов: {str(e)}")
return False