0.7.5-topicfix

This commit is contained in:
2025-07-03 00:20:10 +03:00
parent 27c5a57709
commit 441cca8045
19 changed files with 2008 additions and 3213 deletions

579
services/admin.py Normal file
View File

@@ -0,0 +1,579 @@
"""
Сервис админ-панели с бизнес-логикой для управления пользователями, публикациями и приглашениями.
"""
from math import ceil
from typing import Any
from sqlalchemy import String, cast, null, or_
from sqlalchemy.orm import joinedload
from sqlalchemy.sql import func, select
from auth.orm import Author
from orm.community import Community, CommunityAuthor
from orm.invite import Invite, InviteStatus
from orm.shout import Shout
from services.db import local_session
from services.env import EnvManager, EnvVariable
from utils.logger import root_logger as logger
class AdminService:
"""Сервис для админ-панели с бизнес-логикой"""
@staticmethod
def normalize_pagination(limit: int = 20, offset: int = 0) -> tuple[int, int]:
"""Нормализует параметры пагинации"""
return max(1, min(100, limit or 20)), max(0, offset or 0)
@staticmethod
def calculate_pagination_info(total_count: int, limit: int, offset: int) -> dict[str, int]:
"""Вычисляет информацию о пагинации"""
per_page = limit
if total_count is None or per_page in (None, 0):
total_pages = 1
else:
total_pages = ceil(total_count / per_page)
current_page = (offset // per_page) + 1 if per_page > 0 else 1
return {
"total": total_count,
"page": current_page,
"perPage": per_page,
"totalPages": total_pages,
}
@staticmethod
def get_author_info(author_id: int, session) -> dict[str, Any]:
"""Получает информацию об авторе"""
if not author_id or author_id == 0:
return {
"id": 0,
"email": "system@discours.io",
"name": "System",
"slug": "system",
}
author = session.query(Author).filter(Author.id == author_id).first()
if author:
return {
"id": author.id,
"email": author.email or f"user{author.id}@discours.io",
"name": author.name or f"User {author.id}",
"slug": author.slug or f"user-{author.id}",
}
return {
"id": author_id,
"email": f"deleted{author_id}@discours.io",
"name": f"Deleted User {author_id}",
"slug": f"deleted-user-{author_id}",
}
@staticmethod
def get_user_roles(user: Author, community_id: int = 1) -> list[str]:
"""Получает роли пользователя в сообществе"""
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
admin_emails = ADMIN_EMAILS_LIST.split(",") if ADMIN_EMAILS_LIST else []
user_roles = []
with local_session() as session:
community_author = (
session.query(CommunityAuthor)
.filter(CommunityAuthor.author_id == user.id, CommunityAuthor.community_id == community_id)
.first()
)
if community_author:
user_roles = community_author.role_list
# Добавляем синтетическую роль для системных админов
if user.email and user.email.lower() in [email.lower() for email in admin_emails]:
if "Системный администратор" not in user_roles:
user_roles.insert(0, "Системный администратор")
return user_roles
# === ПОЛЬЗОВАТЕЛИ ===
def get_users(self, limit: int = 20, offset: int = 0, search: str = "") -> dict[str, Any]:
"""Получает список пользователей"""
limit, offset = self.normalize_pagination(limit, offset)
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),
cast(Author.id, String).ilike(search_term),
)
)
total_count = query.count()
authors = query.order_by(Author.id).offset(offset).limit(limit).all()
pagination_info = self.calculate_pagination_info(total_count, limit, offset)
return {
"authors": [
{
"id": user.id,
"email": user.email,
"name": user.name,
"slug": user.slug,
"roles": self.get_user_roles(user, 1),
"created_at": user.created_at,
"last_seen": user.last_seen,
}
for user in authors
],
**pagination_info,
}
def update_user(self, user_data: dict[str, Any]) -> dict[str, Any]:
"""Обновляет данные пользователя"""
user_id = user_data.get("id")
if not user_id:
return {"success": False, "error": "ID пользователя не указан"}
try:
user_id_int = int(user_id)
except (TypeError, ValueError):
return {"success": False, "error": "Некорректный ID пользователя"}
roles = user_data.get("roles", [])
email = user_data.get("email")
name = user_data.get("name")
slug = user_data.get("slug")
with local_session() as session:
author = session.query(Author).filter(Author.id == user_id).first()
if not author:
return {"success": False, "error": f"Пользователь с ID {user_id} не найден"}
# Обновляем основные поля
if email is not None and email != author.email:
existing = session.query(Author).filter(Author.email == email, Author.id != user_id).first()
if existing:
return {"success": False, "error": f"Email {email} уже используется"}
author.email = email
if name is not None and name != author.name:
author.name = name
if slug is not None and slug != author.slug:
existing = session.query(Author).filter(Author.slug == slug, Author.id != user_id).first()
if existing:
return {"success": False, "error": f"Slug {slug} уже используется"}
author.slug = slug
# Обновляем роли
if roles is not None:
community_author = (
session.query(CommunityAuthor)
.filter(CommunityAuthor.author_id == user_id_int, CommunityAuthor.community_id == 1)
.first()
)
if not community_author:
community_author = CommunityAuthor(author_id=user_id_int, community_id=1, roles="")
session.add(community_author)
# Валидация ролей
all_roles = ["reader", "author", "artist", "expert", "editor", "admin"]
valid_roles = [role for role in roles if role in all_roles]
community_author.set_roles(valid_roles)
session.commit()
logger.info(f"Пользователь {author.email or author.id} обновлен")
return {"success": True}
# === ПУБЛИКАЦИИ ===
def get_shouts(
self,
limit: int = 20,
offset: int = 0,
search: str = "",
status: str = "all",
community: int = None,
) -> dict[str, Any]:
"""Получает список публикаций"""
limit = max(1, min(100, limit or 10))
offset = max(0, offset or 0)
with local_session() as session:
q = select(Shout).options(joinedload(Shout.authors), joinedload(Shout.topics))
# Фильтр статуса
if status == "published":
q = q.filter(Shout.published_at.isnot(None), Shout.deleted_at.is_(None))
elif status == "draft":
q = q.filter(Shout.published_at.is_(None), Shout.deleted_at.is_(None))
elif status == "deleted":
q = q.filter(Shout.deleted_at.isnot(None))
# Фильтр по сообществу
if community is not None:
q = q.filter(Shout.community == community)
# Поиск
if search and search.strip():
search_term = f"%{search.strip().lower()}%"
q = q.filter(
or_(
Shout.title.ilike(search_term),
Shout.slug.ilike(search_term),
cast(Shout.id, String).ilike(search_term),
Shout.body.ilike(search_term),
)
)
total_count = session.execute(select(func.count()).select_from(q.subquery())).scalar()
q = q.order_by(Shout.created_at.desc()).limit(limit).offset(offset)
shouts_result = session.execute(q).unique().scalars().all()
shouts_data = []
for shout in shouts_result:
shout_dict = self._serialize_shout(shout, session)
if shout_dict is not None: # Фильтруем объекты с отсутствующими обязательными полями
shouts_data.append(shout_dict)
per_page = limit or 20
total_pages = ceil((total_count or 0) / per_page) if per_page > 0 else 1
current_page = (offset // per_page) + 1 if per_page > 0 else 1
return {
"shouts": shouts_data,
"total": total_count,
"page": current_page,
"perPage": per_page,
"totalPages": total_pages,
}
def _serialize_shout(self, shout, session) -> dict[str, Any] | None:
"""Сериализует публикацию в словарь"""
# Проверяем обязательные поля перед сериализацией
if not hasattr(shout, "id") or not shout.id:
logger.warning(f"Shout без ID найден, пропускаем: {shout}")
return None
# Обрабатываем media
media_data = []
if hasattr(shout, "media") and shout.media:
if isinstance(shout.media, str):
try:
import orjson
media_data = orjson.loads(shout.media)
except Exception:
media_data = []
elif isinstance(shout.media, list):
media_data = shout.media
# Получаем информацию о создателе (обязательное поле)
created_by_info = self.get_author_info(getattr(shout, "created_by", None) or 0, session)
# Получаем информацию о сообществе (обязательное поле)
community_info = self._get_community_info(getattr(shout, "community", None) or 0, session)
return {
"id": shout.id, # Обязательное поле
"title": getattr(shout, "title", "") or "", # Обязательное поле
"slug": getattr(shout, "slug", "") or f"shout-{shout.id}", # Обязательное поле
"body": getattr(shout, "body", "") or "", # Обязательное поле
"lead": getattr(shout, "lead", None),
"subtitle": getattr(shout, "subtitle", None),
"layout": getattr(shout, "layout", "article") or "article", # Обязательное поле
"lang": getattr(shout, "lang", "ru") or "ru", # Обязательное поле
"cover": getattr(shout, "cover", None),
"cover_caption": getattr(shout, "cover_caption", None),
"media": media_data,
"seo": getattr(shout, "seo", None),
"created_at": getattr(shout, "created_at", 0) or 0, # Обязательное поле
"updated_at": getattr(shout, "updated_at", None),
"published_at": getattr(shout, "published_at", None),
"featured_at": getattr(shout, "featured_at", None),
"deleted_at": getattr(shout, "deleted_at", None),
"created_by": created_by_info, # Обязательное поле
"updated_by": self.get_author_info(getattr(shout, "updated_by", None) or 0, session),
"deleted_by": self.get_author_info(getattr(shout, "deleted_by", None) or 0, session),
"community": community_info, # Обязательное поле
"authors": [
{
"id": getattr(author, "id", None),
"email": getattr(author, "email", None),
"name": getattr(author, "name", None),
"slug": getattr(author, "slug", None) or f"user-{getattr(author, 'id', 'unknown')}",
}
for author in getattr(shout, "authors", [])
],
"topics": [
{
"id": getattr(topic, "id", None),
"title": getattr(topic, "title", None),
"slug": getattr(topic, "slug", None),
}
for topic in getattr(shout, "topics", [])
],
"version_of": getattr(shout, "version_of", None),
"draft": getattr(shout, "draft", None),
"stat": None,
}
def _get_community_info(self, community_id: int, session) -> dict[str, Any]:
"""Получает информацию о сообществе"""
if not community_id or community_id == 0:
return {
"id": 1, # Default community ID
"name": "Дискурс",
"slug": "discours",
}
community = session.query(Community).filter(Community.id == community_id).first()
if community:
return {
"id": community.id,
"name": community.name or f"Community {community.id}",
"slug": community.slug or f"community-{community.id}",
}
return {
"id": community_id,
"name": f"Unknown Community {community_id}",
"slug": f"unknown-community-{community_id}",
}
def restore_shout(self, shout_id: int) -> dict[str, Any]:
"""Восстанавливает удаленную публикацию"""
with local_session() as session:
shout = session.query(Shout).filter(Shout.id == shout_id).first()
if not shout:
return {"success": False, "error": f"Публикация с ID {shout_id} не найдена"}
if not shout.deleted_at:
return {"success": False, "error": "Публикация не была удалена"}
shout.deleted_at = null()
shout.deleted_by = null()
session.commit()
logger.info(f"Публикация {shout.title or shout.id} восстановлена")
return {"success": True}
# === ПРИГЛАШЕНИЯ ===
def get_invites(self, limit: int = 20, offset: int = 0, search: str = "", status: str = "all") -> dict[str, Any]:
"""Получает список приглашений"""
limit, offset = self.normalize_pagination(limit, offset)
with local_session() as session:
query = session.query(Invite).options(
joinedload(Invite.inviter),
joinedload(Invite.author),
joinedload(Invite.shout),
)
# Фильтр по статусу
if status and status != "all":
status_enum = InviteStatus[status.upper()]
query = query.filter(Invite.status == status_enum.value)
# Поиск
if search and search.strip():
search_term = f"%{search.strip().lower()}%"
query = query.filter(
or_(
Invite.inviter.has(Author.email.ilike(search_term)),
Invite.inviter.has(Author.name.ilike(search_term)),
Invite.author.has(Author.email.ilike(search_term)),
Invite.author.has(Author.name.ilike(search_term)),
Invite.shout.has(Shout.title.ilike(search_term)),
cast(Invite.inviter_id, String).ilike(search_term),
cast(Invite.author_id, String).ilike(search_term),
cast(Invite.shout_id, String).ilike(search_term),
)
)
total_count = query.count()
invites = (
query.order_by(Invite.inviter_id, Invite.author_id, Invite.shout_id).offset(offset).limit(limit).all()
)
pagination_info = self.calculate_pagination_info(total_count, limit, offset)
result_invites = []
for invite in invites:
created_by_info = self.get_author_info(
(invite.shout.created_by if invite.shout else None) or 0, session
)
result_invites.append(
{
"inviter_id": invite.inviter_id,
"author_id": invite.author_id,
"shout_id": invite.shout_id,
"status": invite.status,
"inviter": {
"id": invite.inviter.id,
"name": invite.inviter.name or "Без имени",
"email": invite.inviter.email,
"slug": invite.inviter.slug or f"user-{invite.inviter.id}",
},
"author": {
"id": invite.author.id,
"name": invite.author.name or "Без имени",
"email": invite.author.email,
"slug": invite.author.slug or f"user-{invite.author.id}",
},
"shout": {
"id": invite.shout.id,
"title": invite.shout.title,
"slug": invite.shout.slug,
"created_by": created_by_info,
},
"created_at": None,
}
)
return {
"invites": result_invites,
**pagination_info,
}
def update_invite(self, invite_data: dict[str, Any]) -> dict[str, Any]:
"""Обновляет приглашение"""
inviter_id = invite_data["inviter_id"]
author_id = invite_data["author_id"]
shout_id = invite_data["shout_id"]
new_status = invite_data["status"]
with local_session() as session:
invite = (
session.query(Invite)
.filter(
Invite.inviter_id == inviter_id,
Invite.author_id == author_id,
Invite.shout_id == shout_id,
)
.first()
)
if not invite:
return {"success": False, "error": "Приглашение не найдено"}
old_status = invite.status
invite.status = new_status
session.commit()
logger.info(f"Статус приглашения обновлен: {old_status}{new_status}")
return {"success": True, "error": None}
def delete_invite(self, inviter_id: int, author_id: int, shout_id: int) -> dict[str, Any]:
"""Удаляет приглашение"""
with local_session() as session:
invite = (
session.query(Invite)
.filter(
Invite.inviter_id == inviter_id,
Invite.author_id == author_id,
Invite.shout_id == shout_id,
)
.first()
)
if not invite:
return {"success": False, "error": "Приглашение не найдено"}
session.delete(invite)
session.commit()
logger.info(f"Приглашение {inviter_id}-{author_id}-{shout_id} удалено")
return {"success": True, "error": None}
# === ПЕРЕМЕННЫЕ ОКРУЖЕНИЯ ===
async def get_env_variables(self) -> list[dict[str, Any]]:
"""Получает переменные окружения"""
env_manager = EnvManager()
sections = await env_manager.get_all_variables()
return [
{
"name": section.name,
"description": section.description,
"variables": [
{
"key": var.key,
"value": var.value,
"description": var.description,
"type": var.type,
"isSecret": var.is_secret,
}
for var in section.variables
],
}
for section in sections
]
async def update_env_variable(self, key: str, value: str) -> dict[str, Any]:
"""Обновляет переменную окружения"""
try:
env_manager = EnvManager()
result = env_manager.update_variables([EnvVariable(key=key, value=value)])
if result:
logger.info(f"Переменная '{key}' обновлена")
return {"success": True, "error": None}
return {"success": False, "error": f"Не удалось обновить переменную '{key}'"}
except Exception as e:
logger.error(f"Ошибка обновления переменной: {e}")
return {"success": False, "error": str(e)}
async def update_env_variables(self, variables: list[dict[str, Any]]) -> dict[str, Any]:
"""Массовое обновление переменных окружения"""
try:
env_manager = EnvManager()
env_variables = [
EnvVariable(key=var.get("key", ""), value=var.get("value", ""), type=var.get("type", "string"))
for var in variables
]
result = env_manager.update_variables(env_variables)
if result:
logger.info(f"Обновлено {len(variables)} переменных")
return {"success": True, "error": None}
return {"success": False, "error": "Не удалось обновить переменные"}
except Exception as e:
logger.error(f"Ошибка массового обновления: {e}")
return {"success": False, "error": str(e)}
# === РОЛИ ===
def get_roles(self, community: int = None) -> list[dict[str, Any]]:
"""Получает список ролей"""
from orm.community import role_descriptions, role_names
all_roles = ["reader", "author", "artist", "expert", "editor", "admin"]
if community is not None:
with local_session() as session:
community_obj = session.query(Community).filter(Community.id == community).first()
available_roles = community_obj.get_available_roles() if community_obj else all_roles
else:
available_roles = all_roles
return [
{
"id": role_id,
"name": role_names.get(role_id, role_id.title()),
"description": role_descriptions.get(role_id, f"Роль {role_id}"),
}
for role_id in available_roles
]
# Синглтон сервиса
admin_service = AdminService()

View File

@@ -1,253 +1,718 @@
"""
Сервис аутентификации с бизнес-логикой для регистрации,
входа и управления сессиями и декорраторами для GraphQL.
"""
import json
import secrets
import time
from functools import wraps
from typing import Any, Callable, Optional
from sqlalchemy import exc
from starlette.requests import Request
from auth.email import send_auth_email
from auth.exceptions import InvalidPassword, InvalidToken, ObjectNotExist
from auth.identity import Identity, Password
from auth.internal import verify_internal_auth
from auth.jwtcodec import JWTCodec
from auth.orm import Author
from auth.tokens.storage import TokenStorage
from cache.cache import get_cached_author_by_id
from resolvers.stat import get_with_stat
from orm.community import Community, CommunityAuthor, CommunityFollower
from services.db import local_session
from settings import SESSION_TOKEN_HEADER
from services.redis import redis
from settings import (
ADMIN_EMAILS,
SESSION_COOKIE_NAME,
SESSION_TOKEN_HEADER,
)
from utils.generate_slug import generate_unique_slug
from utils.logger import root_logger as logger
# Список разрешенных заголовков
ALLOWED_HEADERS = ["Authorization", "Content-Type"]
async def check_auth(req: Request) -> tuple[int, list[str], bool]:
"""
Проверка авторизации пользователя.
class AuthService:
"""Сервис аутентификации с бизнес-логикой"""
Проверяет токен и получает данные из локальной БД.
async def check_auth(self, req: Request) -> tuple[int, list[str], bool]:
"""
Проверка авторизации пользователя.
Параметры:
- req: Входящий GraphQL запрос, содержащий заголовок авторизации.
Проверяет токен и получает данные из локальной БД.
"""
logger.debug("[check_auth] Проверка авторизации...")
Возвращает:
- user_id: str - Идентификатор пользователя
- user_roles: list[str] - Список ролей пользователя
- is_admin: bool - Флаг наличия у пользователя административных прав
"""
logger.debug("[check_auth] Проверка авторизации...")
# Получаем заголовок авторизации
token = None
# Получаем заголовок авторизации
token = None
# Если req is None (в тестах), возвращаем пустые данные
if not req:
logger.debug("[check_auth] Запрос отсутствует (тестовое окружение)")
return 0, [], False
# Если req is None (в тестах), возвращаем пустые данные
if not req:
logger.debug("[check_auth] Запрос отсутствует (тестовое окружение)")
return 0, [], False
# Проверяем заголовок с учетом регистра
headers_dict = dict(req.headers.items())
logger.debug(f"[check_auth] Все заголовки: {headers_dict}")
# Проверяем заголовок с учетом регистра
headers_dict = dict(req.headers.items())
logger.debug(f"[check_auth] Все заголовки: {headers_dict}")
# Ищем заголовок Authorization независимо от регистра
for header_name, header_value in headers_dict.items():
if header_name.lower() == SESSION_TOKEN_HEADER.lower():
token = header_value
logger.debug(f"[check_auth] Найден заголовок {header_name}: {token[:10]}...")
break
# Ищем заголовок Authorization независимо от регистра
for header_name, header_value in headers_dict.items():
if header_name.lower() == SESSION_TOKEN_HEADER.lower():
token = header_value
logger.debug(f"[check_auth] Найден заголовок {header_name}: {token[:10]}...")
break
if not token:
logger.debug("[check_auth] Токен не найден в заголовках")
return 0, [], False
if not token:
logger.debug("[check_auth] Токен не найден в заголовках")
return 0, [], False
# Очищаем токен от префикса Bearer если он есть
if token.startswith("Bearer "):
token = token.split("Bearer ")[-1].strip()
# Очищаем токен от префикса Bearer если он есть
if token.startswith("Bearer "):
token = token.split("Bearer ")[-1].strip()
# Проверяем авторизацию внутренним механизмом
logger.debug("[check_auth] Вызов verify_internal_auth...")
user_id, user_roles, is_admin = await verify_internal_auth(token)
logger.debug(
f"[check_auth] Результат verify_internal_auth: user_id={user_id}, roles={user_roles}, is_admin={is_admin}"
)
# Проверяем авторизацию внутренним механизмом
logger.debug("[check_auth] Вызов verify_internal_auth...")
user_id, user_roles, is_admin = await verify_internal_auth(token)
logger.debug(
f"[check_auth] Результат verify_internal_auth: user_id={user_id}, roles={user_roles}, is_admin={is_admin}"
)
# Если в ролях нет админа, но есть ID - проверяем в БД
if user_id and not is_admin:
try:
with local_session() as session:
# Преобразуем user_id в число
try:
if isinstance(user_id, str):
user_id_int = int(user_id.strip())
else:
user_id_int = int(user_id)
except (ValueError, TypeError):
logger.error(f"Невозможно преобразовать user_id {user_id} в число")
else:
# Проверяем наличие админских прав через новую RBAC систему
from orm.community import get_user_roles_in_community
user_roles_in_community = get_user_roles_in_community(user_id_int, community_id=1)
is_admin = any(role in ["admin", "super"] for role in user_roles_in_community)
except Exception as e:
logger.error(f"Ошибка при проверке прав администратора: {e}")
return user_id, user_roles, is_admin
async def add_user_role(self, user_id: str, roles: Optional[list[str]] = None) -> Optional[str]:
"""
Добавление ролей пользователю в локальной БД через CommunityAuthor.
"""
if not roles:
roles = ["author", "reader"]
logger.info(f"Adding roles {roles} to user {user_id}")
from orm.community import assign_role_to_user
logger.debug("Using local authentication with new RBAC system")
with local_session() as session:
try:
author = session.query(Author).filter(Author.id == user_id).one()
# Добавляем роли через новую систему RBAC в дефолтное сообщество (ID=1)
for role_name in roles:
success = assign_role_to_user(int(user_id), role_name, community_id=1)
if success:
logger.debug(f"Роль {role_name} добавлена пользователю {user_id}")
else:
logger.warning(f"Не удалось добавить роль {role_name} пользователю {user_id}")
return user_id
except exc.NoResultFound:
logger.error(f"Author {user_id} not found")
return None
def create_user(self, user_dict: dict[str, Any], community_id: int | None = None) -> Author:
"""Создает нового пользователя с дефолтными ролями"""
user = Author(**user_dict)
target_community_id = community_id or 1
with local_session() as session:
session.add(user)
session.flush()
# Получаем сообщество для назначения ролей
community = session.query(Community).filter(Community.id == target_community_id).first()
if not community:
logger.warning(f"Сообщество {target_community_id} не найдено, используем ID=1")
target_community_id = 1
community = session.query(Community).filter(Community.id == target_community_id).first()
if community:
# Инициализируем права сообщества
try:
import asyncio
loop = asyncio.get_event_loop()
loop.run_until_complete(community.initialize_role_permissions())
except Exception as e:
logger.warning(f"Не удалось инициализировать права сообщества: {e}")
# Получаем дефолтные роли
try:
default_roles = community.get_default_roles()
if not default_roles:
default_roles = ["reader", "author"]
except AttributeError:
default_roles = ["reader", "author"]
# Создаем CommunityAuthor с ролями
community_author = CommunityAuthor(
community_id=target_community_id,
author_id=user.id,
roles=",".join(default_roles),
)
session.add(community_author)
# Создаем подписку на сообщество
follower = CommunityFollower(community=target_community_id, follower=int(user.id))
session.add(follower)
logger.info(f"Пользователь {user.id} создан с ролями {default_roles}")
session.commit()
return user
async def get_session(self, token: str) -> dict[str, Any]:
"""Получает информацию о текущей сессии по токену"""
try:
# Проверяем токен
payload = JWTCodec.decode(token)
if not payload:
return {"success": False, "token": None, "author": None, "error": "Невалидный токен"}
token_verification = await TokenStorage.verify_session(token)
if not token_verification:
return {"success": False, "token": None, "author": None, "error": "Токен истек"}
user_id = payload.user_id
# Получаем автора
author = await get_cached_author_by_id(int(user_id), lambda x: x)
if not author:
return {"success": False, "token": None, "author": None, "error": "Пользователь не найден"}
return {"success": True, "token": token, "author": author, "error": None}
except Exception as e:
logger.error(f"Ошибка получения сессии: {e}")
return {"success": False, "token": None, "author": None, "error": str(e)}
async def register_user(self, email: str, password: str = "", name: str = "") -> dict[str, Any]:
"""Регистрирует нового пользователя"""
email = email.lower()
logger.info(f"Попытка регистрации для {email}")
with local_session() as session:
user = session.query(Author).filter(Author.email == email).first()
if user:
logger.warning(f"Пользователь {email} уже существует")
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 = self.create_user(user_dict)
try:
await self.send_verification_link(email)
logger.info(f"Пользователь {email} зарегистрирован, ссылка отправлена")
return {
"success": True,
"token": None,
"author": new_user,
"error": "Требуется подтверждение email.",
}
except Exception as e:
logger.error(f"Ошибка отправки ссылки для {email}: {e}")
return {
"success": True,
"token": None,
"author": new_user,
"error": f"Пользователь зарегистрирован, но ошибка отправки ссылки: {e}",
}
async def send_verification_link(self, email: str, lang: str = "ru", template: str = "confirm") -> Author:
"""Отправляет ссылку подтверждения на email"""
email = email.lower()
with local_session() as session:
user = session.query(Author).filter(Author.email == email).first()
if not user:
raise ObjectNotExist("User not found")
try:
from auth.tokens.verification import VerificationTokenManager
verification_manager = VerificationTokenManager()
token = await verification_manager.create_verification_token(
str(user.id), "email_confirmation", {"email": user.email, "template": template}
)
except (AttributeError, ImportError):
token = await TokenStorage.create_session(
user_id=str(user.id),
username=str(user.username or user.email or user.slug or ""),
device_info={"email": user.email} if hasattr(user, "email") else None,
)
await send_auth_email(user, token, lang, template)
return user
async def confirm_email(self, token: str) -> dict[str, Any]:
"""Подтверждает email по токену"""
try:
logger.info("Начало подтверждения email по токену")
payload = JWTCodec.decode(token)
if not payload:
logger.warning("Невалидный токен")
return {"success": False, "token": None, "author": None, "error": "Невалидный токен"}
token_verification = await TokenStorage.verify_session(token)
if not token_verification:
logger.warning("Токен не найден в системе или истек")
return {"success": False, "token": None, "author": None, "error": "Токен не найден или истек"}
user_id = payload.user_id
username = payload.username
with local_session() as session:
user = session.query(Author).where(Author.id == user_id).first()
if not user:
logger.warning(f"Пользователь с ID {user_id} не найден")
return {"success": False, "token": None, "author": None, "error": "Пользователь не найден"}
device_info = {"email": user.email} if hasattr(user, "email") else None
session_token = await TokenStorage.create_session(
user_id=str(user_id),
username=user.username or user.email or user.slug or username,
device_info=device_info,
)
user.email_verified = True
user.last_seen = int(time.time())
session.add(user)
session.commit()
logger.info(f"Email для пользователя {user_id} подтвержден")
return {"success": True, "token": session_token, "author": user, "error": None}
except InvalidToken as e:
logger.warning(f"Невалидный токен - {e.message}")
return {"success": False, "token": None, "author": None, "error": f"Невалидный токен: {e.message}"}
except Exception as e:
logger.error(f"Ошибка подтверждения email: {e}")
return {"success": False, "token": None, "author": None, "error": f"Ошибка подтверждения email: {e}"}
async def login(self, email: str, password: str, request=None) -> dict[str, Any]:
"""Авторизация пользователя"""
email = email.lower()
logger.info(f"Попытка входа для {email}")
# Если в ролях нет админа, но есть ID - проверяем в БД
if user_id and not is_admin:
try:
with local_session() as session:
# Преобразуем user_id в число
author = session.query(Author).filter(Author.email == email).first()
if not author:
logger.warning(f"Пользователь {email} не найден")
return {"success": False, "token": None, "author": None, "error": "Пользователь не найден"}
# Проверяем роли (упрощенная версия)
has_reader_role = False
if hasattr(author, "roles") and author.roles:
for role in author.roles:
if role.id == "reader":
has_reader_role = True
break
if not has_reader_role and author.email not in ADMIN_EMAILS.split(","):
logger.warning(f"У пользователя {email} нет роли 'reader'")
return {"success": False, "token": None, "author": None, "error": "Нет прав для входа"}
# Проверяем пароль
try:
if isinstance(user_id, str):
user_id_int = int(user_id.strip())
else:
user_id_int = int(user_id)
except (ValueError, TypeError):
logger.error(f"Невозможно преобразовать user_id {user_id} в число")
else:
# Проверяем наличие админских прав через новую RBAC систему
from orm.community import get_user_roles_in_community
valid_author = Identity.password(author, password)
except (InvalidPassword, Exception) as e:
logger.warning(f"Неверный пароль для {email}: {e}")
return {"success": False, "token": None, "author": None, "error": str(e)}
# Создаем токен
username = str(valid_author.username or valid_author.email or valid_author.slug or "")
token = await TokenStorage.create_session(
user_id=str(valid_author.id),
username=username,
device_info={"email": valid_author.email} if hasattr(valid_author, "email") else None,
)
# Обновляем время входа
valid_author.last_seen = int(time.time())
session.commit()
# Устанавливаем cookie если есть request
if request and token:
self._set_auth_cookie(request, token)
try:
author_dict = valid_author.dict(True)
except Exception:
author_dict = {
"id": valid_author.id,
"email": valid_author.email,
"name": getattr(valid_author, "name", ""),
"slug": getattr(valid_author, "slug", ""),
"username": getattr(valid_author, "username", ""),
}
logger.info(f"Успешный вход для {email}")
return {"success": True, "token": token, "author": author_dict, "error": None}
user_roles_in_community = get_user_roles_in_community(user_id_int, community_id=1)
is_admin = any(role in ["admin", "super"] for role in user_roles_in_community)
except Exception as e:
logger.error(f"Ошибка при проверке прав администратора: {e}")
logger.error(f"Ошибка входа для {email}: {e}")
return {"success": False, "token": None, "author": None, "error": f"Ошибка авторизации: {e}"}
return user_id, user_roles, is_admin
async def add_user_role(user_id: str, roles: Optional[list[str]] = None) -> Optional[str]:
"""
Добавление ролей пользователю в локальной БД через CommunityAuthor.
Args:
user_id: ID пользователя
roles: Список ролей для добавления. По умолчанию ["author", "reader"]
"""
if not roles:
roles = ["author", "reader"]
logger.info(f"Adding roles {roles} to user {user_id}")
from orm.community import assign_role_to_user
logger.debug("Using local authentication with new RBAC system")
with local_session() as session:
def _set_auth_cookie(self, request, token: str) -> bool:
"""Устанавливает cookie аутентификации"""
try:
author = session.query(Author).filter(Author.id == user_id).one()
if hasattr(request, "cookies"):
request.cookies[SESSION_COOKIE_NAME] = token
return True
except Exception as e:
logger.error(f"Ошибка установки cookie: {e}")
return False
# Добавляем роли через новую систему RBAC в дефолтное сообщество (ID=1)
for role_name in roles:
success = assign_role_to_user(int(user_id), role_name, community_id=1)
if success:
logger.debug(f"Роль {role_name} добавлена пользователю {user_id}")
else:
logger.warning(f"Не удалось добавить роль {role_name} пользователю {user_id}")
async def logout(self, user_id: str, token: str = None) -> dict[str, Any]:
"""Выход из системы"""
try:
if token:
await TokenStorage.revoke_session(token)
logger.info(f"Пользователь {user_id} вышел из системы")
return {"success": True, "message": "Успешный выход"}
except Exception as e:
logger.error(f"Ошибка выхода для {user_id}: {e}")
return {"success": False, "message": f"Ошибка выхода: {e}"}
return user_id
async def refresh_token(self, user_id: str, old_token: str, device_info: dict = None) -> dict[str, Any]:
"""Обновление токена"""
try:
new_token = await TokenStorage.refresh_session(int(user_id), old_token, device_info or {})
if not new_token:
return {"success": False, "token": None, "author": None, "error": "Не удалось обновить токен"}
except exc.NoResultFound:
logger.error(f"Author {user_id} not found")
return None
# Получаем данные пользователя
with local_session() as session:
author = session.query(Author).filter(Author.id == int(user_id)).first()
if not author:
return {"success": False, "token": None, "author": None, "error": "Пользователь не найден"}
try:
author_dict = author.dict(True)
except Exception:
author_dict = {
"id": author.id,
"email": author.email,
"name": getattr(author, "name", ""),
"slug": getattr(author, "slug", ""),
}
def login_required(f: Callable) -> Callable:
"""Декоратор для проверки авторизации пользователя. Требуется наличие роли 'reader'."""
return {"success": True, "token": new_token, "author": author_dict, "error": None}
@wraps(f)
async def decorated_function(*args: Any, **kwargs: Any) -> Any:
from graphql.error import GraphQLError
except Exception as e:
logger.error(f"Ошибка обновления токена для {user_id}: {e}")
return {"success": False, "token": None, "author": None, "error": str(e)}
info = args[1]
req = info.context.get("request")
async def request_password_reset(self, email: str, lang: str = "ru") -> dict[str, Any]:
"""Запрос сброса пароля"""
try:
email = email.lower()
logger.info(f"Запрос сброса пароля для {email}")
logger.debug(
f"[login_required] Проверка авторизации для запроса: {req.method if req else 'unknown'} {req.url.path if req and hasattr(req, 'url') else 'unknown'}"
)
logger.debug(f"[login_required] Заголовки: {req.headers if req else 'none'}")
with local_session() as session:
author = session.query(Author).filter(Author.email == email).first()
if not author:
logger.warning(f"Пользователь {email} не найден")
return {"success": True} # Для безопасности
# Извлекаем токен из заголовков для сохранения в контексте
token = None
if req:
# Проверяем заголовок с учетом регистра
headers_dict = dict(req.headers.items())
try:
from auth.tokens.verification import VerificationTokenManager
# Ищем заголовок Authorization независимо от регистра
for header_name, header_value in headers_dict.items():
if header_name.lower() == SESSION_TOKEN_HEADER.lower():
token = header_value
logger.debug(
f"[login_required] Найден заголовок {header_name}: {token[:10] if token else 'None'}..."
verification_manager = VerificationTokenManager()
token = await verification_manager.create_verification_token(
str(author.id), "password_reset", {"email": author.email}
)
except (AttributeError, ImportError):
token = await TokenStorage.create_session(
user_id=str(author.id),
username=str(author.username or author.email or author.slug or ""),
device_info={"email": author.email} if hasattr(author, "email") else None,
)
break
# Очищаем токен от префикса Bearer если он есть
if token and token.startswith("Bearer "):
token = token.split("Bearer ")[-1].strip()
await send_auth_email(author, token, lang, "password_reset")
logger.info(f"Письмо сброса пароля отправлено для {email}")
# Для тестового режима: если req отсутствует, но в контексте есть author и roles
if not req and info.context.get("author") and info.context.get("roles"):
logger.debug("[login_required] Тестовый режим: используем данные из контекста")
user_id = info.context["author"]["id"]
user_roles = info.context["roles"]
is_admin = info.context.get("is_admin", False)
# В тестовом режиме токен может быть в контексте
if not token:
token = info.context.get("token")
else:
# Обычный режим: проверяем через HTTP заголовки
user_id, user_roles, is_admin = await check_auth(req)
return {"success": True}
except Exception as e:
logger.error(f"Ошибка запроса сброса пароля для {email}: {e}")
return {"success": False}
def is_email_used(self, email: str) -> bool:
"""Проверяет, используется ли email"""
email = email.lower()
with local_session() as session:
user = session.query(Author).filter(Author.email == email).first()
return user is not None
async def update_security(
self, user_id: int, old_password: str, new_password: str = None, email: str = None
) -> dict[str, Any]:
"""Обновление пароля и email"""
try:
with local_session() as session:
author = session.query(Author).filter(Author.id == user_id).first()
if not author:
return {"success": False, "error": "NOT_AUTHENTICATED", "author": None}
if not author.verify_password(old_password):
return {"success": False, "error": "incorrect old password", "author": None}
if email and email != author.email:
existing_user = session.query(Author).filter(Author.email == email).first()
if existing_user:
return {"success": False, "error": "email already exists", "author": None}
changes_made = []
if new_password:
author.set_password(new_password)
changes_made.append("password")
if email and email != author.email:
# Создаем запрос на смену email через Redis
token = secrets.token_urlsafe(32)
email_change_data = {
"user_id": user_id,
"old_email": author.email,
"new_email": email,
"token": token,
"expires_at": int(time.time()) + 3600, # 1 час
}
redis_key = f"email_change:{user_id}"
await redis.execute("SET", redis_key, json.dumps(email_change_data))
await redis.execute("EXPIRE", redis_key, 3600)
changes_made.append("email_pending")
logger.info(f"Email смена инициирована для пользователя {user_id}")
session.commit()
logger.info(f"Безопасность обновлена для {user_id}: {changes_made}")
return {"success": True, "error": None, "author": author}
except Exception as e:
logger.error(f"Ошибка обновления безопасности для {user_id}: {e}")
return {"success": False, "error": str(e), "author": None}
async def confirm_email_change(self, user_id: int, token: str) -> dict[str, Any]:
"""Подтверждение смены email по токену"""
try:
# Получаем данные смены email из Redis
redis_key = f"email_change:{user_id}"
cached_data = await redis.execute("GET", redis_key)
if not cached_data:
return {"success": False, "error": "NO_PENDING_EMAIL", "author": None}
try:
email_change_data = json.loads(cached_data)
except json.JSONDecodeError:
return {"success": False, "error": "INVALID_TOKEN", "author": None}
# Проверяем токен
if email_change_data.get("token") != token:
return {"success": False, "error": "INVALID_TOKEN", "author": None}
# Проверяем срок действия
if email_change_data.get("expires_at", 0) < int(time.time()):
await redis.execute("DEL", redis_key)
return {"success": False, "error": "TOKEN_EXPIRED", "author": None}
new_email = email_change_data.get("new_email")
if not new_email:
return {"success": False, "error": "INVALID_TOKEN", "author": None}
with local_session() as session:
author = session.query(Author).filter(Author.id == user_id).first()
if not author:
return {"success": False, "error": "NOT_AUTHENTICATED", "author": None}
# Проверяем, что новый email не занят
existing_user = session.query(Author).filter(Author.email == new_email).first()
if existing_user and existing_user.id != author.id:
await redis.execute("DEL", redis_key)
return {"success": False, "error": "email already exists", "author": None}
# Применяем смену email
author.email = new_email
author.email_verified = True
author.updated_at = int(time.time())
session.add(author)
session.commit()
# Удаляем данные из Redis
await redis.execute("DEL", redis_key)
logger.info(f"Email изменен для пользователя {user_id}")
return {"success": True, "error": None, "author": author}
except Exception as e:
logger.error(f"Ошибка подтверждения смены email: {e}")
return {"success": False, "error": str(e), "author": None}
async def cancel_email_change(self, user_id: int) -> dict[str, Any]:
"""Отмена смены email"""
try:
redis_key = f"email_change:{user_id}"
cached_data = await redis.execute("GET", redis_key)
if not cached_data:
return {"success": False, "error": "NO_PENDING_EMAIL", "author": None}
# Удаляем данные из Redis
await redis.execute("DEL", redis_key)
# Получаем текущие данные пользователя
with local_session() as session:
author = session.query(Author).filter(Author.id == user_id).first()
if not author:
return {"success": False, "error": "NOT_AUTHENTICATED", "author": None}
logger.info(f"Смена email отменена для пользователя {user_id}")
return {"success": True, "error": None, "author": author}
except Exception as e:
logger.error(f"Ошибка отмены смены email: {e}")
return {"success": False, "error": str(e), "author": None}
def login_required(self, f: Callable) -> Callable:
"""Декоратор для проверки авторизации пользователя. Требуется наличие роли 'reader'."""
@wraps(f)
async def decorated_function(*args: Any, **kwargs: Any) -> Any:
from graphql.error import GraphQLError
info = args[1]
req = info.context.get("request")
if not user_id:
logger.debug(
f"[login_required] Пользователь не авторизован, req={dict(req) if req else 'None'}, info={info}"
f"[login_required] Проверка авторизации для запроса: {req.method if req else 'unknown'} {req.url.path if req and hasattr(req, 'url') else 'unknown'}"
)
msg = "Требуется авторизация"
raise GraphQLError(msg)
# Проверяем наличие роли reader
if "reader" not in user_roles and not is_admin:
logger.error(f"Пользователь {user_id} не имеет роли 'reader'")
msg = "У вас нет необходимых прав для доступа"
raise GraphQLError(msg)
# Извлекаем токен из заголовков
token = None
if req:
headers_dict = dict(req.headers.items())
for header_name, header_value in headers_dict.items():
if header_name.lower() == SESSION_TOKEN_HEADER.lower():
token = header_value
break
logger.info(f"Авторизован пользователь {user_id} с ролями: {user_roles}")
info.context["roles"] = user_roles
if token and token.startswith("Bearer "):
token = token.split("Bearer ")[-1].strip()
# Проверяем права администратора
info.context["is_admin"] = is_admin
# Для тестового режима
if not req and info.context.get("author") and info.context.get("roles"):
logger.debug("[login_required] Тестовый режим")
user_id = info.context["author"]["id"]
user_roles = info.context["roles"]
is_admin = info.context.get("is_admin", False)
if not token:
token = info.context.get("token")
else:
# Обычный режим
user_id, user_roles, is_admin = await self.check_auth(req)
# Сохраняем токен в контексте для доступа в резолверах
if token:
info.context["token"] = token
logger.debug(f"[login_required] Токен сохранен в контексте: {token[:10] if token else 'None'}...")
if not user_id:
msg = "Требуется авторизация"
raise GraphQLError(msg)
# В тестовом режиме автор уже может быть в контексте
if (
not info.context.get("author")
or not isinstance(info.context["author"], dict)
or "dict" not in str(type(info.context["author"]))
):
author = await get_cached_author_by_id(user_id, get_with_stat)
if not author:
logger.error(f"Профиль автора не найден для пользователя {user_id}")
info.context["author"] = author
# Проверяем роль reader
if "reader" not in user_roles and not is_admin:
msg = "У вас нет необходимых прав для доступа"
raise GraphQLError(msg)
return await f(*args, **kwargs)
return decorated_function
def login_accepted(f: Callable) -> Callable:
"""Декоратор для добавления данных авторизации в контекст."""
@wraps(f)
async def decorated_function(*args: Any, **kwargs: Any) -> Any:
info = args[1]
req = info.context.get("request")
logger.debug("login_accepted: Проверка авторизации пользователя.")
user_id, user_roles, is_admin = await check_auth(req)
logger.debug(f"login_accepted: user_id={user_id}, user_roles={user_roles}")
if user_id and user_roles:
logger.info(f"login_accepted: Пользователь авторизован: {user_id} с ролями {user_roles}")
logger.info(f"Авторизован пользователь {user_id} с ролями: {user_roles}")
info.context["roles"] = user_roles
# Проверяем права администратора
info.context["is_admin"] = is_admin
# Пробуем получить профиль автора
author = await get_cached_author_by_id(user_id, get_with_stat)
if author:
logger.debug(f"login_accepted: Найден профиль автора: {author}")
# Используем флаг is_admin из контекста или передаем права владельца для собственных данных
is_owner = True # Пользователь всегда является владельцем собственного профиля
info.context["author"] = author.dict(is_owner or is_admin)
if token:
info.context["token"] = token
# Получаем автора если его нет в контексте
if not info.context.get("author") or not isinstance(info.context["author"], dict):
author = await get_cached_author_by_id(int(user_id), lambda x: x)
if not author:
logger.error(f"Профиль автора не найден для пользователя {user_id}")
info.context["author"] = author
return await f(*args, **kwargs)
return decorated_function
def login_accepted(self, f: Callable) -> Callable:
"""Декоратор для добавления данных авторизации в контекст."""
@wraps(f)
async def decorated_function(*args: Any, **kwargs: Any) -> Any:
info = args[1]
req = info.context.get("request")
logger.debug("login_accepted: Проверка авторизации пользователя.")
user_id, user_roles, is_admin = await self.check_auth(req)
if user_id and user_roles:
logger.info(f"login_accepted: Пользователь авторизован: {user_id} с ролями {user_roles}")
info.context["roles"] = user_roles
info.context["is_admin"] = is_admin
author = await get_cached_author_by_id(int(user_id), lambda x: x)
if author:
is_owner = True
info.context["author"] = author.dict(is_owner or is_admin)
else:
logger.error(f"login_accepted: Профиль автора не найден для пользователя {user_id}")
else:
logger.error(
f"login_accepted: Профиль автора не найден для пользователя {user_id}. Используем базовые данные."
)
else:
logger.debug("login_accepted: Пользователь не авторизован. Очищаем контекст.")
info.context["roles"] = None
info.context["author"] = None
info.context["is_admin"] = False
logger.debug("login_accepted: Пользователь не авторизован")
info.context["roles"] = None
info.context["author"] = None
info.context["is_admin"] = False
return await f(*args, **kwargs)
return await f(*args, **kwargs)
return decorated_function
return decorated_function
# Синглтон сервиса
auth_service = AuthService()
# Экспортируем функции для обратной совместимости
check_auth = auth_service.check_auth
add_user_role = auth_service.add_user_role
login_required = auth_service.login_required
login_accepted = auth_service.login_accepted