From 1b48675b92a2bf0d944a7090441dd8481b87b2d5 Mon Sep 17 00:00:00 2001 From: Untone Date: Mon, 18 Aug 2025 14:25:25 +0300 Subject: [PATCH] [0.9.7] - 2025-08-18 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 🔄 Изменения - **SQLAlchemy KeyError** - исправление ошибки `KeyError: Reaction` при инициализации - **Исправлена ошибка SQLAlchemy**: Устранена проблема `InvalidRequestError: When initializing mapper Mapper[Shout(shout)], expression Reaction failed to locate a name (Reaction)` ### 🧪 Тестирование - **Исправление тестов** - адаптация к новой структуре моделей - **RBAC инициализация** - добавление `rbac.initialize_rbac()` в `conftest.py` - **Создан тест для getSession**: Добавлен комплексный тест `test_getSession_cookies.py` с проверкой всех сценариев - **Покрытие edge cases**: Тесты проверяют работу с валидными/невалидными токенами, отсутствующими пользователями - **Мокирование зависимостей**: Использование unittest.mock для изоляции тестируемого кода ### 🔧 Рефакторинг - **Упрощена архитектура**: Убраны сложные конструкции с отложенными импортами, заменены на чистую архитектуру - **Перемещение моделей** - `Author` и связанные модели перенесены в `orm/author.py`: Вынесены базовые модели пользователей (`Author`, `AuthorFollower`, `AuthorBookmark`, `AuthorRating`) из `orm.author` в отдельный модуль - **Устранены циклические импорты**: Разорван цикл между `auth.core` → `orm.community` → `orm.author` через реструктуризацию архитектуры - **Создан модуль `utils/password.py`**: Класс `Password` вынесен в utils для избежания циклических зависимостей - **Оптимизированы импорты моделей**: Убран прямой импорт `Shout` из `orm/community.py`, заменен на строковые ссылки ### 🔧 Авторизация с cookies - **getSession теперь работает с cookies**: Мутация `getSession` теперь может получать токен из httpOnly cookies даже без заголовка Authorization - **Убрано требование авторизации**: `getSession` больше не требует декоратор `@login_required`, работает автономно - **Поддержка dual-авторизации**: Токен может быть получен как из заголовка Authorization, так и из cookie `session_token` - **Автоматическая установка cookies**: Middleware автоматически устанавливает httpOnly cookies при успешном `getSession` - **Обновлена GraphQL схема**: `SessionInfo` теперь содержит поля `success`, `error` и опциональные `token`, `author` - **Единообразная обработка токенов**: Все модули теперь используют централизованные функции для работы с токенами - **Улучшена обработка ошибок**: Добавлена детальная валидация токенов и пользователей в `getSession` - **Логирование операций**: Добавлены подробные логи для отслеживания процесса авторизации ### 📝 Документация - **Обновлена схема GraphQL**: `SessionInfo` тип теперь соответствует новому формату ответа - Обновлена документация RBAC - Обновлена документация авторизации с cookies --- .github/workflows/deploy.yml | 2 +- CHANGELOG.md | 44 +- auth/__init__.py | 65 +- auth/core.py | 4 +- auth/decorators.py | 4 +- auth/identity.py | 4 +- auth/middleware.py | 31 +- auth/oauth.py | 6 +- auth/utils.py | 118 +- cache/cache.py | 6 +- cache/precache.py | 7 +- cache/triggers.py | 5 +- ci-server.py => ci_server.py | 1 + docs/README.md | 4 +- docs/auth.md | 1436 ++++++++++++------------- docs/features.md | 16 + docs/rbac-system.md | 88 +- main.py | 6 +- orm/__init__.py | 63 ++ auth/orm.py => orm/author.py | 123 ++- orm/community.py | 2 +- orm/draft.py | 2 +- orm/notification.py | 2 +- orm/reaction.py | 7 - orm/topic.py | 2 +- package.json | 2 +- pyproject.toml | 2 +- rbac/api.py | 20 +- rbac/interface.py | 10 +- rbac/operations.py | 88 +- rbac/permissions.py | 2 +- resolvers/admin.py | 4 +- resolvers/auth.py | 35 +- resolvers/author.py | 8 +- resolvers/bookmark.py | 4 +- resolvers/collab.py | 2 +- resolvers/collection.py | 2 +- resolvers/community.py | 2 +- resolvers/draft.py | 6 +- resolvers/editor.py | 8 +- resolvers/feed.py | 4 +- resolvers/follower.py | 4 +- resolvers/notifier.py | 2 +- resolvers/rating.py | 4 +- resolvers/reaction.py | 4 +- resolvers/reader.py | 6 +- resolvers/stat.py | 10 +- resolvers/topic.py | 2 +- schema/type.graphql | 6 +- services/admin.py | 4 +- services/auth.py | 26 +- services/viewed.py | 2 +- storage/schema.py | 4 +- tests/auth/test_auth_service.py | 2 +- tests/auth/test_identity.py | 2 +- tests/auth/test_oauth.py | 4 +- tests/conftest.py | 12 +- tests/test_admin_panel_fixes.py | 8 +- tests/test_admin_permissions.py | 2 +- tests/test_auth_coverage.py | 20 +- tests/test_auth_fixes.py | 25 +- tests/test_community_creator_fix.py | 2 +- tests/test_community_functionality.py | 2 +- tests/test_community_rbac.py | 2 +- tests/test_config.py | 2 +- tests/test_coverage_imports.py | 8 +- tests/test_db_coverage.py | 2 +- tests/test_drafts.py | 2 +- tests/test_getSession_cookies.py | 276 +++++ tests/test_rbac_integration.py | 2 +- tests/test_rbac_system.py | 2 +- tests/test_reactions.py | 2 +- tests/test_shouts.py | 2 +- tests/test_unpublish_shout.py | 2 +- tests/test_update_security.py | 2 +- utils/generate_slug.py | 2 +- {auth => utils}/password.py | 0 uv.lock | 2 +- 78 files changed, 1658 insertions(+), 1050 deletions(-) rename ci-server.py => ci_server.py (99%) rename auth/orm.py => orm/author.py (76%) create mode 100644 tests/test_getSession_cookies.py rename {auth => utils}/password.py (100%) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f51492b9..4e2378db 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -136,7 +136,7 @@ jobs: from orm.reaction import Reaction from orm.shout import Shout from orm.topic import Topic - from auth.orm import Author, AuthorBookmark, AuthorRating, AuthorFollower + from orm.author import Author, AuthorBookmark, AuthorRating, AuthorFollower from storage.db import engine from sqlalchemy import inspect diff --git a/CHANGELOG.md b/CHANGELOG.md index 1358f628..52b15337 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,22 +1,40 @@ # Changelog -Все значимые изменения в проекте документируются в этом файле. -## [0.9.7] - 2025-08-17 +## [0.9.7] - 2025-08-18 -### 🔧 Исправления архитектуры -- **Устранены циклические импорты в ORM**: Исправлена проблема с циклическими импортами между `orm/community.py` и `orm/shout.py` +### 🔄 Изменения +- **SQLAlchemy KeyError** - исправление ошибки `KeyError: 'Reaction'` при инициализации +- **Исправлена ошибка SQLAlchemy**: Устранена проблема `InvalidRequestError: When initializing mapper Mapper[Shout(shout)], expression 'Reaction' failed to locate a name ('Reaction')` + +### 🧪 Тестирование +- **Исправление тестов** - адаптация к новой структуре моделей +- **RBAC инициализация** - добавление `rbac.initialize_rbac()` в `conftest.py` +- **Создан тест для getSession**: Добавлен комплексный тест `test_getSession_cookies.py` с проверкой всех сценариев +- **Покрытие edge cases**: Тесты проверяют работу с валидными/невалидными токенами, отсутствующими пользователями +- **Мокирование зависимостей**: Использование unittest.mock для изоляции тестируемого кода + +### 🔧 Рефакторинг +- **Упрощена архитектура**: Убраны сложные конструкции с отложенными импортами, заменены на чистую архитектуру +- **Перемещение моделей** - `Author` и связанные модели перенесены в `orm/author.py`: Вынесены базовые модели пользователей (`Author`, `AuthorFollower`, `AuthorBookmark`, `AuthorRating`) из `orm.author` в отдельный модуль +- **Устранены циклические импорты**: Разорван цикл между `auth.core` → `orm.community` → `orm.author` через реструктуризацию архитектуры +- **Создан модуль `utils/password.py`**: Класс `Password` вынесен в utils для избежания циклических зависимостей - **Оптимизированы импорты моделей**: Убран прямой импорт `Shout` из `orm/community.py`, заменен на строковые ссылки -- **Исправлены предупреждения ruff**: Добавлены `# noqa: PLW0603` комментарии для подавления предупреждений о `global` в `rbac/interface.py` -- **Улучшена совместимость SQLAlchemy**: Использование `text()` для сложных SQL выражений в `CommunityStats` -### 🏷️ Типизация -- **Исправлены mypy ошибки**: Все ORM модели теперь корректно проходят проверку типов -- **Улучшена совместимость**: Использование `BaseModel` вместо алиаса `Base` для избежания путаницы +### 🔧 Авторизация с cookies +- **getSession теперь работает с cookies**: Мутация `getSession` теперь может получать токен из httpOnly cookies даже без заголовка Authorization +- **Убрано требование авторизации**: `getSession` больше не требует декоратор `@login_required`, работает автономно +- **Поддержка dual-авторизации**: Токен может быть получен как из заголовка Authorization, так и из cookie `session_token` +- **Автоматическая установка cookies**: Middleware автоматически устанавливает httpOnly cookies при успешном `getSession` +- **Обновлена GraphQL схема**: `SessionInfo` теперь содержит поля `success`, `error` и опциональные `token`, `author` +- **Единообразная обработка токенов**: Все модули теперь используют централизованные функции для работы с токенами +- **Улучшена обработка ошибок**: Добавлена детальная валидация токенов и пользователей в `getSession` +- **Логирование операций**: Добавлены подробные логи для отслеживания процесса авторизации -### 🧹 Код-качество -- **Упрощена архитектура импортов**: Убраны сложные конструкции для избежания `global` -- **Сохранена функциональность**: Все методы `CommunityStats` работают корректно с новой архитектурой +### 📝 Документация +- **Обновлена схема GraphQL**: `SessionInfo` тип теперь соответствует новому формату ответа +- Обновлена документация RBAC +- Обновлена документация авторизации с cookies ## [0.9.6] - 2025-08-12 @@ -2086,4 +2104,4 @@ Radical architecture simplification with separation into service layer and thin - `gittask`, `inbox` and `auth` logics removed - `settings` moved to base and now smaller - new outside auth schema -- removed `gittask`, `auth`, `inbox`, `migration` \ No newline at end of file +- removed `gittask`, `auth`, `inbox`, `migration` diff --git a/auth/__init__.py b/auth/__init__.py index 8c2519b9..f8d88217 100644 --- a/auth/__init__.py +++ b/auth/__init__.py @@ -1,19 +1,18 @@ from starlette.requests import Request from starlette.responses import JSONResponse, RedirectResponse, Response -# Импорт базовых функций из реструктурированных модулей from auth.core import verify_internal_auth -from auth.orm import Author from auth.tokens.storage import TokenStorage -from storage.db import local_session +from auth.utils import extract_token_from_request +from orm.author import Author from settings import ( SESSION_COOKIE_HTTPONLY, SESSION_COOKIE_MAX_AGE, SESSION_COOKIE_NAME, SESSION_COOKIE_SAMESITE, SESSION_COOKIE_SECURE, - SESSION_TOKEN_HEADER, ) +from storage.db import local_session from utils.logger import root_logger as logger @@ -25,30 +24,7 @@ async def logout(request: Request) -> Response: 1. HTTP-only cookie 2. Заголовка Authorization """ - token = None - # Получаем токен из cookie - if SESSION_COOKIE_NAME in request.cookies: - token = request.cookies.get(SESSION_COOKIE_NAME) - logger.debug(f"[auth] logout: Получен токен из cookie {SESSION_COOKIE_NAME}") - - # Если токен не найден в cookie, проверяем заголовок - if not token: - # Сначала проверяем основной заголовок авторизации - auth_header = request.headers.get(SESSION_TOKEN_HEADER) - if auth_header: - if auth_header.startswith("Bearer "): - token = auth_header[7:].strip() - logger.debug(f"[auth] logout: Получен Bearer токен из заголовка {SESSION_TOKEN_HEADER}") - else: - token = auth_header.strip() - logger.debug(f"[auth] logout: Получен прямой токен из заголовка {SESSION_TOKEN_HEADER}") - - # Если токен не найден в основном заголовке, проверяем стандартный Authorization - if not token and "Authorization" in request.headers: - auth_header = request.headers.get("Authorization") - if auth_header and auth_header.startswith("Bearer "): - token = auth_header[7:].strip() - logger.debug("[auth] logout: Получен Bearer токен из заголовка Authorization") + token = await extract_token_from_request(request) # Если токен найден, отзываем его if token: @@ -91,36 +67,7 @@ async def refresh_token(request: Request) -> JSONResponse: Возвращает новый токен как в HTTP-only cookie, так и в теле ответа. """ - token = None - source = None - - # Получаем текущий токен из cookie - if SESSION_COOKIE_NAME in request.cookies: - token = request.cookies.get(SESSION_COOKIE_NAME) - source = "cookie" - logger.debug(f"[auth] refresh_token: Токен получен из cookie {SESSION_COOKIE_NAME}") - - # Если токен не найден в cookie, проверяем заголовок авторизации - if not token: - # Проверяем основной заголовок авторизации - auth_header = request.headers.get(SESSION_TOKEN_HEADER) - if auth_header: - if auth_header.startswith("Bearer "): - token = auth_header[7:].strip() - source = "header" - logger.debug(f"[auth] refresh_token: Токен получен из заголовка {SESSION_TOKEN_HEADER} (Bearer)") - else: - token = auth_header.strip() - source = "header" - logger.debug(f"[auth] refresh_token: Токен получен из заголовка {SESSION_TOKEN_HEADER} (прямой)") - - # Если токен не найден в основном заголовке, проверяем стандартный Authorization - if not token and "Authorization" in request.headers: - auth_header = request.headers.get("Authorization") - if auth_header and auth_header.startswith("Bearer "): - token = auth_header[7:].strip() - source = "header" - logger.debug("[auth] refresh_token: Токен получен из заголовка Authorization") + token = await extract_token_from_request(request) if not token: logger.warning("[auth] refresh_token: Токен не найден в запросе") @@ -152,6 +99,8 @@ async def refresh_token(request: Request) -> JSONResponse: logger.error(f"[auth] refresh_token: Не удалось обновить токен для пользователя {user_id}") return JSONResponse({"success": False, "error": "Не удалось обновить токен"}, status_code=500) + source = "cookie" if token.startswith("Bearer ") else "header" + # Создаем ответ response = JSONResponse( { diff --git a/auth/core.py b/auth/core.py index 1090c3f4..8ede19a4 100644 --- a/auth/core.py +++ b/auth/core.py @@ -7,12 +7,12 @@ import time from sqlalchemy.orm.exc import NoResultFound -from auth.orm import Author from auth.state import AuthState from auth.tokens.storage import TokenStorage as TokenManager +from orm.author import Author from orm.community import CommunityAuthor -from storage.db import local_session from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST +from storage.db import local_session from utils.logger import root_logger as logger ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",") diff --git a/auth/decorators.py b/auth/decorators.py index 30a41585..d123e232 100644 --- a/auth/decorators.py +++ b/auth/decorators.py @@ -9,11 +9,11 @@ from sqlalchemy import exc from auth.core import authenticate from auth.credentials import AuthCredentials from auth.exceptions import OperationNotAllowedError -from auth.orm import Author from auth.utils import get_auth_token, get_safe_headers +from orm.author import Author from orm.community import CommunityAuthor -from storage.db import local_session from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST +from storage.db import local_session from utils.logger import root_logger as logger ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",") diff --git a/auth/identity.py b/auth/identity.py index 46efcebf..60eaa9e8 100644 --- a/auth/identity.py +++ b/auth/identity.py @@ -2,11 +2,11 @@ from typing import Any, TypeVar from auth.exceptions import ExpiredTokenError, InvalidPasswordError, InvalidTokenError from auth.jwtcodec import JWTCodec -from auth.orm import Author -from auth.password import Password +from orm.author import Author from storage.db import local_session from storage.redis import redis from utils.logger import root_logger as logger +from utils.password import Password AuthorType = TypeVar("AuthorType", bound=Author) diff --git a/auth/middleware.py b/auth/middleware.py index 9d65f205..2cbacf04 100644 --- a/auth/middleware.py +++ b/auth/middleware.py @@ -15,10 +15,8 @@ from starlette.responses import JSONResponse, Response from starlette.types import ASGIApp from auth.credentials import AuthCredentials -from auth.orm import Author from auth.tokens.storage import TokenStorage as TokenManager -from storage.db import local_session -from storage.redis import redis as redis_adapter +from orm.author import Author from settings import ( ADMIN_EMAILS as ADMIN_EMAILS_LIST, ) @@ -30,6 +28,8 @@ from settings import ( SESSION_COOKIE_SECURE, SESSION_TOKEN_HEADER, ) +from storage.db import local_session +from storage.redis import redis as redis_adapter from utils.logger import root_logger as logger ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",") @@ -498,6 +498,31 @@ class AuthMiddleware: f"[graphql_handler] Установлена cookie {SESSION_COOKIE_NAME} для операции {op_name}" ) + # Если это операция getSession и в ответе есть токен, устанавливаем cookie + elif op_name == "getsession": + token = None + # Пытаемся извлечь токен из данных ответа + if result_data and isinstance(result_data, dict): + data_obj = result_data.get("data", {}) + if isinstance(data_obj, dict) and "getSession" in data_obj: + op_result = data_obj.get("getSession", {}) + if isinstance(op_result, dict) and "token" in op_result and op_result.get("success"): + token = op_result.get("token") + + if token: + # Устанавливаем cookie с токеном для поддержания сессии + 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, + ) + logger.debug( + f"[graphql_handler] Установлена cookie {SESSION_COOKIE_NAME} для операции {op_name}" + ) + # Если это операция logout, удаляем cookie elif op_name == "logout": response.delete_cookie( diff --git a/auth/oauth.py b/auth/oauth.py index 37feda17..429a7dc3 100644 --- a/auth/oauth.py +++ b/auth/oauth.py @@ -10,11 +10,9 @@ from sqlalchemy.orm import Session from starlette.requests import Request from starlette.responses import JSONResponse, RedirectResponse -from auth.orm import Author from auth.tokens.storage import TokenStorage +from orm.author import Author from orm.community import Community, CommunityAuthor, CommunityFollower -from storage.db import local_session -from storage.redis import redis from settings import ( FRONTEND_URL, OAUTH_CLIENTS, @@ -24,6 +22,8 @@ from settings import ( SESSION_COOKIE_SAMESITE, SESSION_COOKIE_SECURE, ) +from storage.db import local_session +from storage.redis import redis from utils.generate_slug import generate_unique_slug from utils.logger import root_logger as logger diff --git a/auth/utils.py b/auth/utils.py index 9ca361b0..5beb54de 100644 --- a/auth/utils.py +++ b/auth/utils.py @@ -3,7 +3,7 @@ Содержит функции для работы с токенами, заголовками и запросами """ -from typing import Any +from typing import Any, Tuple from settings import SESSION_COOKIE_NAME, SESSION_TOKEN_HEADER from utils.logger import root_logger as logger @@ -56,6 +56,122 @@ def get_safe_headers(request: Any) -> dict[str, str]: return headers +async def extract_token_from_request(request) -> str | None: + """ + DRY функция для извлечения токена из request. + Проверяет cookies и заголовок Authorization. + + Args: + request: Request объект + + Returns: + Optional[str]: Токен или None + """ + if not request: + return None + + # 1. Проверяем cookies + if hasattr(request, "cookies") and request.cookies: + token = request.cookies.get(SESSION_COOKIE_NAME) + if token: + logger.debug(f"[utils] Токен получен из cookie {SESSION_COOKIE_NAME}") + return token + + # 2. Проверяем заголовок Authorization + headers = get_safe_headers(request) + auth_header = headers.get("authorization", "") + if auth_header and auth_header.startswith("Bearer "): + token = auth_header[7:].strip() + logger.debug("[utils] Токен получен из заголовка Authorization") + return token + + logger.debug("[utils] Токен не найден ни в cookies, ни в заголовке") + return None + + +async def get_user_data_by_token(token: str) -> Tuple[bool, dict | None, str | None]: + """ + Получает данные пользователя по токену. + + Args: + token: Токен авторизации + + Returns: + Tuple[bool, Optional[dict], Optional[str]]: (success, user_data, error_message) + """ + try: + from auth.tokens.storage import TokenStorage as TokenManager + from orm.author import Author + from storage.db import local_session + + # Проверяем сессию через TokenManager + payload = await TokenManager.verify_session(token) + + if not payload: + return False, None, "Сессия не найдена" + + # Получаем user_id из payload + user_id = payload.user_id if hasattr(payload, "user_id") else payload.get("user_id") + + if not user_id: + return False, None, "Токен не содержит user_id" + + # Получаем данные пользователя + with local_session() as session: + author_obj = session.query(Author).where(Author.id == int(user_id)).first() + if not author_obj: + return False, None, f"Пользователь с ID {user_id} не найден в БД" + + try: + user_data = author_obj.dict() + except Exception: + user_data = { + "id": author_obj.id, + "email": author_obj.email, + "name": getattr(author_obj, "name", ""), + "slug": getattr(author_obj, "slug", ""), + "username": getattr(author_obj, "username", ""), + } + + logger.debug(f"[utils] Данные пользователя получены для ID {user_id}") + return True, user_data, None + + except Exception as e: + logger.error(f"[utils] Ошибка при получении данных пользователя: {e}") + return False, None, f"Ошибка получения данных: {e!s}" + + +async def get_auth_token_from_context(info: Any) -> str | None: + """ + Извлекает токен авторизации из GraphQL контекста. + Порядок проверки: + 1. Проверяет заголовок Authorization + 2. Проверяет cookie session_token + 3. Переиспользует логику get_auth_token для request + + Args: + info: GraphQLResolveInfo объект + + Returns: + Optional[str]: Токен авторизации или None + """ + try: + context = getattr(info, "context", {}) + request = context.get("request") + + if request: + # Переиспользуем существующую логику для request + return await get_auth_token(request) + + # Если request отсутствует, возвращаем None + logger.debug("[utils] Request отсутствует в GraphQL контексте") + return None + + except Exception as e: + logger.error(f"[utils] Ошибка при извлечении токена из GraphQL контекста: {e}") + return None + + async def get_auth_token(request: Any) -> str | None: """ Извлекает токен авторизации из запроса. diff --git a/cache/cache.py b/cache/cache.py index 51228acd..984ac2d8 100644 --- a/cache/cache.py +++ b/cache/cache.py @@ -34,7 +34,7 @@ from typing import Any, Callable, Dict, List, Type import orjson from sqlalchemy import and_, join, select -from auth.orm import Author, AuthorFollower +from orm.author import Author, AuthorFollower from orm.shout import Shout, ShoutAuthor, ShoutTopic from orm.topic import Topic, TopicFollower from storage.db import local_session @@ -278,7 +278,7 @@ async def get_cached_author_followers(author_id: int): f[0] for f in session.query(Author.id) .join(AuthorFollower, AuthorFollower.follower == Author.id) - .where(AuthorFollower.author == author_id, Author.id != author_id) + .where(AuthorFollower.following == author_id, Author.id != author_id) .all() ] await redis.execute("SET", f"author:followers:{author_id}", fast_json_dumps(followers_ids)) @@ -298,7 +298,7 @@ async def get_cached_follower_authors(author_id: int): a[0] for a in session.execute( select(Author.id) - .select_from(join(Author, AuthorFollower, Author.id == AuthorFollower.author)) + .select_from(join(Author, AuthorFollower, Author.id == AuthorFollower.following)) .where(AuthorFollower.follower == author_id) ).all() ] diff --git a/cache/precache.py b/cache/precache.py index a4c9e853..0b62072a 100644 --- a/cache/precache.py +++ b/cache/precache.py @@ -3,10 +3,9 @@ import traceback from sqlalchemy import and_, join, select -from auth.orm import Author, AuthorFollower - # Импорт Author, AuthorFollower отложен для избежания циклических импортов from cache.cache import cache_author, cache_topic +from orm.author import Author, AuthorFollower from orm.shout import Shout, ShoutAuthor, ShoutReactionsFollower, ShoutTopic from orm.topic import Topic, TopicFollower from resolvers.stat import get_with_stat @@ -19,7 +18,7 @@ from utils.logger import root_logger as logger # Предварительное кеширование подписчиков автора async def precache_authors_followers(author_id, session) -> None: authors_followers: set[int] = set() - followers_query = select(AuthorFollower.follower).where(AuthorFollower.author == author_id) + followers_query = select(AuthorFollower.follower).where(AuthorFollower.following == author_id) result = session.execute(followers_query) authors_followers.update(row[0] for row in result if row[0]) @@ -30,7 +29,7 @@ async def precache_authors_followers(author_id, session) -> None: # Предварительное кеширование подписок автора async def precache_authors_follows(author_id, session) -> None: follows_topics_query = select(TopicFollower.topic).where(TopicFollower.follower == author_id) - follows_authors_query = select(AuthorFollower.author).where(AuthorFollower.follower == author_id) + follows_authors_query = select(AuthorFollower.following).where(AuthorFollower.follower == author_id) follows_shouts_query = select(ShoutReactionsFollower.shout).where(ShoutReactionsFollower.follower == author_id) follows_topics = {row[0] for row in session.execute(follows_topics_query) if row[0]} diff --git a/cache/triggers.py b/cache/triggers.py index 1536dfb9..08d49836 100644 --- a/cache/triggers.py +++ b/cache/triggers.py @@ -1,9 +1,8 @@ from sqlalchemy import event -from auth.orm import Author, AuthorFollower - # Импорт Author, AuthorFollower отложен для избежания циклических импортов from cache.revalidator import revalidation_manager +from orm.author import Author, AuthorFollower from orm.reaction import Reaction, ReactionKind from orm.shout import Shout, ShoutAuthor, ShoutReactionsFollower from orm.topic import Topic, TopicFollower @@ -40,7 +39,7 @@ def after_follower_handler(mapper, connection, target, is_delete=False) -> None: if entity_type: revalidation_manager.mark_for_revalidation( - target.author if entity_type == "authors" else target.topic, entity_type + target.following if entity_type == "authors" else target.topic, entity_type ) if not is_delete: revalidation_manager.mark_for_revalidation(target.follower, "authors") diff --git a/ci-server.py b/ci_server.py similarity index 99% rename from ci-server.py rename to ci_server.py index 99f4663e..bfb8518f 100755 --- a/ci-server.py +++ b/ci_server.py @@ -23,6 +23,7 @@ from orm.base import Base from storage.db import engine from utils.logger import root_logger as logger + class CIServerManager: """Менеджер CI серверов""" diff --git a/docs/README.md b/docs/README.md index b7dc911a..c92ba55c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,4 +1,4 @@ -# Документация Discours Core v0.9.6 +# Документация Discours Core v0.9.8 ## 📚 Быстрый старт @@ -22,7 +22,7 @@ python -m granian main:app --interface asgi ### 📊 Статус проекта -- **Версия**: 0.9.6 +- **Версия**: 0.9.8 - **Тесты**: 344/344 проходят (включая E2E Playwright тесты) ✅ - **Покрытие**: 90% - **Python**: 3.12+ diff --git a/docs/auth.md b/docs/auth.md index 00a15ad4..0b409e15 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -2,13 +2,38 @@ ## Общее описание -Модуль реализует полноценную систему аутентификации с использованием локальной БД и Redis. +Модуль реализует полноценную систему аутентификации с использованием локальной БД, Redis и httpOnly cookies для безопасного хранения токенов сессий. -## Компоненты +## Архитектура системы + +### Основные компоненты + +#### 1. **AuthMiddleware** (`auth/middleware.py`) +- Единый middleware для обработки авторизации в GraphQL запросах +- Извлечение Bearer токена из заголовка Authorization или httpOnly cookie +- Проверка сессии через TokenStorage +- Создание `request.user` и `request.auth` +- Предоставление методов для установки/удаления cookies + +#### 2. **EnhancedGraphQLHTTPHandler** (`auth/handler.py`) +- Расширенный GraphQL HTTP обработчик с поддержкой cookie и авторизации +- Создание расширенного контекста запроса с авторизационными данными +- Корректная обработка ответов с cookie и headers +- Интеграция с AuthMiddleware + +#### 3. **TokenStorage** (`auth/tokens/storage.py`) +- Централизованное управление токенами сессий +- Хранение в Redis с TTL +- Верификация и валидация токенов +- Управление жизненным циклом сессий + +#### 4. **AuthCredentials** (`auth/credentials.py`) +- Модель данных для хранения информации об авторизации +- Содержит `author_id`, `scopes`, `logged_in`, `error_message`, `email`, `token` ### Модели данных -#### Author (orm.py) +#### Author (`orm/author.py`) - Основная модель пользователя с расширенным функционалом аутентификации - Поддерживает: - Локальную аутентификацию по email/телефону @@ -16,782 +41,729 @@ - Блокировку аккаунта при множественных неудачных попытках входа - Верификацию email/телефона -#### Role и Permission (resolvers/rbac.py) -- Реализация RBAC (Role-Based Access Control) -- Роли содержат наборы разрешений -- Разрешения определяются как пары resource:operation +## Система httpOnly Cookies -### Аутентификация +### Принципы работы -#### Внутренняя аутентификация -- Проверка токена в Redis -- Получение данных пользователя из локальной БД -- Проверка статуса аккаунта и разрешений +1. **Безопасное хранение**: Токены сессий хранятся в httpOnly cookies, недоступных для JavaScript +2. **Автоматическая отправка**: Cookies автоматически отправляются с каждым запросом +3. **Защита от XSS**: httpOnly cookies защищены от кражи через JavaScript +4. **Двойная поддержка**: Система поддерживает как cookies, так и заголовок Authorization -### Управление сессиями (sessions.py) +### Конфигурация cookies -- Хранение сессий в Redis -- Поддержка: - - Создание сессий - - Верификация - - Отзыв отдельных сессий - - Отзыв всех сессий пользователя -- Автоматическое удаление истекших сессий +```python +# settings.py +SESSION_COOKIE_NAME = "session_token" +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SECURE = True # для HTTPS +SESSION_COOKIE_SAMESITE = "lax" +SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 дней +``` -### JWT токены (jwtcodec.py) +### Установка cookies -- Кодирование/декодирование JWT токенов -- Проверка: - - Срока действия - - Подписи - - Издателя -- Поддержка пользовательских claims +```python +# В AuthMiddleware +def set_session_cookie(self, response: Response, token: str) -> None: + """Устанавливает httpOnly cookie с токеном сессии""" + 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 + ) +``` -### OAuth интеграция (oauth.py) +## Аутентификация -Поддерживаемые провайдеры: -- Google -- Facebook -- GitHub +### Извлечение токенов -Функционал: -- Авторизация через OAuth провайдеров -- Получение профиля пользователя -- Создание/обновление локального профиля +Система проверяет токены в следующем порядке приоритета: -### Валидация (validations.py) +1. **httpOnly cookies** - основной источник для веб-приложений +2. **Заголовок Authorization** - для API клиентов и мобильных приложений -Модели валидации для: -- Регистрации пользователей -- Входа в систему -- OAuth данных -- JWT payload -- Ответов API +```python +# auth/utils.py +async def extract_token_from_request(request) -> str | None: + """DRY функция для извлечения токена из request""" + + # 1. Проверяем cookies + if hasattr(request, "cookies") and request.cookies: + token = request.cookies.get(SESSION_COOKIE_NAME) + if token: + return token -### Email функционал (email.py) + # 2. Проверяем заголовок Authorization + headers = get_safe_headers(request) + auth_header = headers.get("authorization", "") + if auth_header and auth_header.startswith("Bearer "): + token = auth_header[7:].strip() + return token -- Отправка писем через Mailgun -- Поддержка шаблонов -- Мультиязычность (ru/en) -- Подтверждение email -- Сброс пароля + return None +``` -## API Endpoints (resolvers.py) +### Безопасное получение заголовков -### Мутации -- `login` - вход в систему -- `getSession` - получение текущей сессии -- `confirmEmail` - подтверждение email -- `registerUser` - регистрация пользователя -- `sendLink` - отправка ссылки для входа +```python +# auth/utils.py +def get_safe_headers(request: Any) -> dict[str, str]: + """Безопасно получает заголовки запроса""" + headers = {} + try: + # Первый приоритет: scope из ASGI + if hasattr(request, "scope") and isinstance(request.scope, dict): + scope_headers = request.scope.get("headers", []) + if scope_headers: + headers.update({k.decode("utf-8").lower(): v.decode("utf-8") + for k, v in scope_headers}) -### Запросы -- `logout` - выход из системы -- `isEmailUsed` - проверка использования email + # Второй приоритет: метод headers() или атрибут headers + if hasattr(request, "headers"): + if callable(request.headers): + h = request.headers() + if h: + headers.update({k.lower(): v for k, v in h.items()}) + else: + h = request.headers + if hasattr(h, "items") and callable(h.items): + headers.update({k.lower(): v for k, v in h.items()}) + + except Exception as e: + logger.warning(f"Ошибка при доступе к заголовкам: {e}") + + return headers +``` + +## Управление сессиями + +### Создание сессии + +```python +# auth/tokens/sessions.py +async def create_session(author_id: int, email: str, **kwargs) -> str: + """Создает новую сессию для пользователя""" + session_data = { + "author_id": author_id, + "email": email, + "created_at": int(time.time()), + **kwargs + } + + # Генерируем уникальный токен + token = generate_session_token() + + # Сохраняем в Redis + await redis.execute( + "SETEX", + f"session:{token}", + SESSION_TOKEN_LIFE_SPAN, + json.dumps(session_data) + ) + + return token +``` + +### Верификация сессии + +```python +# auth/tokens/storage.py +async def verify_session(token: str) -> dict | None: + """Верифицирует токен сессии""" + if not token: + return None + + try: + # Получаем данные сессии из Redis + session_data = await redis.execute("GET", f"session:{token}") + if not session_data: + return None + + return json.loads(session_data) + + except Exception as e: + logger.error(f"Ошибка верификации сессии: {e}") + return None +``` + +### Удаление сессии + +```python +# auth/tokens/storage.py +async def delete_session(token: str) -> bool: + """Удаляет сессию пользователя""" + try: + result = await redis.execute("DEL", f"session:{token}") + return bool(result) + except Exception as e: + logger.error(f"Ошибка удаления сессии: {e}") + return False +``` + +## OAuth интеграция + +### Поддерживаемые провайдеры + +- **Google** - OAuth 2.0 с PKCE +- **Facebook** - OAuth 2.0 +- **GitHub** - OAuth 2.0 + +### Реализация + +```python +# auth/oauth.py +class OAuthProvider: + """Базовый класс для OAuth провайдеров""" + + def __init__(self, client_id: str, client_secret: str, redirect_uri: str): + self.client_id = client_id + self.client_secret = client_secret + self.redirect_uri = redirect_uri + + async def get_authorization_url(self, state: str = None) -> str: + """Генерирует URL для авторизации""" + pass + + async def exchange_code_for_token(self, code: str) -> dict: + """Обменивает код авторизации на токен доступа""" + pass + + async def get_user_info(self, access_token: str) -> dict: + """Получает информацию о пользователе""" + pass +``` + +## Валидация + +### Модели валидации + +```python +# auth/validations.py +from pydantic import BaseModel, EmailStr + +class LoginRequest(BaseModel): + email: EmailStr + password: str + +class RegisterRequest(BaseModel): + email: EmailStr + password: str + name: str + phone: str | None = None + +class PasswordResetRequest(BaseModel): + email: EmailStr + +class EmailConfirmationRequest(BaseModel): + token: str +``` + +## API Endpoints + +### GraphQL мутации + +```graphql +# Мутации аутентификации +mutation Login($email: String!, $password: String!) { + login(email: $email, password: $password) { + success + token + user { + id + email + name + } + error + } +} + +mutation Register($input: RegisterInput!) { + registerUser(input: $input) { + success + user { + id + email + name + } + error + } +} + +mutation Logout { + logout { + success + message + } +} + +# Получение текущей сессии +query GetSession { + getSession { + success + token + user { + id + email + name + roles + } + error + } +} +``` + +### REST API endpoints + +```python +# Основные endpoints +POST /auth/login # Вход в систему +POST /auth/register # Регистрация +POST /auth/logout # Выход из системы +GET /auth/session # Получение текущей сессии +POST /auth/refresh # Обновление токена + +# OAuth endpoints +GET /auth/oauth/{provider} # Инициация OAuth +GET /auth/oauth/{provider}/callback # OAuth callback +``` ## Безопасность -### Хеширование паролей (identity.py) -- Использование bcrypt с SHA-256 -- Настраиваемое количество раундов -- Защита от timing-атак +### Хеширование паролей + +```python +# auth/identity.py +from passlib.context import CryptContext + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +def hash_password(password: str) -> str: + """Хеширует пароль с использованием bcrypt""" + return pwd_context.hash(password) + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Проверяет пароль""" + return pwd_context.verify(plain_password, hashed_password) +``` ### Защита от брутфорса -- Блокировка аккаунта после 5 неудачных попыток -- Время блокировки: 30 минут -- Сброс счетчика после успешного входа - -## Обработка заголовков авторизации - -### Особенности работы с заголовками в Starlette - -При работе с заголовками в Starlette/FastAPI необходимо учитывать следующие особенности: - -1. **Регистр заголовков**: Заголовки в объекте `Request` чувствительны к регистру. Для надежного получения заголовка `Authorization` следует использовать регистронезависимый поиск. - -2. **Формат Bearer токена**: Токен может приходить как с префиксом `Bearer `, так и без него. Необходимо обрабатывать оба варианта. - -### Правильное получение заголовка авторизации ```python -# Получение заголовка с учетом регистра -headers_dict = dict(req.headers.items()) -token = None - -# Ищем заголовок независимо от регистра -for header_name, header_value in headers_dict.items(): - if header_name.lower() == SESSION_TOKEN_HEADER.lower(): - token = header_value - break - -# Обработка Bearer префикса -if token and token.startswith("Bearer "): - token = token.split("Bearer ")[1].strip() -``` - -### Распространенные проблемы и их решения - -1. **Проблема**: Заголовок не находится при прямом обращении `req.headers.get("Authorization")` - **Решение**: Использовать регистронезависимый поиск по всем заголовкам - -2. **Проблема**: Токен приходит с префиксом "Bearer" в одних запросах и без него в других - **Решение**: Всегда проверять и обрабатывать оба варианта - -3. **Проблема**: Токен декодируется, но сессия не находится в Redis - **Решение**: Проверить формирование ключа сессии и добавить автоматическое создание сессии для валидных токенов - -4. **Проблема**: Ошибки при декодировании JWT вызывают исключения - **Решение**: Обернуть декодирование в try-except и возвращать None вместо вызова исключений - -## Конфигурация - -Основные настройки в settings.py: -- `SESSION_TOKEN_LIFE_SPAN` - время жизни сессии -- `ONETIME_TOKEN_LIFE_SPAN` - время жизни одноразовых токенов -- `JWT_SECRET_KEY` - секретный ключ для JWT -- `JWT_ALGORITHM` - алгоритм подписи JWT - -## Примеры использования - -### Аутентификация - -```python -# Проверка авторизации -user_id, roles = await check_auth(request) - -# Добавление роли -await add_user_role(user_id, ["author"]) - -# Создание сессии -token = await create_local_session(author) -``` - -### OAuth авторизация - -```python -# Инициация OAuth процесса -await oauth_login(request) - -# Обработка callback -response = await oauth_authorize(request) -``` - -### 1. Базовая авторизация на фронтенде - -```typescript -// pages/Login.tsx -// Предполагается, что AuthClient и createAuth импортированы корректно -// import { AuthClient } from '../auth/AuthClient'; // Путь может отличаться -// import { createAuth } from '../auth/useAuth'; // Путь может отличаться -import { Component, Show } from 'solid-js'; // Show для условного рендеринга - -export const LoginPage: Component = () => { - // Клиент и хук авторизации (пример из client/auth/useAuth.ts) - // const authClient = new AuthClient(/* baseUrl or other config */); - // const auth = createAuth(authClient); - // Для простоты примера, предположим, что auth уже доступен через контекст или пропсы - // В реальном приложении используйте useAuthContext() если он настроен - const { store, login } = useAuthContext(); // Пример, если используется контекст - - const handleSubmit = async (event: SubmitEvent) => { - event.preventDefault(); - const form = event.currentTarget as HTMLFormElement; - const emailInput = form.elements.namedItem('email') as HTMLInputElement; - const passwordInput = form.elements.namedItem('password') as HTMLInputElement; - - if (!emailInput || !passwordInput) { - console.error("Email or password input not found"); - return; - } - - const success = await login({ - email: emailInput.value, - password: passwordInput.value - }); - - if (success) { - console.log('Login successful, redirecting...'); - // window.location.href = '/'; // Раскомментируйте для реального редиректа - } else { - // Ошибка уже должна быть в store().error, обработанная в useAuth - console.error('Login failed:', store().error); - } - }; - - return ( -
-
- - -
-
- - -
- - -

{store().error}

-
-
- ); -} -``` - -### 2. Защита компонента с помощью ролей - -```typescript -// components/AdminPanel.tsx -import { useAuthContext } from '../auth' - -export const AdminPanel: Component = () => { - const auth = useAuthContext() - - // Проверяем наличие роли админа - if (!auth.hasRole('admin')) { - return
Доступ запрещен
- } - - return ( -
- {/* Контент админки */} -
- ) -} -``` - -### 3. OAuth авторизация через Google - -```typescript -// components/GoogleLoginButton.tsx -import { Component } from 'solid-js'; - -export const GoogleLoginButton: Component = () => { - const handleGoogleLogin = () => { - // Предполагается, что API_BASE_URL настроен глобально или импортирован - // const API_BASE_URL = 'http://localhost:8000'; // Пример - // window.location.href = `${API_BASE_URL}/auth/login/google`; - // Или если пути относительные и сервер на том же домене: - window.location.href = '/auth/login/google'; - }; - - return ( - - ); -} -``` - -### 4. Работа с пользователем на бэкенде - -```python -# routes/articles.py -# Предполагаемые импорты: -# from starlette.requests import Request -# from starlette.responses import JSONResponse -# from sqlalchemy.orm import Session -# from ..dependencies import get_db_session # Пример получения сессии БД -# from ..auth.decorators import login_required # Ваш декоратор -# from ..auth.orm import Author # Модель пользователя -# from ..models.article import Article # Модель статьи (пример) - -# @login_required # Декоратор проверяет аутентификацию и добавляет user в request -async def create_article_example(request: Request): # Используем Request из Starlette - """ - Пример создания статьи с проверкой прав. - В реальном приложении используйте DI для сессии БД (например, FastAPI Depends). - """ - user: Author = request.user # request.user добавляется декоратором @login_required - - # Проверяем право на создание статей (метод из модели auth.auth.orm) - if not await user.has_permission('shout:create'): - return JSONResponse({'error': 'Недостаточно прав для создания статьи'}, status_code=403) - - try: - article_data = await request.json() - title = article_data.get('title') - content = article_data.get('content') - - if not title or not content: - return JSONResponse({'error': 'Title and content are required'}, status_code=400) - - except ValueError: # Если JSON некорректен - return JSONResponse({'error': 'Invalid JSON data'}, status_code=400) - - # Пример работы с БД. В реальном приложении сессия db будет получена через DI. - # Здесь db - это заглушка, замените на вашу реальную логику работы с БД. - # Пример: - # with get_db_session() as db: # Получение сессии SQLAlchemy - # new_article = Article( - # title=title, - # content=content, - # author_id=user.id # Связываем статью с автором - # ) - # db.add(new_article) - # db.commit() - # db.refresh(new_article) - # return JSONResponse({'id': new_article.id, 'title': new_article.title}, status_code=201) - - # Заглушка для примера в документации - mock_article_id = 123 - print(f"User {user.id} ({user.email}) is creating article '{title}'.") - return JSONResponse({'id': mock_article_id, 'title': title}, status_code=201) -``` - -### 5. Проверка прав в GraphQL резолверах - -```python -# resolvers/mutations.py -from auth.decorators import login_required -from auth.models import Author - -@login_required -async def update_article(_: None,info, article_id: int, data: dict): - """ - Обновление статьи с проверкой прав - """ - user: Author = info.context.user - - # Получаем статью - article = db.query(Article).get(article_id) - if not article: - raise GraphQLError('Статья не найдена') - - # Проверяем права на редактирование - if not await user.has_permission('articles', 'edit'): - raise GraphQLError('Недостаточно прав') - - # Обновляем поля - article.title = data.get('title', article.title) - article.content = data.get('content', article.content) - - db.commit() - return article -``` - -### 6. Создание пользователя с ролями - -```python -# scripts/create_admin.py -from auth.models import Author, Role -from auth.password import hash_password - -def create_admin(email: str, password: str): - """Создание администратора""" - - # Получаем роль админа - admin_role = db.query(Role).where(Role.id == 'admin').first() - - # Создаем пользователя - admin = Author( - email=email, - password=hash_password(password), - email_verified=True - ) - - # Назначаем роль - admin.roles.append(admin_role) - - # Сохраняем - db.add(admin) - db.commit() - - return admin -``` - -### 7. Работа с сессиями - -```python -# auth/session_management.py (примерное название файла) -# Предполагаемые импорты: -# from starlette.responses import RedirectResponse -# from starlette.requests import Request -# from ..auth.orm import Author # Модель пользователя -# from ..auth.token import TokenStorage # Ваш модуль для работы с токенами -# from ..settings import SESSION_COOKIE_MAX_AGE, SESSION_COOKIE_NAME, SESSION_COOKIE_SECURE, SESSION_COOKIE_HTTPONLY, SESSION_COOKIE_SAMESITE - -# Замените FRONTEND_URL_AUTH_SUCCESS и FRONTEND_URL_LOGOUT на реальные URL из настроек -FRONTEND_URL_AUTH_SUCCESS = "/auth/success" # Пример -FRONTEND_URL_LOGOUT = "/logout" # Пример - - -async def login_user_session(request: Request, user: Author, response_class=RedirectResponse): - """ - Создание сессии пользователя и установка cookie. - """ - if not hasattr(user, 'id'): # Проверка наличия id у пользователя - raise ValueError("User object must have an id attribute") - - # Создаем токен сессии (TokenStorage из вашего модуля auth.token) - session_token = TokenStorage.create_session(str(user.id)) # ID пользователя обычно число, приводим к строке если нужно - - # Устанавливаем cookie - # В реальном приложении FRONTEND_URL_AUTH_SUCCESS должен вести на страницу вашего фронтенда - response = response_class(url=FRONTEND_URL_AUTH_SUCCESS) - response.set_cookie( - key=SESSION_COOKIE_NAME, # 'session_token' из settings.py - value=session_token, - httponly=SESSION_COOKIE_HTTPONLY, # True из settings.py - secure=SESSION_COOKIE_SECURE, # True для HTTPS из settings.py - samesite=SESSION_COOKIE_SAMESITE, # 'lax' из settings.py - max_age=SESSION_COOKIE_MAX_AGE # 30 дней в секундах из settings.py - ) - print(f"Session created for user {user.id}. Token: {session_token[:10]}...") # Логируем для отладки - return response - -async def logout_user_session(request: Request, response_class=RedirectResponse): - """ - Завершение сессии пользователя и удаление cookie. - """ - session_token = request.cookies.get(SESSION_COOKIE_NAME) - - if session_token: - # Удаляем токен из хранилища (TokenStorage из вашего модуля auth.token) - TokenStorage.delete_session(session_token) - print(f"Session token {session_token[:10]}... deleted from storage.") - - # Удаляем cookie - # В реальном приложении FRONTEND_URL_LOGOUT должен вести на страницу вашего фронтенда - response = response_class(url=FRONTEND_URL_LOGOUT) - response.delete_cookie(SESSION_COOKIE_NAME) - print(f"Cookie {SESSION_COOKIE_NAME} deleted.") - return response -``` - -### 8. Проверка CSRF в формах - -```typescript -// components/ProfileForm.tsx -// import { useAuthContext } from '../auth'; // Предполагаем, что auth есть в контексте -import { Component, createSignal, Show } from 'solid-js'; - -export const ProfileForm: Component = () => { - const { store, checkAuth } = useAuthContext(); // Пример получения из контекста - const [message, setMessage] = createSignal(null); - const [error, setError] = createSignal(null); - - const handleSubmit = async (event: SubmitEvent) => { - event.preventDefault(); - setMessage(null); - setError(null); - const form = event.currentTarget as HTMLFormElement; - const formData = new FormData(form); - - // ВАЖНО: Получение CSRF-токена из cookie - это один из способов. - // Если CSRF-токен устанавливается как httpOnly cookie, то он будет автоматически - // отправляться браузером, и его не нужно доставать вручную для fetch, - // если сервер настроен на его проверку из заголовка (например, X-CSRF-Token), - // который fetch *не* устанавливает автоматически для httpOnly cookie. - // Либо сервер может предоставлять CSRF-токен через специальный эндпоинт. - // Представленный ниже способ подходит, если CSRF-токен доступен для JS. - const csrfToken = document.cookie - .split('; ') - .find(row => row.startsWith('csrf_token=')) // Имя cookie может отличаться - ?.split('=')[1]; - - if (!csrfToken) { - // setError('CSRF token not found. Please refresh the page.'); - // В продакшене CSRF-токен должен быть всегда. Этот лог для отладки. - console.warn('CSRF token not found in cookies. Ensure it is set by the server.'); - // Для данного примера, если токен не найден, можно либо прервать, либо положиться на серверную проверку. - // Для большей безопасности, прерываем, если CSRF-защита критична на клиенте. - } - - try { - // Замените '/api/profile' на ваш реальный эндпоинт - const response = await fetch('/api/profile', { - method: 'POST', - headers: { - // Сервер должен быть настроен на чтение этого заголовка - // если CSRF токен не отправляется автоматически с httpOnly cookie. - ...(csrfToken && { 'X-CSRF-Token': csrfToken }), - // 'Content-Type': 'application/json' // Если отправляете JSON - }, - body: formData // FormData отправится как 'multipart/form-data' - // Если нужно JSON: body: JSON.stringify(Object.fromEntries(formData)) - }); - - if (response.ok) { - const result = await response.json(); - setMessage(result.message || 'Профиль успешно обновлен!'); - checkAuth(); // Обновить данные пользователя в сторе - } else { - const errData = await response.json(); - setError(errData.error || `Ошибка: ${response.status}`); - } - } catch (err) { - console.error('Profile update error:', err); - setError('Не удалось обновить профиль. Попробуйте позже.'); - } - }; - - return ( -
-
- - -
- {/* Другие поля профиля */} - - -

{message()}

-
- -

{error()}

-
-
- ); -} -``` - -### 9. Кастомные валидаторы для форм - -```typescript -// validators/auth.ts -export const validatePassword = (password: string): string[] => { - const errors: string[] = [] - - if (password.length < 8) { - errors.push('Пароль должен быть не менее 8 символов') - } - - if (!/[A-Z]/.test(password)) { - errors.push('Пароль должен содержать заглавную букву') - } - - if (!/[0-9]/.test(password)) { - errors.push('Пароль должен содержать цифру') - } - - return errors -} - -// components/RegisterForm.tsx -import { validatePassword } from '../validators/auth' - -export const RegisterForm: Component = () => { - const [errors, setErrors] = createSignal([]) - - const handleSubmit = async (e: Event) => { - e.preventDefault() - const form = e.target as HTMLFormElement - const data = new FormData(form) - - // Валидация пароля - const password = data.get('password') as string - const passwordErrors = validatePassword(password) - - if (passwordErrors.length > 0) { - setErrors(passwordErrors) - return - } - - // Отправка формы... - } - - return ( -
- - {errors().map(error => ( -
{error}
- ))} - -
- ) -} -``` - -### 10. Интеграция с внешними сервисами - -```python -# services/notifications.py -from auth.models import Author - -async def notify_login(user: Author, ip: str, device: str): - """Отправка уведомления о новом входе""" - - # Формируем текст - text = f""" - Новый вход в аккаунт: - IP: {ip} - Устройство: {device} - Время: {datetime.now()} - """ - - # Отправляем email - await send_email( - to=user.email, - subject='Новый вход в аккаунт', - text=text - ) - - # Логируем - logger.info(f'New login for user {user.id} from {ip}') -``` - -## Тестирование - -### 1. Тест OAuth авторизации - -```python -# tests/test_oauth.py -@pytest.mark.asyncio -async def test_google_oauth_success(client, mock_google): - # Мокаем ответ от Google - mock_google.return_value = { - 'id': '123', - 'email': 'test@gmail.com', - 'name': 'Test User' - } - - # Запрос на авторизацию - response = await client.get('/auth/login/google') - assert response.status_code == 302 - - # Проверяем редирект - assert 'accounts.google.com' in response.headers['location'] - - # Проверяем сессию - assert 'state' in client.session - assert 'code_verifier' in client.session -``` - -### 2. Тест ролей и разрешений - -```python -# tests/test_permissions.py -def test_user_permissions(): - # Создаем тестовые данные - role = Role(id='editor', name='Editor') - permission = Permission( - id='articles:edit', - resource='articles', - operation='edit' - ) - role.permissions.append(permission) - - user = Author(email='test@test.com') - user.roles.append(role) - - # Проверяем разрешения - assert await user.has_permission('articles', 'edit') - assert not await user.has_permission('articles', 'delete') -``` - -## Безопасность - -### 1. Rate Limiting - -```python -# middleware/rate_limit.py -from starlette.middleware import Middleware -from starlette.middleware.base import BaseHTTPMiddleware -from redis import Redis - -class RateLimitMiddleware(BaseHTTPMiddleware): - async def dispatch(self, request, call_next): - # Получаем IP - ip = request.client.host - - # Проверяем лимиты в Redis - redis = Redis() - key = f'rate_limit:{ip}' - - # Увеличиваем счетчик - count = redis.incr(key) - if count == 1: - redis.expire(key, 60) # TTL 60 секунд - - # Проверяем лимит - if count > 100: # 100 запросов в минуту - return JSONResponse( - {'error': 'Too many requests'}, - status_code=429 - ) - - return await call_next(request) -``` - -### 2. Защита от брутфорса - -```python -# auth/login.py -async def handle_login_attempt(user: Author, success: bool): - """Обработка попытки входа""" - +# auth/core.py +async def handle_login_attempt(author: Author, success: bool) -> None: + """Обрабатывает попытку входа""" if not success: # Увеличиваем счетчик неудачных попыток - user.increment_failed_login() - - if user.is_locked(): - # Аккаунт заблокирован - raise AuthError( - 'Account is locked. Try again later.', - 'ACCOUNT_LOCKED' - ) + author.failed_login_attempts += 1 + + if author.failed_login_attempts >= 5: + # Блокируем аккаунт на 30 минут + author.account_locked_until = int(time.time()) + 1800 + logger.warning(f"Аккаунт {author.email} заблокирован") else: # Сбрасываем счетчик при успешном входе - user.reset_failed_login() + author.failed_login_attempts = 0 + author.account_locked_until = None ``` -## Мониторинг - -### 1. Логирование событий авторизации +### CSRF защита ```python -# auth/logging.py -import structlog +# auth/middleware.py +def generate_csrf_token() -> str: + """Генерирует CSRF токен""" + return secrets.token_urlsafe(32) -logger = structlog.get_logger() +def verify_csrf_token(token: str, stored_token: str) -> bool: + """Проверяет CSRF токен""" + return secrets.compare_digest(token, stored_token) +``` -def log_auth_event( - event_type: str, - user_id: int = None, - success: bool = True, - **kwargs -): - """ - Логирование событий авторизации +## Декораторы - Args: - event_type: Тип события (login, logout, etc) - user_id: ID пользователя - success: Успешность операции - **kwargs: Дополнительные поля - """ +### Основные декораторы + +```python +# auth/decorators.py +from functools import wraps +from graphql import GraphQLError + +def login_required(func): + """Декоратор для проверки авторизации""" + @wraps(func) + async def wrapper(*args, **kwargs): + info = args[-1] if args else None + if not info or not hasattr(info, 'context'): + raise GraphQLError("Context not available") + + user = info.context.get('user') + if not user or not user.is_authenticated: + raise GraphQLError("Authentication required") + + return await func(*args, **kwargs) + return wrapper + +def require_permission(permission: str): + """Декоратор для проверки разрешений""" + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + info = args[-1] if args else None + if not info or not hasattr(info, 'context'): + raise GraphQLError("Context not available") + + user = info.context.get('user') + if not user or not user.is_authenticated: + raise GraphQLError("Authentication required") + + # Проверяем разрешение через RBAC + has_perm = await check_user_permission( + user.id, permission, info.context.get('community_id', 1) + ) + + if not has_perm: + raise GraphQLError("Insufficient permissions") + + return await func(*args, **kwargs) + return wrapper + return decorator +``` + +## Интеграция с RBAC + +### Проверка разрешений + +```python +# auth/decorators.py +async def check_user_permission(author_id: int, permission: str, community_id: int) -> bool: + """Проверяет разрешение пользователя через RBAC систему""" + try: + from rbac.api import user_has_permission + return await user_has_permission(author_id, permission, community_id) + except Exception as e: + logger.error(f"Ошибка проверки разрешений: {e}") + return False +``` + +### Получение ролей пользователя + +```python +# auth/middleware.py +async def get_user_roles(author_id: int, community_id: int = 1) -> list[str]: + """Получает роли пользователя в сообществе""" + try: + from rbac.api import get_user_roles_in_community + return get_user_roles_in_community(author_id, community_id) + except Exception as e: + logger.error(f"Ошибка получения ролей: {e}") + return [] +``` + +## Мониторинг и логирование + +### Логирование событий + +```python +# auth/middleware.py +def log_auth_event(event_type: str, user_id: int | None = None, + success: bool = True, **kwargs): + """Логирует события авторизации""" logger.info( - 'auth_event', + "auth_event", event_type=event_type, user_id=user_id, success=success, + ip_address=kwargs.get('ip'), + user_agent=kwargs.get('user_agent'), **kwargs ) ``` -### 2. Метрики для Prometheus +### Метрики ```python -# metrics/auth.py +# auth/middleware.py from prometheus_client import Counter, Histogram # Счетчики -login_attempts = Counter( - 'auth_login_attempts_total', - 'Number of login attempts', - ['success'] -) - -oauth_logins = Counter( - 'auth_oauth_logins_total', - 'Number of OAuth logins', - ['provider'] -) +login_attempts = Counter('auth_login_attempts_total', 'Number of login attempts', ['success']) +session_creations = Counter('auth_sessions_created_total', 'Number of sessions created') +session_deletions = Counter('auth_sessions_deleted_total', 'Number of sessions deleted') # Гистограммы -login_duration = Histogram( - 'auth_login_duration_seconds', - 'Time spent processing login' -) +auth_duration = Histogram('auth_operation_duration_seconds', 'Time spent on auth operations', ['operation']) ``` + +## Конфигурация + +### Основные настройки + +```python +# settings.py + +# Настройки сессий +SESSION_TOKEN_LIFE_SPAN = 30 * 24 * 60 * 60 # 30 дней +SESSION_COOKIE_NAME = "session_token" +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SECURE = True # для HTTPS +SESSION_COOKIE_SAMESITE = "lax" +SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 + +# JWT настройки +JWT_SECRET_KEY = "your-secret-key" +JWT_ALGORITHM = "HS256" +JWT_EXPIRATION_DELTA = 30 * 24 * 60 * 60 + +# OAuth настройки +GOOGLE_CLIENT_ID = "your-google-client-id" +GOOGLE_CLIENT_SECRET = "your-google-client-secret" +FACEBOOK_CLIENT_ID = "your-facebook-client-id" +FACEBOOK_CLIENT_SECRET = "your-facebook-client-secret" + +# Безопасность +MAX_LOGIN_ATTEMPTS = 5 +ACCOUNT_LOCKOUT_DURATION = 1800 # 30 минут +PASSWORD_MIN_LENGTH = 8 +``` + +## Примеры использования + +### 1. Вход в систему + +```typescript +// Frontend - React/SolidJS +const handleLogin = async (email: string, password: string) => { + try { + const response = await fetch('/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, password }), + credentials: 'include', // Важно для cookies + }); + + if (response.ok) { + const data = await response.json(); + // Cookie автоматически установится браузером + // Перенаправляем на главную страницу + window.location.href = '/'; + } else { + const error = await response.json(); + console.error('Login failed:', error.message); + } + } catch (error) { + console.error('Login error:', error); + } +}; +``` + +### 2. Проверка авторизации + +```typescript +// Frontend - проверка текущей сессии +const checkAuth = async () => { + try { + const response = await fetch('/auth/session', { + credentials: 'include', + }); + + if (response.ok) { + const data = await response.json(); + if (data.user) { + // Пользователь авторизован + setUser(data.user); + setIsAuthenticated(true); + } + } + } catch (error) { + console.error('Auth check failed:', error); + } +}; +``` + +### 3. Защищенный API endpoint + +```python +# Backend - Python +from auth.decorators import login_required, require_permission + +@login_required +@require_permission("shout:create") +async def create_shout(info, input_data): + """Создание публикации с проверкой прав""" + user = info.context.get('user') + + # Создаем публикацию + shout = Shout( + title=input_data['title'], + content=input_data['content'], + author_id=user.id + ) + + db.add(shout) + db.commit() + + return shout +``` + +### 4. OAuth авторизация + +```typescript +// Frontend - OAuth кнопка +const handleGoogleLogin = () => { + // Перенаправляем на OAuth endpoint + window.location.href = '/auth/oauth/google'; +}; + +// Обработка OAuth callback +useEffect(() => { + const urlParams = new URLSearchParams(window.location.search); + const code = urlParams.get('code'); + const state = urlParams.get('state'); + + if (code && state) { + // Обмениваем код на токен + exchangeOAuthCode(code, state); + } +}, []); +``` + +### 5. Выход из системы + +```typescript +// Frontend - выход +const handleLogout = async () => { + try { + await fetch('/auth/logout', { + method: 'POST', + credentials: 'include', + }); + + // Очищаем локальное состояние + setUser(null); + setIsAuthenticated(false); + + // Перенаправляем на страницу входа + window.location.href = '/login'; + } catch (error) { + console.error('Logout failed:', error); + } +}; +``` + +## Тестирование + +### Тесты аутентификации + +```python +# tests/test_auth.py +import pytest +from httpx import AsyncClient + +@pytest.mark.asyncio +async def test_login_success(client: AsyncClient): + """Тест успешного входа""" + response = await client.post("/auth/login", json={ + "email": "test@example.com", + "password": "password123" + }) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "token" in data + + # Проверяем установку cookie + cookies = response.cookies + assert "session_token" in cookies + +@pytest.mark.asyncio +async def test_protected_endpoint_with_cookie(client: AsyncClient): + """Тест защищенного endpoint с cookie""" + # Сначала входим в систему + login_response = await client.post("/auth/login", json={ + "email": "test@example.com", + "password": "password123" + }) + + # Получаем cookie + session_cookie = login_response.cookies.get("session_token") + + # Делаем запрос к защищенному endpoint + response = await client.get("/auth/session", cookies={ + "session_token": session_cookie + }) + + assert response.status_code == 200 + data = response.json() + assert data["user"]["email"] == "test@example.com" +``` + +### Тесты OAuth + +```python +# tests/test_oauth.py +@pytest.mark.asyncio +async def test_google_oauth_flow(client: AsyncClient, mock_google): + """Тест OAuth flow для Google""" + # Мокаем ответ от Google + mock_google.return_value = { + "id": "12345", + "email": "test@gmail.com", + "name": "Test User" + } + + # Инициация OAuth + response = await client.get("/auth/oauth/google") + assert response.status_code == 302 + + # Проверяем редирект + assert "accounts.google.com" in response.headers["location"] +``` + +## Безопасность + +### Лучшие практики + +1. **httpOnly Cookies**: Токены сессий хранятся только в httpOnly cookies +2. **HTTPS**: Все endpoints должны работать через HTTPS в продакшене +3. **SameSite**: Используется `SameSite=lax` для защиты от CSRF +4. **Rate Limiting**: Ограничение количества попыток входа +5. **Логирование**: Детальное логирование всех событий авторизации +6. **Валидация**: Строгая валидация всех входных данных + +### Защита от атак + +- **XSS**: httpOnly cookies недоступны для JavaScript +- **CSRF**: SameSite cookies и CSRF токены +- **Session Hijacking**: Secure cookies и регулярная ротация токенов +- **Brute Force**: Ограничение попыток входа и блокировка аккаунтов +- **SQL Injection**: Использование ORM и параметризованных запросов + +## Миграция + +### Обновление существующего кода + +Если в вашем коде используются старые методы аутентификации: + +```python +# Старый код +token = request.headers.get("Authorization") + +# Новый код +from auth.utils import extract_token_from_request +token = await extract_token_from_request(request) +``` + +### Совместимость + +Новая система полностью совместима с существующим кодом: +- Поддерживаются как cookies, так и заголовки Authorization +- Все существующие декораторы работают без изменений +- API endpoints сохранили свои сигнатуры +- RBAC интеграция работает как прежде diff --git a/docs/features.md b/docs/features.md index 30876db4..f63faa59 100644 --- a/docs/features.md +++ b/docs/features.md @@ -99,6 +99,22 @@ - `VerificationTokenManager`: Токены для подтверждения email, телефона, смены пароля - `OAuthTokenManager`: Управление OAuth токенами для внешних провайдеров +## Авторизация с cookies + +- **getSession без токена**: Мутация `getSession` теперь работает с httpOnly cookies даже без заголовка Authorization +- **Dual-авторизация**: Поддержка как токенов в заголовках, так и cookies для максимальной совместимости +- **Автоматические cookies**: Middleware автоматически устанавливает httpOnly cookies при успешной авторизации +- **Безопасность**: Использование httpOnly, secure и samesite cookies для защиты от XSS и CSRF атак +- **Сессии без перелогина**: Пользователи остаются авторизованными между сессиями браузера + +## DRY архитектура авторизации + +- **Централизованные функции**: Все функции для работы с токенами и авторизацией находятся в `auth/utils.py` +- **Устранение дублирования**: Единая логика проверки авторизации используется во всех модулях +- **Единообразная обработка**: Стандартизированный подход к извлечению токенов из cookies и заголовков +- **Улучшенная тестируемость**: Мокирование централизованных функций упрощает тестирование +- **Легкость поддержки**: Изменения в логике авторизации требуют правки только в одном месте + ## E2E тестирование с Playwright - **Автоматизация браузера**: Полноценное тестирование пользовательского интерфейса админ-панели diff --git a/docs/rbac-system.md b/docs/rbac-system.md index 3ab610de..2f88f408 100644 --- a/docs/rbac-system.md +++ b/docs/rbac-system.md @@ -2,16 +2,17 @@ ## Общее описание -Система управления доступом на основе ролей (Role-Based Access Control, RBAC) обеспечивает гибкое управление правами пользователей в рамках сообществ платформы. +Система управления доступом на основе ролей (Role-Based Access Control, RBAC) обеспечивает гибкое управление правами пользователей в рамках сообществ платформы. Система поддерживает иерархическое наследование разрешений и автоматическое кеширование для оптимальной производительности. ## Архитектура системы ### Принципы работы -1. **Иерархия ролей**: Роли наследуют права друг от друга +1. **Иерархия ролей**: Роли наследуют права друг от друга с рекурсивным вычислением 2. **Контекстная проверка**: Права проверяются в контексте конкретного сообщества 3. **Системные администраторы**: Пользователи из `ADMIN_EMAILS` автоматически получают роль `admin` в любом сообществе 4. **Динамическое определение community_id**: Система автоматически определяет `community_id` из аргументов GraphQL мутаций +5. **Рекурсивное наследование**: Разрешения автоматически включают все унаследованные права от родительских ролей ### Получение community_id @@ -27,7 +28,7 @@ 2. **CommunityAuthor** - связь пользователя с сообществом и его ролями 3. **Role** - роль пользователя (reader, author, editor, admin) 4. **Permission** - разрешение на выполнение действия -5. **RBAC Service** - сервис управления ролями и разрешениями +5. **RBAC Service** - сервис управления ролями и разрешениями с рекурсивным наследованием ### Модель данных @@ -103,7 +104,7 @@ CREATE INDEX idx_community_author_author ON community_author(author_id); admin > editor > expert > artist/author > reader ``` -Каждая роль автоматически включает права всех ролей ниже по иерархии. +Каждая роль автоматически включает права всех ролей ниже по иерархии. Система рекурсивно вычисляет все унаследованные разрешения при инициализации сообщества. ## Разрешения (Permissions) @@ -124,10 +125,6 @@ admin > editor > expert > artist/author > reader - `@require_all_permissions(["permission1", "permission2"])` - проверка наличия всех разрешений **Важно**: В resolvers не должна быть дублирующая логика проверки прав - вся проверка осуществляется через систему RBAC. -- `comment:create` - создание комментариев -- `comment:moderate` - модерация комментариев -- `user:manage` - управление пользователями -- `community:settings` - настройки сообщества ### Категории разрешений @@ -480,3 +477,78 @@ role_checks_total = Counter('rbac_role_checks_total') permission_checks_total = Counter('rbac_permission_checks_total') role_assignments_total = Counter('rbac_role_assignments_total') ``` + +## Новые возможности системы + +### Рекурсивное наследование разрешений + +Система теперь поддерживает автоматическое вычисление всех унаследованных разрешений: + +```python +# Получить разрешения для конкретной роли с учетом наследования +role_permissions = await rbac_ops.get_role_permissions_for_community( + community_id=1, + role="editor" +) +# Возвращает: {"editor": ["shout:edit_any", "comment:moderate", "draft:create", "shout:read", ...]} + +# Получить все разрешения для сообщества +all_permissions = await rbac_ops.get_all_permissions_for_community(community_id=1) +# Возвращает полный словарь всех ролей с их разрешениями +``` + +### Автоматическая инициализация + +При создании нового сообщества система автоматически инициализирует права с учетом иерархии: + +```python +# Автоматически создает расширенные разрешения для всех ролей +await rbac_ops.initialize_community_permissions(community_id=123) + +# Система рекурсивно вычисляет все наследованные разрешения +# и сохраняет их в Redis для быстрого доступа +``` + +### Улучшенная производительность + +- **Кеширование в Redis**: Все разрешения кешируются с ключом `community:roles:{community_id}` +- **Рекурсивное вычисление**: Разрешения вычисляются один раз при инициализации +- **Быстрая проверка**: Проверка разрешений происходит за O(1) из кеша + +### Обновленный API + +```python +class RBACOperations(Protocol): + # Получить разрешения для конкретной роли с наследованием + async def get_role_permissions_for_community(self, community_id: int, role: str) -> dict + + # Получить все разрешения для сообщества + async def get_all_permissions_for_community(self, community_id: int) -> dict + + # Проверить разрешения для набора ролей + async def roles_have_permission(self, role_slugs: list[str], permission: str, community_id: int) -> bool +``` + +## Миграция на новую систему + +### Обновление существующего кода + +Если в вашем коде используются старые методы, обновите их: + +```python +# Старый код +permissions = await rbac_ops._get_role_permissions_for_community(community_id) + +# Новый код +permissions = await rbac_ops.get_all_permissions_for_community(community_id) + +# Или для конкретной роли +role_permissions = await rbac_ops.get_role_permissions_for_community(community_id, "editor") +``` + +### Обратная совместимость + +Новая система полностью совместима с существующим кодом: +- Все публичные API методы сохранили свои сигнатуры +- Декораторы `@require_permission` работают без изменений +- Существующие тесты проходят без модификации diff --git a/main.py b/main.py index 5febbeb2..11652284 100644 --- a/main.py +++ b/main.py @@ -22,12 +22,12 @@ from auth.oauth import oauth_callback, oauth_login from cache.precache import precache_data from cache.revalidator import revalidation_manager from rbac import initialize_rbac -from utils.exception import ExceptionHandlerMiddleware -from storage.redis import redis -from storage.schema import create_all_tables, resolvers from services.search import check_search_service, initialize_search_index_background, search_service from services.viewed import ViewedStorage from settings import DEV_SERVER_PID_FILE_NAME +from storage.redis import redis +from storage.schema import create_all_tables, resolvers +from utils.exception import ExceptionHandlerMiddleware from utils.logger import root_logger as logger DEVMODE = os.getenv("DOKKU_APP_TYPE", "false").lower() == "false" diff --git a/orm/__init__.py b/orm/__init__.py index e69de29b..2e1be2bb 100644 --- a/orm/__init__.py +++ b/orm/__init__.py @@ -0,0 +1,63 @@ +# ORM Models +# Re-export models for convenience +from orm.author import Author, AuthorBookmark, AuthorFollower, AuthorRating + +from . import ( + collection, + community, + draft, + invite, + notification, + rating, + reaction, + shout, + topic, +) +from .collection import Collection, ShoutCollection +from .community import Community, CommunityFollower +from .draft import Draft, DraftAuthor, DraftTopic +from .invite import Invite +from .notification import Notification, NotificationSeen + +# from .rating import Rating # rating.py содержит только константы, не классы +from .reaction import REACTION_KINDS, Reaction, ReactionKind +from .shout import Shout, ShoutAuthor, ShoutReactionsFollower, ShoutTopic +from .topic import Topic, TopicFollower + +__all__ = [ + # "Rating", # rating.py содержит только константы, не классы + "REACTION_KINDS", + # Models + "Author", + "AuthorBookmark", + "AuthorFollower", + "AuthorRating", + "Collection", + "Community", + "CommunityFollower", + "Draft", + "DraftAuthor", + "DraftTopic", + "Invite", + "Notification", + "NotificationSeen", + "Reaction", + "ReactionKind", + "Shout", + "ShoutAuthor", + "ShoutCollection", + "ShoutReactionsFollower", + "ShoutTopic", + "Topic", + "TopicFollower", + # Modules + "collection", + "community", + "draft", + "invite", + "notification", + "rating", + "reaction", + "shout", + "topic", +] diff --git a/auth/orm.py b/orm/author.py similarity index 76% rename from auth/orm.py rename to orm/author.py index 586edb81..e74b446c 100644 --- a/auth/orm.py +++ b/orm/author.py @@ -12,8 +12,8 @@ from sqlalchemy import ( ) from sqlalchemy.orm import Mapped, Session, mapped_column -from auth.password import Password from orm.base import BaseModel as Base +from utils.password import Password # Общие table_args для всех моделей DEFAULT_TABLE_ARGS = {"extend_existing": True} @@ -53,7 +53,7 @@ class Author(Base): # Поля аутентификации email: Mapped[str | None] = mapped_column(String, unique=True, nullable=True, comment="Email") - phone: Mapped[str | None] = mapped_column(String, unique=True, nullable=True, comment="Phone") + phone: Mapped[str | None] = mapped_column(String, nullable=True, comment="Phone") password: Mapped[str | None] = mapped_column(String, nullable=True, comment="Password hash") email_verified: Mapped[bool] = mapped_column(Boolean, default=False) phone_verified: Mapped[bool] = mapped_column(Boolean, default=False) @@ -100,7 +100,7 @@ class Author(Base): """Проверяет, заблокирован ли аккаунт""" if not self.account_locked_until: return False - return bool(self.account_locked_until > int(time.time())) + return int(time.time()) < self.account_locked_until @property def username(self) -> str: @@ -211,72 +211,103 @@ class Author(Base): if self.oauth and provider in self.oauth: del self.oauth[provider] + def to_dict(self, include_protected: bool = False) -> Dict[str, Any]: + """Конвертирует модель в словарь""" + result = { + "id": self.id, + "name": self.name, + "slug": self.slug, + "bio": self.bio, + "about": self.about, + "pic": self.pic, + "links": self.links, + "oauth": self.oauth, + "email_verified": self.email_verified, + "phone_verified": self.phone_verified, + "created_at": self.created_at, + "updated_at": self.updated_at, + "last_seen": self.last_seen, + "deleted_at": self.deleted_at, + "oid": self.oid, + } + + if include_protected: + result.update( + { + "email": self.email, + "phone": self.phone, + "failed_login_attempts": self.failed_login_attempts, + "account_locked_until": self.account_locked_until, + } + ) + + return result + + def __repr__(self) -> str: + return f"" + + +class AuthorFollower(Base): + """ + Связь подписки между авторами. + """ + + __tablename__ = "author_follower" + __table_args__ = ( + PrimaryKeyConstraint("follower", "following"), + Index("idx_author_follower_follower", "follower"), + Index("idx_author_follower_following", "following"), + {"extend_existing": True}, + ) + + follower: Mapped[int] = mapped_column(Integer, ForeignKey("author.id"), nullable=False) + following: Mapped[int] = mapped_column(Integer, ForeignKey("author.id"), nullable=False) + created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time())) + + def __repr__(self) -> str: + return f"" + class AuthorBookmark(Base): """ - Закладка автора на публикацию. - - Attributes: - author (int): ID автора - shout (int): ID публикации + Закладки автора. """ __tablename__ = "author_bookmark" - author: Mapped[int] = mapped_column(ForeignKey(Author.id)) - shout: Mapped[int] = mapped_column(ForeignKey("shout.id")) - created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time())) - __table_args__ = ( - PrimaryKeyConstraint(author, shout), + PrimaryKeyConstraint("author", "shout"), Index("idx_author_bookmark_author", "author"), Index("idx_author_bookmark_shout", "shout"), {"extend_existing": True}, ) + author: Mapped[int] = mapped_column(Integer, ForeignKey("author.id"), nullable=False) + shout: Mapped[int] = mapped_column(Integer, ForeignKey("shout.id"), nullable=False) + created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time())) + + def __repr__(self) -> str: + return f"" + class AuthorRating(Base): """ - Рейтинг автора от другого автора. - - Attributes: - rater (int): ID оценивающего автора - author (int): ID оцениваемого автора - plus (bool): Положительная/отрицательная оценка + Рейтинг автора. """ __tablename__ = "author_rating" - rater: Mapped[int] = mapped_column(ForeignKey(Author.id)) - author: Mapped[int] = mapped_column(ForeignKey(Author.id)) - plus: Mapped[bool] = mapped_column(Boolean) - __table_args__ = ( - PrimaryKeyConstraint(rater, author), + PrimaryKeyConstraint("author", "rater"), Index("idx_author_rating_author", "author"), Index("idx_author_rating_rater", "rater"), {"extend_existing": True}, ) - -class AuthorFollower(Base): - """ - Подписка одного автора на другого. - - Attributes: - follower (int): ID подписчика - author (int): ID автора, на которого подписываются - created_at (int): Время создания подписки - auto (bool): Признак автоматической подписки - """ - - __tablename__ = "author_follower" - follower: Mapped[int] = mapped_column(ForeignKey(Author.id)) - author: Mapped[int] = mapped_column(ForeignKey(Author.id)) + author: Mapped[int] = mapped_column(Integer, ForeignKey("author.id"), nullable=False) + rater: Mapped[int] = mapped_column(Integer, ForeignKey("author.id"), nullable=False) + plus: Mapped[bool] = mapped_column(Boolean, nullable=True) + rating: Mapped[int] = mapped_column(Integer, nullable=False, comment="Rating value") created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time())) - auto: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + updated_at: Mapped[int | None] = mapped_column(Integer, nullable=True) - __table_args__ = ( - PrimaryKeyConstraint(follower, author), - Index("idx_author_follower_author", "author"), - Index("idx_author_follower_follower", "follower"), - {"extend_existing": True}, - ) + def __repr__(self) -> str: + return f"" diff --git a/orm/community.py b/orm/community.py index 19573d0f..bd58ced2 100644 --- a/orm/community.py +++ b/orm/community.py @@ -18,7 +18,7 @@ from sqlalchemy import ( from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import Mapped, mapped_column -from auth.orm import Author +from orm.author import Author from orm.base import BaseModel from rbac.interface import get_rbac_operations from storage.db import local_session diff --git a/orm/draft.py b/orm/draft.py index df63ca35..008e646c 100644 --- a/orm/draft.py +++ b/orm/draft.py @@ -4,7 +4,7 @@ from typing import Any from sqlalchemy import JSON, Boolean, ForeignKey, Index, Integer, PrimaryKeyConstraint, String from sqlalchemy.orm import Mapped, mapped_column, relationship -from auth.orm import Author +from orm.author import Author from orm.base import BaseModel as Base from orm.topic import Topic diff --git a/orm/notification.py b/orm/notification.py index 30a6bd40..df6cdbbf 100644 --- a/orm/notification.py +++ b/orm/notification.py @@ -6,7 +6,7 @@ from sqlalchemy import JSON, DateTime, ForeignKey, Index, Integer, PrimaryKeyCon from sqlalchemy.orm import Mapped, mapped_column, relationship # Импорт Author отложен для избежания циклических импортов -from auth.orm import Author +from orm.author import Author from orm.base import BaseModel as Base from utils.logger import root_logger as logger diff --git a/orm/reaction.py b/orm/reaction.py index c4edf6fa..02639e5b 100644 --- a/orm/reaction.py +++ b/orm/reaction.py @@ -4,16 +4,9 @@ from enum import Enum as Enumeration from sqlalchemy import ForeignKey, Index, Integer, String from sqlalchemy.orm import Mapped, mapped_column -from auth.orm import Author from orm.base import BaseModel as Base -# Author уже импортирован в начале файла -def get_author_model(): - """Возвращает модель Author для использования в запросах""" - return Author - - class ReactionKind(Enumeration): # TYPE = # rating diff diff --git a/orm/topic.py b/orm/topic.py index c15451c7..0df1a230 100644 --- a/orm/topic.py +++ b/orm/topic.py @@ -11,7 +11,7 @@ from sqlalchemy import ( ) from sqlalchemy.orm import Mapped, mapped_column -from auth.orm import Author +from orm.author import Author from orm.base import BaseModel as Base diff --git a/package.json b/package.json index e48e76d3..e53faf6d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "publy-panel", - "version": "0.9.5", + "version": "0.9.7", "type": "module", "description": "Publy, a modern platform for collaborative text creation, offers a user-friendly interface for authors, editors, and readers, supporting real-time collaboration and structured feedback.", "scripts": { diff --git a/pyproject.toml b/pyproject.toml index 6cff75d8..46ec4626 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "discours-core" -version = "0.9.5" +version = "0.9.7" description = "Core backend for Discours.io platform" authors = [ {name = "Tony Rewin", email = "tonyrewin@yandex.ru"} diff --git a/rbac/api.py b/rbac/api.py index f2701498..698ae05f 100644 --- a/rbac/api.py +++ b/rbac/api.py @@ -12,10 +12,10 @@ import asyncio from functools import wraps from typing import Any, Callable -from auth.orm import Author +from orm.author import Author from rbac.interface import get_community_queries, get_rbac_operations -from storage.db import local_session from settings import ADMIN_EMAILS +from storage.db import local_session from utils.logger import root_logger as logger @@ -46,6 +46,20 @@ async def get_permissions_for_role(role: str, community_id: int) -> list[str]: return await rbac_ops.get_permissions_for_role(role, community_id) +async def get_role_permissions_for_community(community_id: int) -> dict: + """ + Получает все разрешения для всех ролей в сообществе. + + Args: + community_id: ID сообщества + + Returns: + Словарь {роль: [разрешения]} для всех ролей + """ + rbac_ops = get_rbac_operations() + return await rbac_ops.get_all_permissions_for_community(community_id) + + async def update_all_communities_permissions() -> None: """ Обновляет права для всех существующих сообществ на основе актуальных дефолтных настроек. @@ -121,7 +135,7 @@ async def roles_have_permission(role_slugs: list[str], permission: str, communit True если хотя бы одна роль имеет разрешение """ rbac_ops = get_rbac_operations() - return await rbac_ops._roles_have_permission(role_slugs, permission, community_id) + return await rbac_ops.roles_have_permission(role_slugs, permission, community_id) # --- Декораторы --- diff --git a/rbac/interface.py b/rbac/interface.py index 4c14b2c4..09aebc1b 100644 --- a/rbac/interface.py +++ b/rbac/interface.py @@ -28,7 +28,15 @@ class RBACOperations(Protocol): """Проверяет разрешение пользователя в сообществе""" ... - async def _roles_have_permission(self, role_slugs: list[str], permission: str, community_id: int) -> bool: + async def get_role_permissions_for_community(self, community_id: int, role: str) -> dict: + """Получает права для конкретной роли в сообществе""" + ... + + async def get_all_permissions_for_community(self, community_id: int) -> dict: + """Получает все права ролей для конкретного сообщества""" + ... + + async def roles_have_permission(self, role_slugs: list[str], permission: str, community_id: int) -> bool: """Проверяет, есть ли у набора ролей конкретное разрешение в сообществе""" ... diff --git a/rbac/operations.py b/rbac/operations.py index 6add797a..a103e05d 100644 --- a/rbac/operations.py +++ b/rbac/operations.py @@ -40,7 +40,7 @@ class RBACOperationsImpl(RBACOperations): Returns: Список разрешений для роли """ - role_perms = await self._get_role_permissions_for_community(community_id) + role_perms = await self.get_role_permissions_for_community(community_id, role) return role_perms.get(role, []) async def initialize_community_permissions(self, community_id: int) -> None: @@ -117,18 +117,52 @@ class RBACOperationsImpl(RBACOperations): """ community_queries = get_community_queries() user_roles = community_queries.get_user_roles_in_community(author_id, community_id, session) - return await self._roles_have_permission(user_roles, permission, community_id) + return await self.roles_have_permission(user_roles, permission, community_id) - async def _get_role_permissions_for_community(self, community_id: int) -> dict: + async def get_role_permissions_for_community(self, community_id: int, role: str) -> dict: """ - Получает права ролей для конкретного сообщества. + Получает права для конкретной роли в сообществе, включая все наследованные разрешения. + Если права не настроены, автоматически инициализирует их дефолтными. + + Args: + community_id: ID сообщества + role: Название роли для получения разрешений + + Returns: + Словарь {роль: [разрешения]} для указанной роли с учетом наследования + """ + key = f"community:roles:{community_id}" + data = await redis.execute("GET", key) + + if data: + role_permissions = json.loads(data) + if role in role_permissions: + return {role: role_permissions[role]} + # Если роль не найдена в кеше, используем рекурсивный расчет + + # Автоматически инициализируем, если не найдено + await self.initialize_community_permissions(community_id) + + # Получаем инициализированные разрешения + data = await redis.execute("GET", key) + if data: + role_permissions = json.loads(data) + if role in role_permissions: + return {role: role_permissions[role]} + + # Fallback: рекурсивно вычисляем разрешения для роли + return {role: list(self._get_role_permissions_recursive(role))} + + async def get_all_permissions_for_community(self, community_id: int) -> dict: + """ + Получает все права ролей для конкретного сообщества. Если права не настроены, автоматически инициализирует их дефолтными. Args: community_id: ID сообщества Returns: - Словарь прав ролей для сообщества + Словарь {роль: [разрешения]} для всех ролей в сообществе """ key = f"community:roles:{community_id}" data = await redis.execute("GET", key) @@ -147,7 +181,41 @@ class RBACOperationsImpl(RBACOperations): # Fallback на дефолтные разрешения если что-то пошло не так return DEFAULT_ROLE_PERMISSIONS - async def _roles_have_permission(self, role_slugs: list[str], permission: str, community_id: int) -> bool: + def _get_role_permissions_recursive(self, role: str, processed_roles: set[str] | None = None) -> set[str]: + """ + Рекурсивно получает все разрешения для роли, включая наследованные. + Вспомогательный метод для вычисления разрешений без обращения к Redis. + + Args: + role: Название роли + processed_roles: Множество уже обработанных ролей для предотвращения зацикливания + + Returns: + Множество всех разрешений роли (прямых и наследованных) + """ + if processed_roles is None: + processed_roles = set() + + if role in processed_roles: + return set() + + processed_roles.add(role) + + # Получаем прямые разрешения роли + direct_permissions = set(DEFAULT_ROLE_PERMISSIONS.get(role, [])) + + # Проверяем, есть ли наследование роли + inherited_permissions = set() + for perm in list(direct_permissions): + if perm in role_names: + # Если пермишен - это название роли, добавляем все её разрешения + direct_permissions.remove(perm) + inherited_permissions.update(self._get_role_permissions_recursive(perm, processed_roles)) + + # Объединяем прямые и наследованные разрешения + return direct_permissions | inherited_permissions + + async def roles_have_permission(self, role_slugs: list[str], permission: str, community_id: int) -> bool: """ Проверяет, есть ли у набора ролей конкретное разрешение в сообществе. @@ -159,8 +227,12 @@ class RBACOperationsImpl(RBACOperations): Returns: True если хотя бы одна роль имеет разрешение """ - role_perms = await self._get_role_permissions_for_community(community_id) - return any(permission in role_perms.get(role, []) for role in role_slugs) + # Получаем разрешения для каждой роли с учетом наследования + for role in role_slugs: + role_perms = await self.get_role_permissions_for_community(community_id, role) + if permission in role_perms.get(role, []): + return True + return False class CommunityAuthorQueriesImpl(CommunityAuthorQueries): diff --git a/rbac/permissions.py b/rbac/permissions.py index 49019ff9..99ff3389 100644 --- a/rbac/permissions.py +++ b/rbac/permissions.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import Session -from auth.orm import Author +from orm.author import Author from orm.community import Community, CommunityAuthor from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST diff --git a/resolvers/admin.py b/resolvers/admin.py index 17b88942..359e34af 100644 --- a/resolvers/admin.py +++ b/resolvers/admin.py @@ -11,7 +11,7 @@ from sqlalchemy import and_, case, func, or_ from sqlalchemy.orm import aliased from auth.decorators import admin_auth_required -from auth.orm import Author +from orm.author import Author from orm.community import Community, CommunityAuthor from orm.draft import DraftTopic from orm.reaction import Reaction @@ -21,10 +21,10 @@ from rbac.api import update_all_communities_permissions from resolvers.editor import delete_shout, update_shout from resolvers.topic import invalidate_topic_followers_cache, invalidate_topics_cache from services.admin import AdminService -from utils.common_result import handle_error from storage.db import local_session from storage.redis import redis from storage.schema import mutation, query +from utils.common_result import handle_error from utils.logger import root_logger as logger admin_service = AdminService() diff --git a/resolvers/auth.py b/resolvers/auth.py index 7e469472..f3b8948a 100644 --- a/resolvers/auth.py +++ b/resolvers/auth.py @@ -7,9 +7,10 @@ from typing import Any from graphql import GraphQLResolveInfo from starlette.responses import JSONResponse +from auth.utils import extract_token_from_request, get_auth_token_from_context, get_user_data_by_token from services.auth import auth_service -from storage.schema import mutation, query, type_author from settings import SESSION_COOKIE_NAME +from storage.schema import mutation, query, type_author from utils.logger import root_logger as logger # === РЕЗОЛВЕР ДЛЯ ТИПА AUTHOR === @@ -121,11 +122,7 @@ async def logout(_: None, info: GraphQLResolveInfo, **kwargs: Any) -> dict[str, # Получаем токен token = None if request: - 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:] + token = await extract_token_from_request(request) result = await auth_service.logout(user_id, token) @@ -158,11 +155,7 @@ async def refresh_token(_: None, info: GraphQLResolveInfo, **kwargs: Any) -> dic return {"success": False, "token": None, "author": None, "error": "Запрос не найден"} # Получаем токен - 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:] + token = await extract_token_from_request(request) if not token: return {"success": False, "token": None, "author": None, "error": "Токен не найден"} @@ -262,21 +255,25 @@ async def cancel_email_change(_: None, info: GraphQLResolveInfo, **kwargs: Any) @mutation.field("getSession") -@auth_service.login_required async def get_session(_: None, info: GraphQLResolveInfo, **kwargs: Any) -> dict[str, Any]: """Получает информацию о текущей сессии""" try: - # Получаем токен из контекста (установлен декоратором login_required) - token = info.context.get("token") - author = info.context.get("author") + token = await get_auth_token_from_context(info) if not token: - return {"success": False, "token": None, "author": None, "error": "Токен не найден"} + logger.debug("[getSession] Токен не найден") + return {"success": False, "token": None, "author": None, "error": "Сессия не найдена"} - if not author: - return {"success": False, "token": None, "author": None, "error": "Пользователь не найден"} + # Используем DRY функцию для получения данных пользователя + success, user_data, error_message = await get_user_data_by_token(token) + + if success and user_data: + user_id = user_data.get("id", "NO_ID") + logger.debug(f"[getSession] Сессия валидна для пользователя {user_id}") + return {"success": True, "token": token, "author": user_data, "error": None} + logger.warning(f"[getSession] Ошибка валидации токена: {error_message}") + return {"success": False, "token": None, "author": None, "error": error_message} - 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)} diff --git a/resolvers/author.py b/resolvers/author.py index f12357b9..92d25d8c 100644 --- a/resolvers/author.py +++ b/resolvers/author.py @@ -7,7 +7,6 @@ from graphql import GraphQLResolveInfo from sqlalchemy import and_, asc, func, select, text from sqlalchemy.sql import desc as sql_desc -from auth.orm import Author, AuthorFollower from cache.cache import ( cache_author, cached_query, @@ -17,14 +16,15 @@ from cache.cache import ( get_cached_follower_topics, invalidate_cache_by_prefix, ) +from orm.author import Author, AuthorFollower from orm.community import Community, CommunityAuthor, CommunityFollower from orm.shout import Shout, ShoutAuthor from resolvers.stat import get_with_stat from services.auth import login_required -from utils.common_result import CommonResult from storage.db import local_session from storage.redis import redis from storage.schema import mutation, query +from utils.common_result import CommonResult from utils.logger import root_logger as logger DEFAULT_COMMUNITIES = [1] @@ -199,11 +199,11 @@ async def get_authors_with_stats( logger.debug("Building subquery for followers sorting") subquery = ( select( - AuthorFollower.author, + AuthorFollower.following, func.count(func.distinct(AuthorFollower.follower)).label("followers_count"), ) .select_from(AuthorFollower) - .group_by(AuthorFollower.author) + .group_by(AuthorFollower.following) .subquery() ) diff --git a/resolvers/bookmark.py b/resolvers/bookmark.py index dd07def4..51b0d275 100644 --- a/resolvers/bookmark.py +++ b/resolvers/bookmark.py @@ -3,13 +3,13 @@ from operator import and_ from graphql import GraphQLError from sqlalchemy import delete, insert -from auth.orm import AuthorBookmark +from orm.author import AuthorBookmark from orm.shout import Shout from resolvers.reader import apply_options, get_shouts_with_links, query_with_stat from services.auth import login_required -from utils.common_result import CommonResult from storage.db import local_session from storage.schema import mutation, query +from utils.common_result import CommonResult @query.field("load_shouts_bookmarked") diff --git a/resolvers/collab.py b/resolvers/collab.py index 94335c9d..39f032e8 100644 --- a/resolvers/collab.py +++ b/resolvers/collab.py @@ -1,6 +1,6 @@ from typing import Any -from auth.orm import Author +from orm.author import Author from orm.invite import Invite, InviteStatus from orm.shout import Shout from services.auth import login_required diff --git a/resolvers/collection.py b/resolvers/collection.py index a64a5c81..108d3d33 100644 --- a/resolvers/collection.py +++ b/resolvers/collection.py @@ -4,7 +4,7 @@ from graphql import GraphQLResolveInfo from sqlalchemy.orm import joinedload from auth.decorators import editor_or_admin_required -from auth.orm import Author +from orm.author import Author from orm.collection import Collection, ShoutCollection from rbac.api import require_any_permission from storage.db import local_session diff --git a/resolvers/community.py b/resolvers/community.py index 7c1a6a66..afa5eade 100644 --- a/resolvers/community.py +++ b/resolvers/community.py @@ -4,7 +4,7 @@ from typing import Any from graphql import GraphQLResolveInfo from sqlalchemy import distinct, func -from auth.orm import Author +from orm.author import Author from orm.community import Community, CommunityAuthor, CommunityFollower from orm.shout import Shout, ShoutAuthor from rbac.api import ( diff --git a/resolvers/draft.py b/resolvers/draft.py index d40deca1..465d9f08 100644 --- a/resolvers/draft.py +++ b/resolvers/draft.py @@ -4,18 +4,18 @@ from typing import Any from graphql import GraphQLResolveInfo from sqlalchemy.orm import Session, joinedload -from auth.orm import Author from cache.cache import ( invalidate_shout_related_cache, invalidate_shouts_cache, ) +from orm.author import Author from orm.draft import Draft, DraftAuthor, DraftTopic from orm.shout import Shout, ShoutAuthor, ShoutTopic from services.auth import login_required -from storage.db import local_session from services.notify import notify_shout -from storage.schema import mutation, query from services.search import search_service +from storage.db import local_session +from storage.schema import mutation, query from utils.extract_text import extract_text from utils.logger import root_logger as logger diff --git a/resolvers/editor.py b/resolvers/editor.py index 86329b9a..6edb3ccf 100644 --- a/resolvers/editor.py +++ b/resolvers/editor.py @@ -7,23 +7,23 @@ from sqlalchemy import and_, desc, select from sqlalchemy.orm import joinedload from sqlalchemy.sql.functions import coalesce -from auth.orm import Author from cache.cache import ( cache_author, cache_topic, invalidate_shout_related_cache, invalidate_shouts_cache, ) +from orm.author import Author from orm.shout import Shout, ShoutAuthor, ShoutTopic from orm.topic import Topic from resolvers.follower import follow from resolvers.stat import get_with_stat from services.auth import login_required -from utils.common_result import CommonResult -from storage.db import local_session from services.notify import notify_shout -from storage.schema import mutation, query from services.search import search_service +from storage.db import local_session +from storage.schema import mutation, query +from utils.common_result import CommonResult from utils.extract_text import extract_text from utils.logger import root_logger as logger diff --git a/resolvers/feed.py b/resolvers/feed.py index 4fca31f3..e8898aa8 100644 --- a/resolvers/feed.py +++ b/resolvers/feed.py @@ -3,7 +3,7 @@ from typing import Any from graphql import GraphQLResolveInfo from sqlalchemy import Select, and_, select -from auth.orm import Author, AuthorFollower +from orm.author import Author, AuthorFollower from orm.shout import Shout, ShoutAuthor, ShoutReactionsFollower, ShoutTopic from orm.topic import Topic, TopicFollower from resolvers.reader import ( @@ -70,7 +70,7 @@ def shouts_by_follower(info: GraphQLResolveInfo, follower_id: int, options: dict :return: Список публикаций. """ q = query_with_stat(info) - reader_followed_authors: Select = select(AuthorFollower.author).where(AuthorFollower.follower == follower_id) + reader_followed_authors: Select = select(AuthorFollower.following).where(AuthorFollower.follower == follower_id) reader_followed_topics: Select = select(TopicFollower.topic).where(TopicFollower.follower == follower_id) reader_followed_shouts: Select = select(ShoutReactionsFollower.shout).where( ShoutReactionsFollower.follower == follower_id diff --git a/resolvers/follower.py b/resolvers/follower.py index 05e205db..81e60844 100644 --- a/resolvers/follower.py +++ b/resolvers/follower.py @@ -5,19 +5,19 @@ from typing import Any from graphql import GraphQLResolveInfo from sqlalchemy.sql import and_ -from auth.orm import Author, AuthorFollower from cache.cache import ( cache_author, cache_topic, get_cached_follower_authors, get_cached_follower_topics, ) +from orm.author import Author, AuthorFollower from orm.community import Community, CommunityFollower from orm.shout import Shout, ShoutReactionsFollower from orm.topic import Topic, TopicFollower from services.auth import login_required -from storage.db import local_session from services.notify import notify_follower +from storage.db import local_session from storage.redis import redis from storage.schema import mutation, query from utils.logger import root_logger as logger diff --git a/resolvers/notifier.py b/resolvers/notifier.py index 2b831ab4..2a61a40e 100644 --- a/resolvers/notifier.py +++ b/resolvers/notifier.py @@ -8,7 +8,7 @@ from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import aliased from sqlalchemy.sql import not_ -from auth.orm import Author +from orm.author import Author from orm.notification import ( Notification, NotificationAction, diff --git a/resolvers/rating.py b/resolvers/rating.py index 8a4933a0..930ae711 100644 --- a/resolvers/rating.py +++ b/resolvers/rating.py @@ -4,7 +4,7 @@ from graphql import GraphQLResolveInfo from sqlalchemy import and_, case, func, select, true from sqlalchemy.orm import Session, aliased -from auth.orm import Author, AuthorRating +from orm.author import Author, AuthorRating from orm.reaction import Reaction, ReactionKind from orm.shout import Shout, ShoutAuthor from services.auth import login_required @@ -116,7 +116,7 @@ async def rate_author(_: None, info: GraphQLResolveInfo, rated_slug: str, value: .first() ) if rating: - rating.plus = value > 0 # type: ignore[assignment] + rating.plus = value > 0 session.add(rating) session.commit() return {} diff --git a/resolvers/reaction.py b/resolvers/reaction.py index dc466279..8d6b1672 100644 --- a/resolvers/reaction.py +++ b/resolvers/reaction.py @@ -7,7 +7,7 @@ from sqlalchemy import Select, and_, asc, case, desc, func, select from sqlalchemy.orm import Session, aliased from sqlalchemy.sql import ColumnElement -from auth.orm import Author +from orm.author import Author from orm.rating import ( NEGATIVE_REACTIONS, POSITIVE_REACTIONS, @@ -21,8 +21,8 @@ from resolvers.follower import follow from resolvers.proposals import handle_proposing from resolvers.stat import update_author_stat from services.auth import add_user_role, login_required -from storage.db import local_session from services.notify import notify_reaction +from storage.db import local_session from storage.schema import mutation, query from utils.logger import root_logger as logger diff --git a/resolvers/reader.py b/resolvers/reader.py index e38a14e2..34c5c5a9 100644 --- a/resolvers/reader.py +++ b/resolvers/reader.py @@ -6,14 +6,14 @@ from sqlalchemy import Select, and_, nulls_last, text from sqlalchemy.orm import Session, aliased from sqlalchemy.sql.expression import asc, case, desc, func, select -from auth.orm import Author +from orm.author import Author from orm.reaction import Reaction, ReactionKind from orm.shout import Shout, ShoutAuthor, ShoutTopic from orm.topic import Topic -from storage.db import json_array_builder, json_builder, local_session -from storage.schema import query from services.search import SearchService, search_text from services.viewed import ViewedStorage +from storage.db import json_array_builder, json_builder, local_session +from storage.schema import query from utils.logger import root_logger as logger diff --git a/resolvers/stat.py b/resolvers/stat.py index 82b24189..b21fcbbb 100644 --- a/resolvers/stat.py +++ b/resolvers/stat.py @@ -7,8 +7,8 @@ from sqlalchemy import and_, distinct, func, join, select from sqlalchemy.orm import aliased from sqlalchemy.sql.expression import Select -from auth.orm import Author, AuthorFollower from cache.cache import cache_author +from orm.author import Author, AuthorFollower from orm.community import Community, CommunityFollower from orm.reaction import Reaction, ReactionKind from orm.shout import Shout, ShoutAuthor, ShoutTopic @@ -81,7 +81,7 @@ def add_author_stat_columns(q: QueryType) -> QueryType: # Подзапрос для подсчета подписчиков followers_subq = ( select(func.count(distinct(AuthorFollower.follower))) - .where(AuthorFollower.author == Author.id) + .where(AuthorFollower.following == Author.id) .scalar_subquery() ) @@ -241,7 +241,7 @@ def get_author_followers_stat(author_id: int) -> int: """ Получает количество подписчиков для указанного автора """ - q = select(func.count(AuthorFollower.follower)).filter(AuthorFollower.author == author_id) + q = select(func.count(AuthorFollower.follower)).filter(AuthorFollower.following == author_id) with local_session() as session: result = session.execute(q).scalar() @@ -336,7 +336,7 @@ def author_follows_authors(author_id: int) -> list[Any]: """ af = aliased(AuthorFollower, name="af") author_follows_authors_query = ( - select(Author).select_from(join(Author, af, Author.id == af.author)).where(af.follower == author_id) + select(Author).select_from(join(Author, af, Author.id == af.following)).where(af.follower == author_id) ) return get_with_stat(author_follows_authors_query) @@ -393,7 +393,7 @@ def get_followers_count(entity_type: str, entity_id: int) -> int: # Count followers of this author result = ( session.query(func.count(AuthorFollower.follower)) - .filter(AuthorFollower.author == entity_id) + .filter(AuthorFollower.following == entity_id) .scalar() ) elif entity_type == "community": diff --git a/resolvers/topic.py b/resolvers/topic.py index 0fb47304..6dad54b2 100644 --- a/resolvers/topic.py +++ b/resolvers/topic.py @@ -4,7 +4,6 @@ from typing import Any from graphql import GraphQLResolveInfo from sqlalchemy import desc, func, select, text -from auth.orm import Author from cache.cache import ( cache_topic, cached_query, @@ -14,6 +13,7 @@ from cache.cache import ( invalidate_cache_by_prefix, invalidate_topic_followers_cache, ) +from orm.author import Author from orm.draft import DraftTopic from orm.reaction import Reaction, ReactionKind from orm.shout import Shout, ShoutAuthor, ShoutTopic diff --git a/schema/type.graphql b/schema/type.graphql index 1883b008..4309a734 100644 --- a/schema/type.graphql +++ b/schema/type.graphql @@ -309,8 +309,10 @@ type Permission { } type SessionInfo { - token: String! - author: Author! + success: Boolean! + token: String + author: Author + error: String } type AuthSuccess { diff --git a/services/admin.py b/services/admin.py index f4b693bf..bacf34d1 100644 --- a/services/admin.py +++ b/services/admin.py @@ -10,13 +10,13 @@ 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.author import Author from orm.community import Community, CommunityAuthor, role_descriptions, role_names from orm.invite import Invite, InviteStatus from orm.shout import Shout +from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST from storage.db import local_session from storage.env import EnvVariable, env_manager -from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST from utils.logger import root_logger as logger diff --git a/services/auth.py b/services/auth.py index 88da303a..bce59ee2 100644 --- a/services/auth.py +++ b/services/auth.py @@ -17,11 +17,11 @@ from auth.exceptions import InvalidPasswordError, InvalidTokenError, ObjectNotEx from auth.identity import Identity from auth.internal import verify_internal_auth from auth.jwtcodec import JWTCodec -from auth.orm import Author -from auth.password import Password from auth.tokens.storage import TokenStorage from auth.tokens.verification import VerificationTokenManager +from auth.utils import extract_token_from_request from cache.cache import get_cached_author_by_id +from orm.author import Author from orm.community import ( Community, CommunityAuthor, @@ -29,15 +29,16 @@ from orm.community import ( assign_role_to_user, get_user_roles_in_community, ) -from storage.db import local_session -from storage.redis import redis from settings import ( ADMIN_EMAILS, SESSION_COOKIE_NAME, SESSION_TOKEN_HEADER, ) +from storage.db import local_session +from storage.redis import redis from utils.generate_slug import generate_unique_slug from utils.logger import root_logger as logger +from utils.password import Password # Список разрешенных заголовков ALLOWED_HEADERS = ["Authorization", "Content-Type"] @@ -62,25 +63,12 @@ class AuthService: logger.debug("[check_auth] Запрос отсутствует (тестовое окружение)") return 0, [], False - # Проверяем заголовок с учетом регистра - 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 + token = await extract_token_from_request(req) if not token: - logger.debug("[check_auth] Токен не найден в заголовках") + logger.debug("[check_auth] Токен не найден") return 0, [], False - # Очищаем токен от префикса 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) diff --git a/services/viewed.py b/services/viewed.py index 295d0761..31a21dcc 100644 --- a/services/viewed.py +++ b/services/viewed.py @@ -15,7 +15,7 @@ from google.analytics.data_v1beta.types import ( ) from google.analytics.data_v1beta.types import Filter as GAFilter -from auth.orm import Author +from orm.author import Author from orm.shout import Shout, ShoutAuthor, ShoutTopic from orm.topic import Topic from storage.db import local_session diff --git a/storage/schema.py b/storage/schema.py index ce26ada2..f4e1b0c2 100644 --- a/storage/schema.py +++ b/storage/schema.py @@ -9,10 +9,8 @@ from ariadne import ( load_schema_from_path, ) -from auth.orm import Author, AuthorBookmark, AuthorFollower, AuthorRating - -# Импорт Author, AuthorBookmark, AuthorFollower, AuthorRating отложен для избежания циклических импортов from orm import collection, community, draft, invite, notification, reaction, shout, topic +from orm.author import Author, AuthorBookmark, AuthorFollower, AuthorRating from storage.db import create_table_if_not_exists, local_session # Создаем основные типы diff --git a/tests/auth/test_auth_service.py b/tests/auth/test_auth_service.py index 96010065..03b81ae7 100644 --- a/tests/auth/test_auth_service.py +++ b/tests/auth/test_auth_service.py @@ -1,6 +1,6 @@ import pytest from services.auth import AuthService -from auth.orm import Author +from orm.author import Author @pytest.mark.asyncio async def test_ensure_user_has_reader_role(db_session): diff --git a/tests/auth/test_identity.py b/tests/auth/test_identity.py index 10ab7025..2745bd3c 100644 --- a/tests/auth/test_identity.py +++ b/tests/auth/test_identity.py @@ -1,5 +1,5 @@ import pytest -from auth.password import Password +from utils.password import Password def test_password_verify(): # Создаем пароль diff --git a/tests/auth/test_oauth.py b/tests/auth/test_oauth.py index e91ef054..de327e8e 100644 --- a/tests/auth/test_oauth.py +++ b/tests/auth/test_oauth.py @@ -6,7 +6,7 @@ import logging from starlette.responses import JSONResponse, RedirectResponse from auth.oauth import get_user_profile, oauth_callback_http, oauth_login_http -from auth.orm import Author +from orm.author import Author from storage.db import local_session # Настройка логгера @@ -213,7 +213,7 @@ def oauth_db_session(db_session): @pytest.fixture def simple_user(oauth_db_session): """Фикстура для простого пользователя""" - from auth.orm import Author + from orm.author import Author import time # Создаем тестового пользователя diff --git a/tests/conftest.py b/tests/conftest.py index 7f47726e..a7e2b49e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -62,13 +62,17 @@ def test_engine(): # Импортируем все модели, чтобы они были зарегистрированы from orm.base import BaseModel as Base from orm.community import Community, CommunityAuthor - from auth.orm import Author + from orm.author import Author from orm.draft import Draft, DraftAuthor, DraftTopic from orm.shout import Shout, ShoutAuthor, ShoutTopic, ShoutReactionsFollower from orm.topic import Topic from orm.reaction import Reaction from orm.invite import Invite from orm.notification import Notification + + # Инициализируем RBAC систему + import rbac + rbac.initialize_rbac() engine = create_engine( "sqlite:///:memory:", echo=False, poolclass=StaticPool, connect_args={"check_same_thread": False} @@ -121,7 +125,7 @@ def db_session(test_session_factory, test_engine): # Создаем дефолтное сообщество для тестов from orm.community import Community - from auth.orm import Author + from orm.author import Author import time # Создаем системного автора если его нет @@ -178,7 +182,7 @@ def db_session_commit(test_session_factory): # Создаем дефолтное сообщество для тестов from orm.community import Community - from auth.orm import Author + from orm.author import Author # Создаем системного автора если его нет system_author = session.query(Author).where(Author.slug == "system").first() @@ -429,7 +433,7 @@ def wait_for_server(): @pytest.fixture def test_users(db_session): """Создает тестовых пользователей для тестов""" - from auth.orm import Author + from orm.author import Author # Создаем первого пользователя (администратор) admin_user = Author( diff --git a/tests/test_admin_panel_fixes.py b/tests/test_admin_panel_fixes.py index d2d5abb5..fb5248b9 100644 --- a/tests/test_admin_panel_fixes.py +++ b/tests/test_admin_panel_fixes.py @@ -9,7 +9,7 @@ import pytest import time from unittest.mock import patch, MagicMock -from auth.orm import Author +from orm.author import Author from orm.community import Community, CommunityAuthor from storage.db import local_session @@ -291,7 +291,7 @@ class TestPermissionSystem: def test_admin_permissions(self, db_session, admin_user_with_roles, test_community): """Тест разрешений администратора""" - from auth.permissions import ContextualPermissionCheck + from rbac.permissions import ContextualPermissionCheck # Проверяем что администратор имеет все разрешения permissions_to_check = [ @@ -314,7 +314,7 @@ class TestPermissionSystem: def test_regular_user_permissions(self, db_session, regular_user_with_roles, test_community): """Тест разрешений обычного пользователя""" - from auth.permissions import ContextualPermissionCheck + from rbac.permissions import ContextualPermissionCheck # Проверяем что обычный пользователь имеет роли reader и author ca = CommunityAuthor.find_author_in_community( @@ -331,7 +331,7 @@ class TestPermissionSystem: def test_permission_without_community_author(self, db_session, test_users, test_community): """Тест разрешений для пользователя без CommunityAuthor""" - from auth.permissions import ContextualPermissionCheck + from rbac.permissions import ContextualPermissionCheck # Проверяем разрешения для пользователя без ролей в сообществе has_permission = ContextualPermissionCheck.check_permission( diff --git a/tests/test_admin_permissions.py b/tests/test_admin_permissions.py index 1a4541d4..424405a8 100644 --- a/tests/test_admin_permissions.py +++ b/tests/test_admin_permissions.py @@ -11,7 +11,7 @@ async def test_admin_permissions(): """Проверяем, что у роли admin есть все необходимые права""" # Загружаем дефолтные права - with Path("services/default_role_permissions.json").open() as f: + with Path("rbac/default_role_permissions.json").open() as f: default_permissions = json.load(f) # Получаем права роли admin diff --git a/tests/test_auth_coverage.py b/tests/test_auth_coverage.py index 6ab13647..1d8880f7 100644 --- a/tests/test_auth_coverage.py +++ b/tests/test_auth_coverage.py @@ -7,7 +7,7 @@ from datetime import datetime, timedelta # Импортируем модули auth для покрытия import auth.__init__ -import auth.permissions +import rbac.permissions import auth.decorators import auth.oauth import auth.state @@ -17,7 +17,7 @@ import auth.jwtcodec import auth.email import auth.exceptions import auth.validations -import auth.orm +import orm.author import auth.credentials import auth.handler import auth.internal @@ -39,18 +39,18 @@ class TestAuthInit: class TestAuthPermissions: - """Тесты для auth.permissions""" + """Тесты для rbac.permissions""" def test_permissions_import(self): """Тест импорта permissions""" - import auth.permissions - assert auth.permissions is not None + import rbac.permissions + assert rbac.permissions is not None def test_permissions_functions_exist(self): """Тест существования функций permissions""" - import auth.permissions + import rbac.permissions # Проверяем что модуль импортируется без ошибок - assert auth.permissions is not None + assert rbac.permissions is not None class TestAuthDecorators: @@ -189,16 +189,16 @@ class TestAuthValidations: class TestAuthORM: - """Тесты для auth.orm""" + """Тесты для orm.author""" def test_orm_import(self): """Тест импорта orm""" - from auth.orm import Author + from orm.author import Author assert Author is not None def test_orm_functions_exist(self): """Тест существования функций orm""" - from auth.orm import Author + from orm.author import Author # Проверяем что модель Author существует assert Author is not None assert hasattr(Author, 'id') diff --git a/tests/test_auth_fixes.py b/tests/test_auth_fixes.py index 5eee20d8..0e76ef67 100644 --- a/tests/test_auth_fixes.py +++ b/tests/test_auth_fixes.py @@ -8,11 +8,10 @@ import pytest import time from unittest.mock import patch, MagicMock -from auth.orm import Author, AuthorBookmark, AuthorRating, AuthorFollower +from orm.author import Author, AuthorBookmark, AuthorRating, AuthorFollower from auth.internal import verify_internal_auth -from auth.permissions import ContextualPermissionCheck +from rbac.permissions import ContextualPermissionCheck from orm.community import Community, CommunityAuthor -from auth.permissions import ContextualPermissionCheck from storage.db import local_session @@ -69,7 +68,7 @@ class TestAuthORMFixes: rating = AuthorRating( rater=test_users[0].id, author=test_users[1].id, - plus=True + rating=5 # Используем поле rating вместо plus ) db_session.add(rating) db_session.commit() @@ -83,15 +82,15 @@ class TestAuthORMFixes: assert saved_rating is not None assert saved_rating.rater == test_users[0].id assert saved_rating.author == test_users[1].id - assert saved_rating.plus is True + assert saved_rating.rating == 5 # Проверяем поле rating def test_author_follower_creation(self, db_session, test_users): """Тест создания подписки автора""" follower = AuthorFollower( follower=test_users[0].id, - author=test_users[1].id, - created_at=int(time.time()), - auto=False + following=test_users[1].id, # Используем поле following вместо author + created_at=int(time.time()) + # Убрано поле auto, которого нет в новой модели ) db_session.add(follower) db_session.commit() @@ -99,13 +98,13 @@ class TestAuthORMFixes: # Проверяем что подписка создана saved_follower = db_session.query(AuthorFollower).where( AuthorFollower.follower == test_users[0].id, - AuthorFollower.author == test_users[1].id + AuthorFollower.following == test_users[1].id # Используем поле following ).first() assert saved_follower is not None assert saved_follower.follower == test_users[0].id - assert saved_follower.author == test_users[1].id - assert saved_follower.auto is False + assert saved_follower.following == test_users[1].id # Проверяем поле following + # Убрана проверка поля auto def test_author_oauth_methods(self, db_session, test_users): """Тест методов работы с OAuth""" @@ -145,10 +144,6 @@ class TestAuthORMFixes: """Тест метода dict() для сериализации""" user = test_users[0] - # Добавляем роли - user.roles_data = {"1": ["reader", "author"]} - db_session.commit() - # Получаем словарь user_dict = user.dict() diff --git a/tests/test_community_creator_fix.py b/tests/test_community_creator_fix.py index e92589d8..2e7466f1 100644 --- a/tests/test_community_creator_fix.py +++ b/tests/test_community_creator_fix.py @@ -9,7 +9,7 @@ import pytest import time from sqlalchemy.orm import Session -from auth.orm import Author +from orm.author import Author from orm.community import ( Community, CommunityAuthor, diff --git a/tests/test_community_functionality.py b/tests/test_community_functionality.py index 4a37726b..61918385 100644 --- a/tests/test_community_functionality.py +++ b/tests/test_community_functionality.py @@ -8,7 +8,7 @@ import pytest import time from sqlalchemy import text from orm.community import Community, CommunityAuthor, CommunityFollower -from auth.orm import Author +from orm.author import Author class TestCommunityFunctionality: diff --git a/tests/test_community_rbac.py b/tests/test_community_rbac.py index 31c5b7b3..beeeb474 100644 --- a/tests/test_community_rbac.py +++ b/tests/test_community_rbac.py @@ -10,7 +10,7 @@ import time import uuid from unittest.mock import patch, MagicMock -from auth.orm import Author +from orm.author import Author from orm.community import Community, CommunityAuthor from rbac.api import ( initialize_community_permissions, diff --git a/tests/test_config.py b/tests/test_config.py index 00eddb34..4bb599c1 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -12,7 +12,7 @@ from starlette.routing import Route from starlette.testclient import TestClient # Импортируем все модели чтобы SQLAlchemy знал о них -from auth.orm import ( # noqa: F401 +from orm.author import ( # noqa: F401 Author, AuthorBookmark, AuthorFollower, diff --git a/tests/test_coverage_imports.py b/tests/test_coverage_imports.py index d75ff01b..f31f9528 100644 --- a/tests/test_coverage_imports.py +++ b/tests/test_coverage_imports.py @@ -61,7 +61,7 @@ import resolvers.admin import auth import auth.__init__ -import auth.permissions +import rbac.permissions import auth.decorators import auth.oauth import auth.state @@ -71,7 +71,7 @@ import auth.jwtcodec import auth.email import auth.exceptions import auth.validations -import auth.orm +import orm.author import auth.credentials import auth.handler import auth.internal @@ -147,7 +147,7 @@ class TestCoverageImports: """Тест импорта модулей auth""" assert auth is not None assert auth.__init__ is not None - assert auth.permissions is not None + assert rbac.permissions is not None assert auth.decorators is not None assert auth.oauth is not None assert auth.state is not None @@ -157,7 +157,7 @@ class TestCoverageImports: assert auth.email is not None assert auth.exceptions is not None assert auth.validations is not None - assert auth.orm is not None + assert orm.author is not None assert auth.credentials is not None assert auth.handler is not None assert auth.internal is not None diff --git a/tests/test_db_coverage.py b/tests/test_db_coverage.py index dd07c35f..7eaf946b 100644 --- a/tests/test_db_coverage.py +++ b/tests/test_db_coverage.py @@ -60,7 +60,7 @@ class TestDatabaseFunctions: # Проверяем, что сессия работает с существующими таблицами # Используем Author вместо TestModel - from auth.orm import Author + from orm.author import Author authors_count = session.query(Author).count() assert isinstance(authors_count, int) diff --git a/tests/test_drafts.py b/tests/test_drafts.py index 08f56cc7..86a65745 100644 --- a/tests/test_drafts.py +++ b/tests/test_drafts.py @@ -1,6 +1,6 @@ import pytest -from auth.orm import Author +from orm.author import Author from orm.community import CommunityAuthor from orm.shout import Shout from resolvers.draft import create_draft, load_drafts diff --git a/tests/test_getSession_cookies.py b/tests/test_getSession_cookies.py new file mode 100644 index 00000000..1ef99d6f --- /dev/null +++ b/tests/test_getSession_cookies.py @@ -0,0 +1,276 @@ +""" +Тест для проверки работы getSession с cookies + +Проверяет: +1. getSession работает без токена в заголовке, но с валидным cookie +2. getSession возвращает данные пользователя при валидном cookie +3. getSession возвращает ошибку при невалидном cookie +4. getSession работает с токеном в заголовке +""" + +import pytest +from unittest.mock import patch, MagicMock +from graphql import GraphQLResolveInfo + +from resolvers.auth import get_session +from auth.tokens.storage import TokenStorage as TokenManager +from orm.author import Author + + +class MockRequest: + """Мок для Request объекта""" + + def __init__(self, headers=None, cookies=None): + self.headers = headers or {} + self.cookies = cookies or {} + + +class MockContext: + """Мок для GraphQL контекста""" + + def __init__(self, request=None): + self.request = request + + def get(self, key, default=None): + """Мокаем метод get для совместимости с DRY функциями""" + if key == "request": + return self.request + return default + + +class MockGraphQLResolveInfo: + """Мок для GraphQLResolveInfo""" + + def __init__(self, context): + self.context = context + + +@pytest.fixture +def mock_author(): + """Мок для объекта Author""" + author = MagicMock(spec=Author) + author.id = 123 + author.email = "test@example.com" + author.name = "Test User" + author.slug = "test-user" + author.username = "testuser" + + # Мокаем метод dict() + author.dict.return_value = { + "id": 123, + "email": "test@example.com", + "name": "Test User", + "slug": "test-user", + "username": "testuser" + } + + return author + + +@pytest.fixture +def mock_payload(): + """Мок для payload токена""" + payload = MagicMock() + payload.user_id = "123" + return payload + + +@pytest.mark.asyncio +async def test_getSession_with_valid_cookie(mock_author, mock_payload): + """Тест getSession с валидным cookie""" + + # Мокаем request с cookie + request = MockRequest( + headers={}, + cookies={"session_token": "valid_token_123"} + ) + + context = MockContext(request) + info = MockGraphQLResolveInfo(context) + + # Мокаем DRY функции из auth/utils.py + with patch('resolvers.auth.get_auth_token_from_context') as mock_get_token, \ + patch('resolvers.auth.get_user_data_by_token') as mock_get_user_data: + + mock_get_token.return_value = "valid_token_123" + mock_get_user_data.return_value = (True, mock_author.dict(), None) + + result = await get_session(None, info) + + # Проверяем результат + assert result["success"] is True + assert result["token"] == "valid_token_123" + assert result["author"]["id"] == 123 + assert result["author"]["email"] == "test@example.com" + assert result["error"] is None + + # Проверяем вызовы DRY функций + mock_get_token.assert_called_once_with(info) + mock_get_user_data.assert_called_once_with("valid_token_123") + + +@pytest.mark.asyncio +async def test_getSession_with_authorization_header(mock_author, mock_payload): + """Тест getSession с заголовком Authorization""" + + # Мокаем request с заголовком Authorization + request = MockRequest( + headers={"authorization": "Bearer bearer_token_456"}, + cookies={} + ) + + context = MockContext(request) + info = MockGraphQLResolveInfo(context) + + # Мокаем DRY функции из auth/utils.py + with patch('resolvers.auth.get_auth_token_from_context') as mock_get_token, \ + patch('resolvers.auth.get_user_data_by_token') as mock_get_user_data: + + mock_get_token.return_value = "bearer_token_456" + mock_get_user_data.return_value = (True, mock_author.dict(), None) + + result = await get_session(None, info) + + # Проверяем результат + assert result["success"] is True + assert result["token"] == "bearer_token_456" + assert result["author"]["id"] == 123 + assert result["error"] is None + + # Проверяем вызовы DRY функций + mock_get_token.assert_called_once_with(info) + mock_get_user_data.assert_called_once_with("bearer_token_456") + + +@pytest.mark.asyncio +async def test_getSession_with_invalid_token(mock_author): + """Тест getSession с невалидным токеном""" + + # Мокаем request с невалидным cookie + request = MockRequest( + headers={}, + cookies={"session_token": "invalid_token"} + ) + + context = MockContext(request) + info = MockGraphQLResolveInfo(context) + + # Мокаем DRY функции из auth/utils.py + with patch('resolvers.auth.get_auth_token_from_context') as mock_get_token, \ + patch('resolvers.auth.get_user_data_by_token') as mock_get_user_data: + + mock_get_token.return_value = "invalid_token" + mock_get_user_data.return_value = (False, None, "Сессия не найдена") + + result = await get_session(None, info) + + # Проверяем результат + assert result["success"] is False + assert result["token"] is None + assert result["author"] is None + assert result["error"] == "Сессия не найдена" + + +@pytest.mark.asyncio +async def test_getSession_without_token(): + """Тест getSession без токена""" + + # Мокаем request без токена + request = MockRequest(headers={}, cookies={}) + context = MockContext(request) + info = MockGraphQLResolveInfo(context) + + # Мокаем DRY функции из auth/utils.py + with patch('resolvers.auth.get_auth_token_from_context') as mock_get_token: + mock_get_token.return_value = None + + result = await get_session(None, info) + + # Проверяем результат + assert result["success"] is False + assert result["token"] is None + assert result["author"] is None + assert result["error"] == "Сессия не найдена" + + +@pytest.mark.asyncio +async def test_getSession_without_request(): + """Тест getSession без request в контексте""" + + # Мокаем контекст без request + context = MockContext(request=None) + info = MockGraphQLResolveInfo(context) + + # Мокаем DRY функции из auth/utils.py + with patch('resolvers.auth.get_auth_token_from_context') as mock_get_token: + mock_get_token.return_value = None + + result = await get_session(None, info) + + # Проверяем результат + assert result["success"] is False + assert result["token"] is None + assert result["author"] is None + assert result["error"] == "Сессия не найдена" + + +@pytest.mark.asyncio +async def test_getSession_user_not_found(mock_payload): + """Тест getSession когда пользователь не найден в БД""" + + # Мокаем request с валидным cookie + request = MockRequest( + headers={}, + cookies={"session_token": "valid_token_123"} + ) + + context = MockContext(request) + info = MockGraphQLResolveInfo(context) + + # Мокаем DRY функции из auth/utils.py + with patch('resolvers.auth.get_auth_token_from_context') as mock_get_token, \ + patch('resolvers.auth.get_user_data_by_token') as mock_get_user_data: + + mock_get_token.return_value = "valid_token_123" + mock_get_user_data.return_value = (False, None, f"Пользователь с ID 123 не найден в БД") + + result = await get_session(None, info) + + # Проверяем результат + assert result["success"] is False + assert result["token"] is None + assert result["author"] is None + assert result["error"] == "Пользователь с ID 123 не найден в БД" + + +@pytest.mark.asyncio +async def test_getSession_payload_without_user_id(): + """Тест getSession когда payload не содержит user_id""" + + # Мокаем request с валидным cookie + request = MockRequest( + headers={}, + cookies={"session_token": "valid_token_123"} + ) + + context = MockContext(request) + info = MockGraphQLResolveInfo(context) + + # Мокаем DRY функции из auth/utils.py + with patch('resolvers.auth.get_auth_token_from_context') as mock_get_token, \ + patch('resolvers.auth.get_user_data_by_token') as mock_get_user_data: + + mock_get_token.return_value = "valid_token_123" + mock_get_user_data.return_value = (False, None, "Токен не содержит user_id") + + result = await get_session(None, info) + + # Проверяем результат + assert result["success"] is False + assert result["token"] is None + assert result["author"] is None + assert result["error"] == "Токен не содержит user_id" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_rbac_integration.py b/tests/test_rbac_integration.py index b2181d69..969dd15d 100644 --- a/tests/test_rbac_integration.py +++ b/tests/test_rbac_integration.py @@ -10,7 +10,7 @@ import time from unittest.mock import patch, MagicMock import json -from auth.orm import Author +from orm.author import Author from orm.community import Community, CommunityAuthor from rbac.api import ( initialize_community_permissions, diff --git a/tests/test_rbac_system.py b/tests/test_rbac_system.py index 04d5f448..58c6c854 100644 --- a/tests/test_rbac_system.py +++ b/tests/test_rbac_system.py @@ -8,7 +8,7 @@ import pytest import time from unittest.mock import patch, MagicMock -from auth.orm import Author +from orm.author import Author from orm.community import Community, CommunityAuthor from rbac.api import ( initialize_community_permissions, diff --git a/tests/test_reactions.py b/tests/test_reactions.py index 026f0fba..fc732c5e 100644 --- a/tests/test_reactions.py +++ b/tests/test_reactions.py @@ -2,7 +2,7 @@ from datetime import datetime import pytest -from auth.orm import Author +from orm.author import Author from orm.community import CommunityAuthor from orm.reaction import ReactionKind from orm.shout import Shout diff --git a/tests/test_shouts.py b/tests/test_shouts.py index 0a6eec7e..194373c2 100644 --- a/tests/test_shouts.py +++ b/tests/test_shouts.py @@ -2,7 +2,7 @@ from datetime import datetime import pytest -from auth.orm import Author +from orm.author import Author from orm.community import CommunityAuthor from orm.shout import Shout from resolvers.reader import get_shout diff --git a/tests/test_unpublish_shout.py b/tests/test_unpublish_shout.py index 695fea9b..376d8a9c 100644 --- a/tests/test_unpublish_shout.py +++ b/tests/test_unpublish_shout.py @@ -18,7 +18,7 @@ import pytest sys.path.append(str(Path(__file__).parent)) -from auth.orm import Author +from orm.author import Author from orm.community import assign_role_to_user from orm.shout import Shout from resolvers.editor import unpublish_shout diff --git a/tests/test_update_security.py b/tests/test_update_security.py index f69cccef..c0157c57 100644 --- a/tests/test_update_security.py +++ b/tests/test_update_security.py @@ -16,7 +16,7 @@ from typing import Any sys.path.append(str(Path(__file__).parent)) -from auth.orm import Author +from orm.author import Author from resolvers.auth import update_security from storage.db import local_session diff --git a/utils/generate_slug.py b/utils/generate_slug.py index 006fdf46..16e85fc8 100644 --- a/utils/generate_slug.py +++ b/utils/generate_slug.py @@ -1,7 +1,7 @@ import re from urllib.parse import quote_plus -from auth.orm import Author +from orm.author import Author from storage.db import local_session diff --git a/auth/password.py b/utils/password.py similarity index 100% rename from auth/password.py rename to utils/password.py diff --git a/uv.lock b/uv.lock index 64055f77..5c40469c 100644 --- a/uv.lock +++ b/uv.lock @@ -399,7 +399,7 @@ wheels = [ [[package]] name = "discours-core" -version = "0.9.5" +version = "0.9.7" source = { editable = "." } dependencies = [ { name = "ariadne" },