diff --git a/.gitea/workflows/main.yml b/.gitea/workflows/main.yml index f65ae48a..d856d77d 100644 --- a/.gitea/workflows/main.yml +++ b/.gitea/workflows/main.yml @@ -1,8 +1,42 @@ name: 'Deploy on push' on: [push] + jobs: + type-check: + runs-on: ubuntu-latest + steps: + - name: Cloning repo + uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.12' + + - name: Cache pip packages + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements.dev.txt + pip install mypy types-redis types-requests + + - name: Run type checking with mypy + run: | + echo "🔍 Проверка типобезопасности с mypy..." + mypy . --show-error-codes --no-error-summary --pretty + echo "✅ Все проверки типов прошли успешно!" + deploy: runs-on: ubuntu-latest + needs: type-check steps: - name: Cloning repo uses: actions/checkout@v2 @@ -41,4 +75,4 @@ jobs: branch: 'dev' git_remote_url: 'ssh://dokku@staging.discours.io:22/core' ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }} - git_push_flags: '--force' \ No newline at end of file + git_push_flags: '--force' diff --git a/CHANGELOG.md b/CHANGELOG.md index 491a6636..83496044 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,40 @@ # Changelog -## [Unreleased] +## [0.5.0] ### Добавлено +- **НОВОЕ**: Поддержка дополнительных OAuth провайдеров: + - поддержка vk, telegram, yandex, x + - Обработка провайдеров без email (X, Telegram) - генерация временных email адресов + - Полная документация в `docs/oauth-setup.md` с инструкциями настройки + - Маршруты: `/oauth/x`, `/oauth/telegram`, `/oauth/vk`, `/oauth/yandex` + - Поддержка PKCE для всех провайдеров для дополнительной безопасности - Статистика пользователя (shouts, followers, authors, comments) в ответе метода `getSession` - Интеграция с функцией `get_with_stat` для единого подхода к получению статистики +- **НОВОЕ**: Полная система управления паролями и email через мутацию `updateSecurity`: + - Смена пароля с валидацией сложности и проверкой текущего пароля + - Смена email с двухэтапным подтверждением через токен + - Одновременная смена пароля и email в одной транзакции + - Дополнительные мутации `confirmEmailChange` и `cancelEmailChange` + - **Redis-based токены**: Все токены смены email хранятся в Redis с автоматическим TTL + - **Без миграции БД**: Система не требует изменений схемы базы данных + - Полная документация в `docs/security.md` + - Комплексные тесты в `test_update_security.py` +- **НОВОЕ**: OAuth токены перенесены в Redis: + - Модуль `auth/oauth_tokens.py` для управления OAuth токенами через Redis + - Поддержка access и refresh токенов с автоматическим TTL + - Убраны поля `provider_access_token` и `provider_refresh_token` из модели Author + - Централизованное управление токенами всех OAuth провайдеров (Google, Facebook, GitHub) + - **Внутренняя система истечения Redis**: Использует SET + EXPIRE для точного контроля TTL + - Дополнительные методы: `extend_token_ttl()`, `get_token_info()` для гибкого управления + - Мониторинг оставшегося времени жизни токенов через TTL команды + - Автоматическая очистка истекших токенов + - Улучшенная безопасность и производительность ### Исправлено - **КРИТИЧНО**: Ошибка в функции `unfollow` с некорректным состоянием UI: - **Проблема**: При попытке отписки от несуществующей подписки сервер возвращал ошибку "following was not found" с пустым списком подписок `[]`, что приводило к тому, что клиент не обновлял UI состояние из-за условия `if (result && !result.error)` - - **Решение**: + - **Решение**: - Функция `unfollow` теперь всегда возвращает актуальный список подписок из кэша/БД, даже если подписка не найдена - Добавлена инвалидация кэша подписок после операций follow/unfollow: `author:follows-{entity_type}s:{follower_id}` - Улучшено логирование для отладки операций подписок @@ -51,6 +76,10 @@ - Обновлен `docs/follower.md` с подробным описанием исправлений в follow/unfollow - Добавлены примеры кода и диаграммы потока данных - Документированы все кейсы ошибок и их обработка +- **НОВОЕ**: Мутация `getSession` теперь возвращает email пользователя: + - Используется `access=True` при сериализации данных автора для владельца аккаунта + - Обеспечен доступ к защищенным полям для самого пользователя + - Улучшена безопасность возврата персональных данных #### [0.4.23] - 2025-05-25 @@ -493,4 +522,4 @@ - `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/Dockerfile b/Dockerfile index 24ce4682..8c8914fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,4 +23,4 @@ RUN pip install -r requirements.txt EXPOSE 8000 -CMD ["python", "-m", "granian", "main:app", "--interface", "asgi", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +CMD ["python", "-m", "granian", "main:app", "--interface", "asgi", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 2aeb6403..9149a0f2 100644 --- a/README.md +++ b/README.md @@ -64,10 +64,10 @@ python -m granian main:app --interface asgi ```shell # Linting and import sorting -ruff check . --fix --select I +ruff check . --fix --select I # Code formatting -ruff format . --line-length=120 +ruff format . --line-length=120 # Run tests pytest diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 00000000..1934df65 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,93 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version number format. +version_num_format = %%04d + +# version name format. +version_name_format = %%s + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = sqlite:///discoursio.db + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/auth/__init__.py b/auth/__init__.py index 71f3ac08..8cb42b2f 100644 --- a/auth/__init__.py +++ b/auth/__init__.py @@ -1,5 +1,5 @@ from starlette.requests import Request -from starlette.responses import JSONResponse, RedirectResponse +from starlette.responses import JSONResponse, RedirectResponse, Response from starlette.routing import Route from auth.internal import verify_internal_auth @@ -17,7 +17,7 @@ from settings import ( from utils.logger import root_logger as logger -async def logout(request: Request): +async def logout(request: Request) -> Response: """ Выход из системы с удалением сессии и cookie. @@ -54,10 +54,10 @@ async def logout(request: Request): if token: try: # Декодируем токен для получения user_id - user_id, _ = await verify_internal_auth(token) + user_id, _, _ = await verify_internal_auth(token) if user_id: # Отзываем сессию - await SessionManager.revoke_session(user_id, token) + await SessionManager.revoke_session(str(user_id), token) logger.info(f"[auth] logout: Токен успешно отозван для пользователя {user_id}") else: logger.warning("[auth] logout: Не удалось получить user_id из токена") @@ -81,7 +81,7 @@ async def logout(request: Request): return response -async def refresh_token(request: Request): +async def refresh_token(request: Request) -> JSONResponse: """ Обновление токена аутентификации. @@ -128,7 +128,7 @@ async def refresh_token(request: Request): try: # Получаем информацию о пользователе из токена - user_id, _ = await verify_internal_auth(token) + user_id, _, _ = await verify_internal_auth(token) if not user_id: logger.warning("[auth] refresh_token: Недействительный токен") return JSONResponse({"success": False, "error": "Недействительный токен"}, status_code=401) @@ -142,7 +142,10 @@ async def refresh_token(request: Request): return JSONResponse({"success": False, "error": "Пользователь не найден"}, status_code=404) # Обновляем сессию (создаем новую и отзываем старую) - device_info = {"ip": request.client.host, "user_agent": request.headers.get("user-agent")} + device_info = { + "ip": request.client.host if request.client else "unknown", + "user_agent": request.headers.get("user-agent"), + } new_token = await SessionManager.refresh_session(user_id, token, device_info) if not new_token: diff --git a/auth/credentials.py b/auth/credentials.py index 0391ac5c..3cae0578 100644 --- a/auth/credentials.py +++ b/auth/credentials.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Optional, Set +from typing import Any, Optional from pydantic import BaseModel, Field @@ -25,13 +25,13 @@ class AuthCredentials(BaseModel): """ author_id: Optional[int] = Field(None, description="ID автора") - scopes: Dict[str, Set[str]] = Field(default_factory=dict, description="Разрешения пользователя") + scopes: dict[str, set[str]] = Field(default_factory=dict, description="Разрешения пользователя") logged_in: bool = Field(False, description="Флаг, указывающий, авторизован ли пользователь") error_message: str = Field("", description="Сообщение об ошибке аутентификации") email: Optional[str] = Field(None, description="Email пользователя") token: Optional[str] = Field(None, description="JWT токен авторизации") - def get_permissions(self) -> List[str]: + def get_permissions(self) -> list[str]: """ Возвращает список строковых представлений разрешений. Например: ["posts:read", "posts:write", "comments:create"]. @@ -71,7 +71,7 @@ class AuthCredentials(BaseModel): """ return self.email in ADMIN_EMAILS if self.email else False - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """ Преобразует учетные данные в словарь @@ -85,11 +85,10 @@ class AuthCredentials(BaseModel): "permissions": self.get_permissions(), } - async def permissions(self) -> List[Permission]: + async def permissions(self) -> list[Permission]: if self.author_id is None: # raise Unauthorized("Please login first") - return {"error": "Please login first"} - else: - # TODO: implement permissions logix - print(self.author_id) - return NotImplemented + return [] # Возвращаем пустой список вместо dict + # TODO: implement permissions logix + print(self.author_id) + return [] # Возвращаем пустой список вместо NotImplemented diff --git a/auth/decorators.py b/auth/decorators.py index 47476148..de971da5 100644 --- a/auth/decorators.py +++ b/auth/decorators.py @@ -1,5 +1,6 @@ +from collections.abc import Callable from functools import wraps -from typing import Any, Callable, Dict, Optional +from typing import Any, Optional from graphql import GraphQLError, GraphQLResolveInfo from sqlalchemy import exc @@ -7,12 +8,8 @@ from sqlalchemy import exc from auth.credentials import AuthCredentials from auth.exceptions import OperationNotAllowed from auth.internal import authenticate -from auth.jwtcodec import ExpiredToken, InvalidToken, JWTCodec from auth.orm import Author -from auth.sessions import SessionManager -from auth.tokenstorage import TokenStorage from services.db import local_session -from services.redis import redis from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST from settings import SESSION_COOKIE_NAME, SESSION_TOKEN_HEADER from utils.logger import root_logger as logger @@ -20,7 +17,7 @@ from utils.logger import root_logger as logger ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",") -def get_safe_headers(request: Any) -> Dict[str, str]: +def get_safe_headers(request: Any) -> dict[str, str]: """ Безопасно получает заголовки запроса. @@ -107,10 +104,9 @@ def get_auth_token(request: Any) -> Optional[str]: token = auth_header[7:].strip() logger.debug(f"[decorators] Токен получен из заголовка {SESSION_TOKEN_HEADER}: {len(token)}") return token - else: - token = auth_header.strip() - logger.debug(f"[decorators] Прямой токен получен из заголовка {SESSION_TOKEN_HEADER}: {len(token)}") - return token + token = auth_header.strip() + logger.debug(f"[decorators] Прямой токен получен из заголовка {SESSION_TOKEN_HEADER}: {len(token)}") + return token # Затем проверяем стандартный заголовок Authorization, если основной не определен if SESSION_TOKEN_HEADER.lower() != "authorization": @@ -135,7 +131,7 @@ def get_auth_token(request: Any) -> Optional[str]: return None -async def validate_graphql_context(info: Any) -> None: +async def validate_graphql_context(info: GraphQLResolveInfo) -> None: """ Проверяет валидность GraphQL контекста и проверяет авторизацию. @@ -148,12 +144,14 @@ async def validate_graphql_context(info: Any) -> None: # Проверка базовой структуры контекста if info is None or not hasattr(info, "context"): logger.error("[decorators] Missing GraphQL context information") - raise GraphQLError("Internal server error: missing context") + msg = "Internal server error: missing context" + raise GraphQLError(msg) request = info.context.get("request") if not request: logger.error("[decorators] Missing request in context") - raise GraphQLError("Internal server error: missing request") + msg = "Internal server error: missing request" + raise GraphQLError(msg) # Проверяем auth из контекста - если уже авторизован, просто возвращаем auth = getattr(request, "auth", None) @@ -179,7 +177,8 @@ async def validate_graphql_context(info: Any) -> None: "headers": get_safe_headers(request), } logger.warning(f"[decorators] Токен авторизации не найден: {client_info}") - raise GraphQLError("Unauthorized - please login") + msg = "Unauthorized - please login" + raise GraphQLError(msg) # Используем единый механизм проверки токена из auth.internal auth_state = await authenticate(request) @@ -187,7 +186,8 @@ async def validate_graphql_context(info: Any) -> None: if not auth_state.logged_in: error_msg = auth_state.error or "Invalid or expired token" logger.warning(f"[decorators] Недействительный токен: {error_msg}") - raise GraphQLError(f"Unauthorized - {error_msg}") + msg = f"Unauthorized - {error_msg}" + raise GraphQLError(msg) # Если все проверки пройдены, создаем AuthCredentials и устанавливаем в request.auth with local_session() as session: @@ -198,7 +198,12 @@ async def validate_graphql_context(info: Any) -> None: # Создаем объект авторизации auth_cred = AuthCredentials( - author_id=author.id, scopes=scopes, logged_in=True, email=author.email, token=auth_state.token + author_id=author.id, + scopes=scopes, + logged_in=True, + error_message="", + email=author.email, + token=auth_state.token, ) # Устанавливаем auth в request @@ -206,7 +211,8 @@ async def validate_graphql_context(info: Any) -> None: logger.debug(f"[decorators] Токен успешно проверен и установлен для пользователя {auth_state.author_id}") except exc.NoResultFound: logger.error(f"[decorators] Пользователь с ID {auth_state.author_id} не найден в базе данных") - raise GraphQLError("Unauthorized - user not found") + msg = "Unauthorized - user not found" + raise GraphQLError(msg) return @@ -232,48 +238,59 @@ def admin_auth_required(resolver: Callable) -> Callable: """ @wraps(resolver) - async def wrapper(root: Any = None, info: Any = None, **kwargs): + async def wrapper(root: Any = None, info: Optional[GraphQLResolveInfo] = None, **kwargs): try: # Проверяем авторизацию пользователя + if info is None: + logger.error("[admin_auth_required] GraphQL info is None") + msg = "Invalid GraphQL context" + raise GraphQLError(msg) + await validate_graphql_context(info) + if info: + # Получаем объект авторизации + auth = info.context["request"].auth + if not auth or not auth.logged_in: + logger.error("[admin_auth_required] Пользователь не авторизован после validate_graphql_context") + msg = "Unauthorized - please login" + raise GraphQLError(msg) - # Получаем объект авторизации - auth = info.context["request"].auth - if not auth or not auth.logged_in: - logger.error(f"[admin_auth_required] Пользователь не авторизован после validate_graphql_context") - raise GraphQLError("Unauthorized - please login") + # Проверяем, является ли пользователь администратором + with local_session() as session: + try: + # Преобразуем author_id в int для совместимости с базой данных + author_id = int(auth.author_id) if auth and auth.author_id else None + if not author_id: + logger.error(f"[admin_auth_required] ID автора не определен: {auth}") + msg = "Unauthorized - invalid user ID" + raise GraphQLError(msg) - # Проверяем, является ли пользователь администратором - with local_session() as session: - try: - # Преобразуем author_id в int для совместимости с базой данных - author_id = int(auth.author_id) if auth and auth.author_id else None - if not author_id: - logger.error(f"[admin_auth_required] ID автора не определен: {auth}") - raise GraphQLError("Unauthorized - invalid user ID") + author = session.query(Author).filter(Author.id == author_id).one() - author = session.query(Author).filter(Author.id == author_id).one() + # Проверяем, является ли пользователь администратором + if author.email in ADMIN_EMAILS: + logger.info(f"Admin access granted for {author.email} (ID: {author.id})") + return await resolver(root, info, **kwargs) - # Проверяем, является ли пользователь администратором - if author.email in ADMIN_EMAILS: - logger.info(f"Admin access granted for {author.email} (ID: {author.id})") - return await resolver(root, info, **kwargs) + # Проверяем роли пользователя + admin_roles = ["admin", "super"] + user_roles = [role.id for role in author.roles] if author.roles else [] - # Проверяем роли пользователя - admin_roles = ["admin", "super"] - user_roles = [role.id for role in author.roles] if author.roles else [] + if any(role in admin_roles for role in user_roles): + logger.info( + f"Admin access granted for {author.email} (ID: {author.id}) with role: {user_roles}" + ) + return await resolver(root, info, **kwargs) - if any(role in admin_roles for role in user_roles): - logger.info( - f"Admin access granted for {author.email} (ID: {author.id}) with role: {user_roles}" + logger.warning(f"Admin access denied for {author.email} (ID: {author.id}). Roles: {user_roles}") + msg = "Unauthorized - not an admin" + raise GraphQLError(msg) + except exc.NoResultFound: + logger.error( + f"[admin_auth_required] Пользователь с ID {auth.author_id} не найден в базе данных" ) - return await resolver(root, info, **kwargs) - - logger.warning(f"Admin access denied for {author.email} (ID: {author.id}). Roles: {user_roles}") - raise GraphQLError("Unauthorized - not an admin") - except exc.NoResultFound: - logger.error(f"[admin_auth_required] Пользователь с ID {auth.author_id} не найден в базе данных") - raise GraphQLError("Unauthorized - user not found") + msg = "Unauthorized - user not found" + raise GraphQLError(msg) except Exception as e: error_msg = str(e) @@ -285,18 +302,18 @@ def admin_auth_required(resolver: Callable) -> Callable: return wrapper -def permission_required(resource: str, operation: str, func): +def permission_required(resource: str, operation: str, func: Callable) -> Callable: """ Декоратор для проверки разрешений. Args: - resource (str): Ресурс для проверки - operation (str): Операция для проверки + resource: Ресурс для проверки + operation: Операция для проверки func: Декорируемая функция """ @wraps(func) - async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs): + async def wrap(parent: Any, info: GraphQLResolveInfo, *args: Any, **kwargs: Any) -> Any: # Сначала проверяем авторизацию await validate_graphql_context(info) @@ -304,8 +321,9 @@ def permission_required(resource: str, operation: str, func): logger.debug(f"[permission_required] Контекст: {info.context}") auth = info.context["request"].auth if not auth or not auth.logged_in: - logger.error(f"[permission_required] Пользователь не авторизован после validate_graphql_context") - raise OperationNotAllowed("Требуются права доступа") + logger.error("[permission_required] Пользователь не авторизован после validate_graphql_context") + msg = "Требуются права доступа" + raise OperationNotAllowed(msg) # Проверяем разрешения with local_session() as session: @@ -313,10 +331,9 @@ def permission_required(resource: str, operation: str, func): author = session.query(Author).filter(Author.id == auth.author_id).one() # Проверяем базовые условия - if not author.is_active: - raise OperationNotAllowed("Account is not active") if author.is_locked(): - raise OperationNotAllowed("Account is locked") + msg = "Account is locked" + raise OperationNotAllowed(msg) # Проверяем, является ли пользователь администратором (у них есть все разрешения) if author.email in ADMIN_EMAILS: @@ -338,7 +355,8 @@ def permission_required(resource: str, operation: str, func): logger.warning( f"[permission_required] У пользователя {author.email} нет разрешения {operation} на {resource}" ) - raise OperationNotAllowed(f"No permission for {operation} on {resource}") + msg = f"No permission for {operation} on {resource}" + raise OperationNotAllowed(msg) logger.debug( f"[permission_required] Пользователь {author.email} имеет разрешение {operation} на {resource}" @@ -346,12 +364,13 @@ def permission_required(resource: str, operation: str, func): return await func(parent, info, *args, **kwargs) except exc.NoResultFound: logger.error(f"[permission_required] Пользователь с ID {auth.author_id} не найден в базе данных") - raise OperationNotAllowed("User not found") + msg = "User not found" + raise OperationNotAllowed(msg) return wrap -def login_accepted(func): +def login_accepted(func: Callable) -> Callable: """ Декоратор для резолверов, которые могут работать как с авторизованными, так и с неавторизованными пользователями. @@ -363,7 +382,7 @@ def login_accepted(func): """ @wraps(func) - async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs): + async def wrap(parent: Any, info: GraphQLResolveInfo, *args: Any, **kwargs: Any) -> Any: try: # Пробуем проверить авторизацию, но не выбрасываем исключение, если пользователь не авторизован try: diff --git a/auth/email.py b/auth/email.py index a42cf1f7..a44f4899 100644 --- a/auth/email.py +++ b/auth/email.py @@ -1,3 +1,5 @@ +from typing import Any + import requests from settings import MAILGUN_API_KEY, MAILGUN_DOMAIN @@ -7,9 +9,9 @@ noreply = "discours.io " % (MAILGUN_DOMAIN or "discours.io") lang_subject = {"ru": "Подтверждение почты", "en": "Confirm email"} -async def send_auth_email(user, token, lang="ru", template="email_confirmation"): +async def send_auth_email(user: Any, token: str, lang: str = "ru", template: str = "email_confirmation") -> None: try: - to = "%s <%s>" % (user.name, user.email) + to = f"{user.name} <{user.email}>" if lang not in ["ru", "en"]: lang = "ru" subject = lang_subject.get(lang, lang_subject["en"]) @@ -19,12 +21,12 @@ async def send_auth_email(user, token, lang="ru", template="email_confirmation") "to": to, "subject": subject, "template": template, - "h:X-Mailgun-Variables": '{ "token": "%s" }' % token, + "h:X-Mailgun-Variables": f'{{ "token": "{token}" }}', } - print("[auth.email] payload: %r" % payload) + print(f"[auth.email] payload: {payload!r}") # debug # print('http://localhost:3000/?modal=auth&mode=confirm-email&token=%s' % token) - response = requests.post(api_url, auth=("api", MAILGUN_API_KEY), data=payload) + response = requests.post(api_url, auth=("api", MAILGUN_API_KEY), data=payload, timeout=30) response.raise_for_status() except Exception as e: print(e) diff --git a/auth/handler.py b/auth/handler.py index f2677ab3..0e0edb0a 100644 --- a/auth/handler.py +++ b/auth/handler.py @@ -1,6 +1,6 @@ from ariadne.asgi.handlers import GraphQLHTTPHandler from starlette.requests import Request -from starlette.responses import JSONResponse, Response +from starlette.responses import JSONResponse from auth.middleware import auth_middleware from utils.logger import root_logger as logger @@ -51,6 +51,6 @@ class EnhancedGraphQLHTTPHandler(GraphQLHTTPHandler): # Безопасно логируем информацию о типе объекта auth logger.debug(f"[graphql] Добавлены данные авторизации в контекст: {type(request.auth).__name__}") - logger.debug(f"[graphql] Подготовлен расширенный контекст для запроса") + logger.debug("[graphql] Подготовлен расширенный контекст для запроса") return context diff --git a/auth/identity.py b/auth/identity.py index be32fbb2..8aab77ef 100644 --- a/auth/identity.py +++ b/auth/identity.py @@ -1,6 +1,6 @@ from binascii import hexlify from hashlib import sha256 -from typing import TYPE_CHECKING, Any, Dict, TypeVar +from typing import TYPE_CHECKING, Any, TypeVar from passlib.hash import bcrypt @@ -8,6 +8,7 @@ from auth.exceptions import ExpiredToken, InvalidPassword, InvalidToken from auth.jwtcodec import JWTCodec from auth.tokenstorage import TokenStorage from services.db import local_session +from utils.logger import root_logger as logger # Для типизации if TYPE_CHECKING: @@ -42,11 +43,11 @@ class Password: @staticmethod def verify(password: str, hashed: str) -> bool: - """ + r""" Verify that password hash is equal to specified hash. Hash format: $2a$10$Ro0CUfOqk6cXEKf3dyaM7OhSCvnwM9s4wIX9JeLapehKK5YdLxKcm - \__/\/ \____________________/\_____________________________/ # noqa: W605 + \__/\/ \____________________/\_____________________________/ | | Salt Hash | Cost Version @@ -65,7 +66,7 @@ class Password: class Identity: @staticmethod - def password(orm_author: Any, password: str) -> Any: + def password(orm_author: AuthorType, password: str) -> AuthorType: """ Проверяет пароль пользователя @@ -80,24 +81,26 @@ class Identity: InvalidPassword: Если пароль не соответствует хешу или отсутствует """ # Импортируем внутри функции для избежания циклических импортов - from auth.orm import Author from utils.logger import root_logger as logger # Проверим исходный пароль в orm_author if not orm_author.password: logger.warning(f"[auth.identity] Пароль в исходном объекте автора пуст: email={orm_author.email}") - raise InvalidPassword("Пароль не установлен для данного пользователя") + msg = "Пароль не установлен для данного пользователя" + raise InvalidPassword(msg) # Проверяем пароль напрямую, не используя dict() - if not Password.verify(password, orm_author.password): + password_hash = str(orm_author.password) if orm_author.password else "" + if not password_hash or not Password.verify(password, password_hash): logger.warning(f"[auth.identity] Неверный пароль для {orm_author.email}") - raise InvalidPassword("Неверный пароль пользователя") + msg = "Неверный пароль пользователя" + raise InvalidPassword(msg) # Возвращаем исходный объект, чтобы сохранить все связи return orm_author @staticmethod - def oauth(inp: Dict[str, Any]) -> Any: + def oauth(inp: dict[str, Any]) -> Any: """ Создает нового пользователя OAuth, если он не существует @@ -114,7 +117,7 @@ class Identity: author = session.query(Author).filter(Author.email == inp["email"]).first() if not author: author = Author(**inp) - author.email_verified = True + author.email_verified = True # type: ignore[assignment] session.add(author) session.commit() @@ -137,21 +140,29 @@ class Identity: try: print("[auth.identity] using one time token") payload = JWTCodec.decode(token) - if not await TokenStorage.exist(f"{payload.user_id}-{payload.username}-{token}"): - # raise InvalidToken("Login token has expired, please login again") - return {"error": "Token has expired"} + if payload is None: + logger.warning("[Identity.token] Токен не валиден (payload is None)") + return {"error": "Invalid token"} + + # Проверяем существование токена в хранилище + token_key = f"{payload.user_id}-{payload.username}-{token}" + token_storage = TokenStorage() + if not await token_storage.exists(token_key): + logger.warning(f"[Identity.token] Токен не найден в хранилище: {token_key}") + return {"error": "Token not found"} + + # Если все проверки пройдены, ищем автора в базе данных + with local_session() as session: + author = session.query(Author).filter_by(id=payload.user_id).first() + if not author: + logger.warning(f"[Identity.token] Автор с ID {payload.user_id} не найден") + return {"error": "User not found"} + + logger.info(f"[Identity.token] Токен валиден для автора {author.id}") + return author except ExpiredToken: # raise InvalidToken("Login token has expired, please try again") return {"error": "Token has expired"} except InvalidToken: # raise InvalidToken("token format error") from e return {"error": "Token format error"} - with local_session() as session: - author = session.query(Author).filter_by(id=payload.user_id).first() - if not author: - # raise Exception("user not exist") - return {"error": "Author does not exist"} - if not author.email_verified: - author.email_verified = True - session.commit() - return author diff --git a/auth/internal.py b/auth/internal.py index cdd130a5..79ebc64f 100644 --- a/auth/internal.py +++ b/auth/internal.py @@ -4,7 +4,7 @@ """ import time -from typing import Any, Optional, Tuple +from typing import Any, Optional from sqlalchemy.orm import exc @@ -20,7 +20,7 @@ from utils.logger import root_logger as logger ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",") -async def verify_internal_auth(token: str) -> Tuple[str, list, bool]: +async def verify_internal_auth(token: str) -> tuple[int, list, bool]: """ Проверяет локальную авторизацию. Возвращает user_id, список ролей и флаг администратора. @@ -41,18 +41,13 @@ async def verify_internal_auth(token: str) -> Tuple[str, list, bool]: payload = await SessionManager.verify_session(token) if not payload: logger.warning("[verify_internal_auth] Недействительный токен: payload не получен") - return "", [], False + return 0, [], False logger.debug(f"[verify_internal_auth] Токен действителен, user_id={payload.user_id}") with local_session() as session: try: - author = ( - session.query(Author) - .filter(Author.id == payload.user_id) - .filter(Author.is_active == True) # noqa - .one() - ) + author = session.query(Author).filter(Author.id == payload.user_id).one() # Получаем роли roles = [role.id for role in author.roles] @@ -64,10 +59,10 @@ async def verify_internal_auth(token: str) -> Tuple[str, list, bool]: f"[verify_internal_auth] Пользователь {author.id} {'является' if is_admin else 'не является'} администратором" ) - return str(author.id), roles, is_admin + return int(author.id), roles, is_admin except exc.NoResultFound: logger.warning(f"[verify_internal_auth] Пользователь с ID {payload.user_id} не найден в БД или не активен") - return "", [], False + return 0, [], False async def create_internal_session(author: Author, device_info: Optional[dict] = None) -> str: @@ -85,12 +80,12 @@ async def create_internal_session(author: Author, device_info: Optional[dict] = author.reset_failed_login() # Обновляем last_seen - author.last_seen = int(time.time()) + author.last_seen = int(time.time()) # type: ignore[assignment] # Создаем сессию, используя token для идентификации return await SessionManager.create_session( user_id=str(author.id), - username=author.slug or author.email or author.phone or "", + username=str(author.slug or author.email or author.phone or ""), device_info=device_info, ) @@ -124,10 +119,7 @@ async def authenticate(request: Any) -> AuthState: try: headers = {} if hasattr(request, "headers"): - if callable(request.headers): - headers = dict(request.headers()) - else: - headers = dict(request.headers) + headers = dict(request.headers()) if callable(request.headers) else dict(request.headers) auth_header = headers.get(SESSION_TOKEN_HEADER, "") if auth_header and auth_header.startswith("Bearer "): @@ -153,7 +145,7 @@ async def authenticate(request: Any) -> AuthState: # Проверяем токен через SessionManager, который теперь совместим с TokenStorage payload = await SessionManager.verify_session(token) if not payload: - logger.warning(f"[auth.authenticate] Токен не валиден: не найдена сессия") + logger.warning("[auth.authenticate] Токен не валиден: не найдена сессия") state.error = "Invalid or expired token" return state @@ -175,11 +167,16 @@ async def authenticate(request: Any) -> AuthState: # Создаем объект авторизации auth_cred = AuthCredentials( - author_id=author.id, scopes=scopes, logged_in=True, email=author.email, token=token + author_id=author.id, + scopes=scopes, + logged_in=True, + email=author.email, + token=token, + error_message="", ) # Устанавливаем auth в request - setattr(request, "auth", auth_cred) + request.auth = auth_cred logger.debug( f"[auth.authenticate] Авторизационные данные установлены в request.auth для {payload.user_id}" ) diff --git a/auth/jwtcodec.py b/auth/jwtcodec.py index abca8bf7..4f25ebd2 100644 --- a/auth/jwtcodec.py +++ b/auth/jwtcodec.py @@ -1,10 +1,9 @@ from datetime import datetime, timedelta, timezone -from typing import Optional +from typing import Any, Optional, Union import jwt from pydantic import BaseModel -from auth.exceptions import ExpiredToken, InvalidToken from settings import JWT_ALGORITHM, JWT_SECRET_KEY from utils.logger import root_logger as logger @@ -19,7 +18,7 @@ class TokenPayload(BaseModel): class JWTCodec: @staticmethod - def encode(user, exp: Optional[datetime] = None) -> str: + def encode(user: Union[dict[str, Any], Any], exp: Optional[datetime] = None) -> str: # Поддержка как объектов, так и словарей if isinstance(user, dict): # В SessionManager.create_session передается словарь {"id": user_id, "email": username} @@ -59,13 +58,16 @@ class JWTCodec: try: token = jwt.encode(payload, JWT_SECRET_KEY, JWT_ALGORITHM) logger.debug(f"[JWTCodec.encode] Токен успешно создан, длина: {len(token) if token else 0}") - return token + # Ensure we always return str, not bytes + if isinstance(token, bytes): + return token.decode("utf-8") + return str(token) except Exception as e: logger.error(f"[JWTCodec.encode] Ошибка при кодировании JWT: {e}") raise @staticmethod - def decode(token: str, verify_exp: bool = True): + def decode(token: str, verify_exp: bool = True) -> Optional[TokenPayload]: logger.debug(f"[JWTCodec.decode] Начало декодирования токена длиной {len(token) if token else 0}") if not token: @@ -87,7 +89,7 @@ class JWTCodec: # Убедимся, что exp существует (добавим обработку если exp отсутствует) if "exp" not in payload: - logger.warning(f"[JWTCodec.decode] В токене отсутствует поле exp") + logger.warning("[JWTCodec.decode] В токене отсутствует поле exp") # Добавим exp по умолчанию, чтобы избежать ошибки при создании TokenPayload payload["exp"] = int((datetime.now(tz=timezone.utc) + timedelta(days=30)).timestamp()) diff --git a/auth/middleware.py b/auth/middleware.py index e5335dc6..bf3150ba 100644 --- a/auth/middleware.py +++ b/auth/middleware.py @@ -3,14 +3,16 @@ """ import time -from typing import Any, Dict +from collections.abc import Awaitable, MutableMapping +from typing import Any, Callable, Optional +from graphql import GraphQLResolveInfo from sqlalchemy.orm import exc from starlette.authentication import UnauthenticatedUser from starlette.datastructures import Headers from starlette.requests import Request from starlette.responses import JSONResponse, Response -from starlette.types import ASGIApp, Receive, Scope, Send +from starlette.types import ASGIApp from auth.credentials import AuthCredentials from auth.orm import Author @@ -36,8 +38,13 @@ class AuthenticatedUser: """Аутентифицированный пользователь""" def __init__( - self, user_id: str, username: str = "", roles: list = None, permissions: dict = None, token: str = None - ): + self, + user_id: str, + username: str = "", + roles: Optional[list] = None, + permissions: Optional[dict] = None, + token: Optional[str] = None, + ) -> None: self.user_id = user_id self.username = username self.roles = roles or [] @@ -68,33 +75,39 @@ class AuthMiddleware: 4. Предоставление методов для установки/удаления cookies """ - def __init__(self, app: ASGIApp): + def __init__(self, app: ASGIApp) -> None: self.app = app self._context = None - async def authenticate_user(self, token: str): + async def authenticate_user(self, token: str) -> tuple[AuthCredentials, AuthenticatedUser | UnauthenticatedUser]: """Аутентифицирует пользователя по токену""" if not token: - return AuthCredentials(scopes={}, error_message="no token"), UnauthenticatedUser() + return AuthCredentials( + author_id=None, scopes={}, logged_in=False, error_message="no token", email=None, token=None + ), UnauthenticatedUser() # Проверяем сессию в Redis payload = await SessionManager.verify_session(token) if not payload: logger.debug("[auth.authenticate] Недействительный токен") - return AuthCredentials(scopes={}, error_message="Invalid token"), UnauthenticatedUser() + return AuthCredentials( + author_id=None, scopes={}, logged_in=False, error_message="Invalid token", email=None, token=None + ), UnauthenticatedUser() with local_session() as session: try: - author = ( - session.query(Author) - .filter(Author.id == payload.user_id) - .filter(Author.is_active == True) # noqa - .one() - ) + author = session.query(Author).filter(Author.id == payload.user_id).one() if author.is_locked(): logger.debug(f"[auth.authenticate] Аккаунт заблокирован: {author.id}") - return AuthCredentials(scopes={}, error_message="Account is locked"), UnauthenticatedUser() + return AuthCredentials( + author_id=None, + scopes={}, + logged_in=False, + error_message="Account is locked", + email=None, + token=None, + ), UnauthenticatedUser() # Получаем разрешения из ролей scopes = author.get_permissions() @@ -108,7 +121,12 @@ class AuthMiddleware: # Создаем объекты авторизации с сохранением токена credentials = AuthCredentials( - author_id=author.id, scopes=scopes, logged_in=True, email=author.email, token=token + author_id=author.id, + scopes=scopes, + logged_in=True, + error_message="", + email=author.email, + token=token, ) user = AuthenticatedUser( @@ -124,9 +142,16 @@ class AuthMiddleware: except exc.NoResultFound: logger.debug("[auth.authenticate] Пользователь не найден") - return AuthCredentials(scopes={}, error_message="User not found"), UnauthenticatedUser() + return AuthCredentials( + author_id=None, scopes={}, logged_in=False, error_message="User not found", email=None, token=None + ), UnauthenticatedUser() - async def __call__(self, scope: Scope, receive: Receive, send: Send): + async def __call__( + self, + scope: MutableMapping[str, Any], + receive: Callable[[], Awaitable[MutableMapping[str, Any]]], + send: Callable[[MutableMapping[str, Any]], Awaitable[None]], + ) -> None: """Обработка ASGI запроса""" if scope["type"] != "http": await self.app(scope, receive, send) @@ -135,21 +160,18 @@ class AuthMiddleware: # Извлекаем заголовки headers = Headers(scope=scope) token = None - token_source = None # Сначала пробуем получить токен из заголовка авторизации auth_header = headers.get(SESSION_TOKEN_HEADER) if auth_header: if auth_header.startswith("Bearer "): token = auth_header.replace("Bearer ", "", 1).strip() - token_source = "header" logger.debug( f"[middleware] Извлечен Bearer токен из заголовка {SESSION_TOKEN_HEADER}, длина: {len(token) if token else 0}" ) else: # Если заголовок не начинается с Bearer, предполагаем, что это чистый токен token = auth_header.strip() - token_source = "header" logger.debug( f"[middleware] Извлечен прямой токен из заголовка {SESSION_TOKEN_HEADER}, длина: {len(token) if token else 0}" ) @@ -159,7 +181,6 @@ class AuthMiddleware: auth_header = headers.get("Authorization") if auth_header and auth_header.startswith("Bearer "): token = auth_header.replace("Bearer ", "", 1).strip() - token_source = "auth_header" logger.debug( f"[middleware] Извлечен Bearer токен из заголовка Authorization, длина: {len(token) if token else 0}" ) @@ -173,14 +194,13 @@ class AuthMiddleware: name, value = item.split("=", 1) if name.strip() == SESSION_COOKIE_NAME: token = value.strip() - token_source = "cookie" logger.debug( f"[middleware] Извлечен токен из cookie {SESSION_COOKIE_NAME}, длина: {len(token) if token else 0}" ) break # Аутентифицируем пользователя - auth, user = await self.authenticate_user(token) + auth, user = await self.authenticate_user(token or "") # Добавляем в scope данные авторизации и пользователя scope["auth"] = auth @@ -188,25 +208,29 @@ class AuthMiddleware: if token: # Обновляем заголовки в scope для совместимости - new_headers = [] + new_headers: list[tuple[bytes, bytes]] = [] for name, value in scope["headers"]: - if name.decode("latin1").lower() != SESSION_TOKEN_HEADER.lower(): - new_headers.append((name, value)) + header_name = name.decode("latin1") if isinstance(name, bytes) else str(name) + if header_name.lower() != SESSION_TOKEN_HEADER.lower(): + # Ensure both name and value are bytes + name_bytes = name if isinstance(name, bytes) else str(name).encode("latin1") + value_bytes = value if isinstance(value, bytes) else str(value).encode("latin1") + new_headers.append((name_bytes, value_bytes)) new_headers.append((SESSION_TOKEN_HEADER.encode("latin1"), token.encode("latin1"))) scope["headers"] = new_headers logger.debug(f"[middleware] Пользователь аутентифицирован: {user.is_authenticated}") else: - logger.debug(f"[middleware] Токен не найден, пользователь неаутентифицирован") + logger.debug("[middleware] Токен не найден, пользователь неаутентифицирован") await self.app(scope, receive, send) - def set_context(self, context): + def set_context(self, context) -> None: """Сохраняет ссылку на контекст GraphQL запроса""" self._context = context logger.debug(f"[middleware] Установлен контекст GraphQL: {bool(context)}") - def set_cookie(self, key, value, **options): + def set_cookie(self, key, value, **options) -> None: """ Устанавливает cookie в ответе @@ -224,7 +248,7 @@ class AuthMiddleware: logger.debug(f"[middleware] Установлена cookie {key} через response") success = True except Exception as e: - logger.error(f"[middleware] Ошибка при установке cookie {key} через response: {str(e)}") + logger.error(f"[middleware] Ошибка при установке cookie {key} через response: {e!s}") # Способ 2: Через собственный response в контексте if not success and hasattr(self, "_response") and self._response and hasattr(self._response, "set_cookie"): @@ -233,12 +257,12 @@ class AuthMiddleware: logger.debug(f"[middleware] Установлена cookie {key} через _response") success = True except Exception as e: - logger.error(f"[middleware] Ошибка при установке cookie {key} через _response: {str(e)}") + logger.error(f"[middleware] Ошибка при установке cookie {key} через _response: {e!s}") if not success: logger.error(f"[middleware] Не удалось установить cookie {key}: объекты response недоступны") - def delete_cookie(self, key, **options): + def delete_cookie(self, key, **options) -> None: """ Удаляет cookie из ответа @@ -255,7 +279,7 @@ class AuthMiddleware: logger.debug(f"[middleware] Удалена cookie {key} через response") success = True except Exception as e: - logger.error(f"[middleware] Ошибка при удалении cookie {key} через response: {str(e)}") + logger.error(f"[middleware] Ошибка при удалении cookie {key} через response: {e!s}") # Способ 2: Через собственный response в контексте if not success and hasattr(self, "_response") and self._response and hasattr(self._response, "delete_cookie"): @@ -264,12 +288,14 @@ class AuthMiddleware: logger.debug(f"[middleware] Удалена cookie {key} через _response") success = True except Exception as e: - logger.error(f"[middleware] Ошибка при удалении cookie {key} через _response: {str(e)}") + logger.error(f"[middleware] Ошибка при удалении cookie {key} через _response: {e!s}") if not success: logger.error(f"[middleware] Не удалось удалить cookie {key}: объекты response недоступны") - async def resolve(self, next, root, info, *args, **kwargs): + async def resolve( + self, next: Callable[..., Any], root: Any, info: GraphQLResolveInfo, *args: Any, **kwargs: Any + ) -> Any: """ Middleware для обработки запросов GraphQL. Добавляет методы для установки cookie в контекст. @@ -291,13 +317,11 @@ class AuthMiddleware: context["response"] = JSONResponse({}) logger.debug("[middleware] Создан новый response объект в контексте GraphQL") - logger.debug( - f"[middleware] GraphQL resolve: контекст подготовлен, добавлены расширения для работы с cookie" - ) + logger.debug("[middleware] GraphQL resolve: контекст подготовлен, добавлены расширения для работы с cookie") return await next(root, info, *args, **kwargs) except Exception as e: - logger.error(f"[AuthMiddleware] Ошибка в GraphQL resolve: {str(e)}") + logger.error(f"[AuthMiddleware] Ошибка в GraphQL resolve: {e!s}") raise async def process_result(self, request: Request, result: Any) -> Response: @@ -321,9 +345,14 @@ class AuthMiddleware: try: import json - result_data = json.loads(result.body.decode("utf-8")) + body_content = result.body + if isinstance(body_content, (bytes, memoryview)): + body_text = bytes(body_content).decode("utf-8") + result_data = json.loads(body_text) + else: + result_data = json.loads(str(body_content)) except Exception as e: - logger.error(f"[process_result] Не удалось извлечь данные из JSONResponse: {str(e)}") + logger.error(f"[process_result] Не удалось извлечь данные из JSONResponse: {e!s}") else: response = JSONResponse(result) result_data = result @@ -369,10 +398,18 @@ class AuthMiddleware: ) logger.debug(f"[graphql_handler] Удалена cookie {SESSION_COOKIE_NAME} для операции {op_name}") except Exception as e: - logger.error(f"[process_result] Ошибка при обработке POST запроса: {str(e)}") + logger.error(f"[process_result] Ошибка при обработке POST запроса: {e!s}") return response # Создаем единый экземпляр AuthMiddleware для использования с GraphQL -auth_middleware = AuthMiddleware(lambda scope, receive, send: None) +async def _dummy_app( + scope: MutableMapping[str, Any], + receive: Callable[[], Awaitable[MutableMapping[str, Any]]], + send: Callable[[MutableMapping[str, Any]], Awaitable[None]], +) -> None: + """Dummy ASGI app for middleware initialization""" + + +auth_middleware = AuthMiddleware(_dummy_app) diff --git a/auth/oauth.py b/auth/oauth.py index c627663a..cc6a3d99 100644 --- a/auth/oauth.py +++ b/auth/oauth.py @@ -1,9 +1,12 @@ import time from secrets import token_urlsafe +from typing import Any, Optional import orjson from authlib.integrations.starlette_client import OAuth from authlib.oauth2.rfc7636 import create_s256_code_challenge +from graphql import GraphQLResolveInfo +from starlette.requests import Request from starlette.responses import JSONResponse, RedirectResponse from auth.orm import Author @@ -40,17 +43,106 @@ PROVIDERS = { "api_base_url": "https://graph.facebook.com/", "client_kwargs": {"scope": "public_profile email"}, }, + "x": { + "name": "x", + "access_token_url": "https://api.twitter.com/2/oauth2/token", + "authorize_url": "https://twitter.com/i/oauth2/authorize", + "api_base_url": "https://api.twitter.com/2/", + "client_kwargs": {"scope": "tweet.read users.read offline.access"}, + }, + "telegram": { + "name": "telegram", + "authorize_url": "https://oauth.telegram.org/auth", + "api_base_url": "https://api.telegram.org/", + "client_kwargs": {"scope": "user:read"}, + }, + "vk": { + "name": "vk", + "access_token_url": "https://oauth.vk.com/access_token", + "authorize_url": "https://oauth.vk.com/authorize", + "api_base_url": "https://api.vk.com/method/", + "client_kwargs": {"scope": "email", "v": "5.131"}, + }, + "yandex": { + "name": "yandex", + "access_token_url": "https://oauth.yandex.ru/token", + "authorize_url": "https://oauth.yandex.ru/authorize", + "api_base_url": "https://login.yandex.ru/info", + "client_kwargs": {"scope": "login:email login:info"}, + }, } # Регистрация провайдеров for provider, config in PROVIDERS.items(): - if provider in OAUTH_CLIENTS: - oauth.register( - name=config["name"], - client_id=OAUTH_CLIENTS[provider.upper()]["id"], - client_secret=OAUTH_CLIENTS[provider.upper()]["key"], - **config, - ) + if provider in OAUTH_CLIENTS and OAUTH_CLIENTS[provider.upper()]: + client_config = OAUTH_CLIENTS[provider.upper()] + if "id" in client_config and "key" in client_config: + try: + # Регистрируем провайдеров вручную для избежания проблем типизации + if provider == "google": + oauth.register( + name="google", + client_id=client_config["id"], + client_secret=client_config["key"], + server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", + ) + elif provider == "github": + oauth.register( + name="github", + client_id=client_config["id"], + client_secret=client_config["key"], + access_token_url="https://github.com/login/oauth/access_token", + authorize_url="https://github.com/login/oauth/authorize", + api_base_url="https://api.github.com/", + ) + elif provider == "facebook": + oauth.register( + name="facebook", + client_id=client_config["id"], + client_secret=client_config["key"], + access_token_url="https://graph.facebook.com/v13.0/oauth/access_token", + authorize_url="https://www.facebook.com/v13.0/dialog/oauth", + api_base_url="https://graph.facebook.com/", + ) + elif provider == "x": + oauth.register( + name="x", + client_id=client_config["id"], + client_secret=client_config["key"], + access_token_url="https://api.twitter.com/2/oauth2/token", + authorize_url="https://twitter.com/i/oauth2/authorize", + api_base_url="https://api.twitter.com/2/", + ) + elif provider == "telegram": + oauth.register( + name="telegram", + client_id=client_config["id"], + client_secret=client_config["key"], + authorize_url="https://oauth.telegram.org/auth", + api_base_url="https://api.telegram.org/", + ) + elif provider == "vk": + oauth.register( + name="vk", + client_id=client_config["id"], + client_secret=client_config["key"], + access_token_url="https://oauth.vk.com/access_token", + authorize_url="https://oauth.vk.com/authorize", + api_base_url="https://api.vk.com/method/", + ) + elif provider == "yandex": + oauth.register( + name="yandex", + client_id=client_config["id"], + client_secret=client_config["key"], + access_token_url="https://oauth.yandex.ru/token", + authorize_url="https://oauth.yandex.ru/authorize", + api_base_url="https://login.yandex.ru/info", + ) + logger.info(f"OAuth provider {provider} registered successfully") + except Exception as e: + logger.error(f"Failed to register OAuth provider {provider}: {e}") + continue async def get_user_profile(provider: str, client, token) -> dict: @@ -63,7 +155,7 @@ async def get_user_profile(provider: str, client, token) -> dict: "name": userinfo.get("name"), "picture": userinfo.get("picture", "").replace("=s96", "=s600"), } - elif provider == "github": + if provider == "github": profile = await client.get("user", token=token) profile_data = profile.json() emails = await client.get("user/emails", token=token) @@ -75,7 +167,7 @@ async def get_user_profile(provider: str, client, token) -> dict: "name": profile_data.get("name") or profile_data.get("login"), "picture": profile_data.get("avatar_url"), } - elif provider == "facebook": + if provider == "facebook": profile = await client.get("me?fields=id,name,email,picture.width(600)", token=token) profile_data = profile.json() return { @@ -84,12 +176,65 @@ async def get_user_profile(provider: str, client, token) -> dict: "name": profile_data.get("name"), "picture": profile_data.get("picture", {}).get("data", {}).get("url"), } + if provider == "x": + # Twitter/X API v2 + profile = await client.get("users/me?user.fields=id,name,username,profile_image_url", token=token) + profile_data = profile.json() + user_data = profile_data.get("data", {}) + return { + "id": user_data.get("id"), + "email": None, # X не предоставляет email через API + "name": user_data.get("name") or user_data.get("username"), + "picture": user_data.get("profile_image_url", "").replace("_normal", "_400x400"), + } + if provider == "telegram": + # Telegram OAuth (через Telegram Login Widget) + # Данные обычно приходят в token параметрах + return { + "id": str(token.get("id", "")), + "email": None, # Telegram не предоставляет email + "phone": str(token.get("phone_number", "")), + "name": token.get("first_name", "") + " " + token.get("last_name", ""), + "picture": token.get("photo_url"), + } + if provider == "vk": + # VK API + profile = await client.get("users.get?fields=photo_400_orig,contacts&v=5.131", token=token) + profile_data = profile.json() + if profile_data.get("response"): + user_data = profile_data["response"][0] + return { + "id": str(user_data["id"]), + "email": user_data.get("contacts", {}).get("email"), + "name": f"{user_data.get('first_name', '')} {user_data.get('last_name', '')}".strip(), + "picture": user_data.get("photo_400_orig"), + } + if provider == "yandex": + # Yandex API + profile = await client.get("?format=json", token=token) + profile_data = profile.json() + return { + "id": profile_data.get("id"), + "email": profile_data.get("default_email"), + "name": profile_data.get("display_name") or profile_data.get("real_name"), + "picture": f"https://avatars.yandex.net/get-yapic/{profile_data.get('default_avatar_id')}/islands-200" + if profile_data.get("default_avatar_id") + else None, + } return {} -async def oauth_login(request): - """Начинает процесс OAuth авторизации""" - provider = request.path_params["provider"] +async def oauth_login(_: None, _info: GraphQLResolveInfo, provider: str, callback_data: dict[str, Any]) -> JSONResponse: + """ + Обработка OAuth авторизации + + Args: + provider: Провайдер OAuth (google, github, etc.) + callback_data: Данные из callback-а + + Returns: + dict: Результат авторизации с токеном или ошибкой + """ if provider not in PROVIDERS: return JSONResponse({"error": "Invalid provider"}, status_code=400) @@ -98,8 +243,8 @@ async def oauth_login(request): return JSONResponse({"error": "Provider not configured"}, status_code=400) # Получаем параметры из query string - state = request.query_params.get("state") - redirect_uri = request.query_params.get("redirect_uri", FRONTEND_URL) + state = callback_data.get("state") + redirect_uri = callback_data.get("redirect_uri", FRONTEND_URL) if not state: return JSONResponse({"error": "State parameter is required"}, status_code=400) @@ -118,18 +263,18 @@ async def oauth_login(request): await store_oauth_state(state, oauth_data) # Используем URL из фронтенда для callback - oauth_callback_uri = f"{request.base_url}oauth/{provider}/callback" + oauth_callback_uri = f"{callback_data['base_url']}oauth/{provider}/callback" try: return await client.authorize_redirect( - request, + callback_data["request"], oauth_callback_uri, code_challenge=code_challenge, code_challenge_method="S256", state=state, ) except Exception as e: - logger.error(f"OAuth redirect error for {provider}: {str(e)}") + logger.error(f"OAuth redirect error for {provider}: {e!s}") return JSONResponse({"error": str(e)}, status_code=500) @@ -162,41 +307,73 @@ async def oauth_callback(request): # Получаем профиль пользователя profile = await get_user_profile(provider, client, token) - if not profile.get("email"): - return JSONResponse({"error": "Email not provided"}, status_code=400) + + # Для некоторых провайдеров (X, Telegram) email может отсутствовать + email = profile.get("email") + if not email: + # Генерируем временный email на основе провайдера и ID + email = f"{provider}_{profile.get('id', 'unknown')}@oauth.local" + logger.info(f"Generated temporary email for {provider} user: {email}") # Создаем или обновляем пользователя with local_session() as session: - author = session.query(Author).filter(Author.email == profile["email"]).first() + # Сначала ищем пользователя по OAuth + author = Author.find_by_oauth(provider, profile["id"], session) - if not author: - # Генерируем slug из имени или email - slug = generate_unique_slug(profile["name"] or profile["email"].split("@")[0]) + if author: + # Пользователь найден по OAuth - обновляем данные + author.set_oauth_account(provider, profile["id"], email=profile.get("email")) + + # Обновляем основные данные автора если они пустые + if profile.get("name") and not author.name: + author.name = profile["name"] # type: ignore[assignment] + if profile.get("picture") and not author.pic: + author.pic = profile["picture"] # type: ignore[assignment] + author.updated_at = int(time.time()) # type: ignore[assignment] + author.last_seen = int(time.time()) # type: ignore[assignment] - author = Author( - email=profile["email"], - name=profile["name"], - slug=slug, - pic=profile.get("picture"), - oauth=f"{provider}:{profile['id']}", - email_verified=True, - created_at=int(time.time()), - updated_at=int(time.time()), - last_seen=int(time.time()), - ) - session.add(author) else: - author.name = profile["name"] - author.pic = profile.get("picture") or author.pic - author.oauth = f"{provider}:{profile['id']}" - author.email_verified = True - author.updated_at = int(time.time()) - author.last_seen = int(time.time()) + # Ищем пользователя по email если есть настоящий email + author = None + if email and email != f"{provider}_{profile.get('id', 'unknown')}@oauth.local": + author = session.query(Author).filter(Author.email == email).first() + + if author: + # Пользователь найден по email - добавляем OAuth данные + author.set_oauth_account(provider, profile["id"], email=profile.get("email")) + + # Обновляем данные автора если нужно + if profile.get("name") and not author.name: + author.name = profile["name"] # type: ignore[assignment] + if profile.get("picture") and not author.pic: + author.pic = profile["picture"] # type: ignore[assignment] + author.updated_at = int(time.time()) # type: ignore[assignment] + author.last_seen = int(time.time()) # type: ignore[assignment] + + else: + # Создаем нового пользователя + slug = generate_unique_slug(profile["name"] or f"{provider}_{profile.get('id', 'user')}") + + author = Author( + email=email, + name=profile["name"] or f"{provider.title()} User", + slug=slug, + pic=profile.get("picture"), + email_verified=True if profile.get("email") else False, + created_at=int(time.time()), + updated_at=int(time.time()), + last_seen=int(time.time()), + ) + session.add(author) + session.flush() # Получаем ID автора + + # Добавляем OAuth данные для нового пользователя + author.set_oauth_account(provider, profile["id"], email=profile.get("email")) session.commit() - # Создаем сессию - session_token = await TokenStorage.create_session(author) + # Создаем токен сессии + session_token = await TokenStorage.create_session(str(author.id)) # Формируем URL для редиректа с токеном redirect_url = f"{stored_redirect_uri}?state={state}&access_token={session_token}" @@ -212,10 +389,10 @@ async def oauth_callback(request): return response except Exception as e: - logger.error(f"OAuth callback error: {str(e)}") + logger.error(f"OAuth callback error: {e!s}") # В случае ошибки редиректим на фронтенд с ошибкой fallback_redirect = request.query_params.get("redirect_uri", FRONTEND_URL) - return RedirectResponse(url=f"{fallback_redirect}?error=oauth_failed&message={str(e)}") + return RedirectResponse(url=f"{fallback_redirect}?error=oauth_failed&message={e!s}") async def store_oauth_state(state: str, data: dict) -> None: @@ -224,7 +401,7 @@ async def store_oauth_state(state: str, data: dict) -> None: await redis.execute("SETEX", key, OAUTH_STATE_TTL, orjson.dumps(data)) -async def get_oauth_state(state: str) -> dict: +async def get_oauth_state(state: str) -> Optional[dict]: """Получает и удаляет OAuth состояние из Redis (one-time use)""" key = f"oauth_state:{state}" data = await redis.execute("GET", key) @@ -232,3 +409,164 @@ async def get_oauth_state(state: str) -> dict: await redis.execute("DEL", key) # Одноразовое использование return orjson.loads(data) return None + + +# HTTP handlers для тестирования +async def oauth_login_http(request: Request) -> JSONResponse | RedirectResponse: + """HTTP handler для OAuth login""" + try: + provider = request.path_params.get("provider") + if not provider or provider not in PROVIDERS: + return JSONResponse({"error": "Invalid provider"}, status_code=400) + + client = oauth.create_client(provider) + if not client: + return JSONResponse({"error": "Provider not configured"}, status_code=400) + + # Генерируем PKCE challenge + code_verifier = token_urlsafe(32) + code_challenge = create_s256_code_challenge(code_verifier) + state = token_urlsafe(32) + + # Сохраняем состояние в сессии + request.session["code_verifier"] = code_verifier + request.session["provider"] = provider + request.session["state"] = state + + # Сохраняем состояние OAuth в Redis + oauth_data = { + "code_verifier": code_verifier, + "provider": provider, + "redirect_uri": FRONTEND_URL, + "created_at": int(time.time()), + } + await store_oauth_state(state, oauth_data) + + # URL для callback + callback_uri = f"{FRONTEND_URL}oauth/{provider}/callback" + + return await client.authorize_redirect( + request, + callback_uri, + code_challenge=code_challenge, + code_challenge_method="S256", + state=state, + ) + + except Exception as e: + logger.error(f"OAuth login error: {e}") + return JSONResponse({"error": "OAuth login failed"}, status_code=500) + + +async def oauth_callback_http(request: Request) -> JSONResponse | RedirectResponse: + """HTTP handler для OAuth callback""" + try: + # Используем GraphQL resolver логику + provider = request.session.get("provider") + if not provider: + return JSONResponse({"error": "No OAuth session found"}, status_code=400) + + state = request.query_params.get("state") + session_state = request.session.get("state") + + if not state or state != session_state: + return JSONResponse({"error": "Invalid or expired OAuth state"}, status_code=400) + + oauth_data = await get_oauth_state(state) + if not oauth_data: + return JSONResponse({"error": "Invalid or expired OAuth state"}, status_code=400) + + # Используем существующую логику + client = oauth.create_client(provider) + token = await client.authorize_access_token(request) + + profile = await get_user_profile(provider, client, token) + if not profile: + return JSONResponse({"error": "Failed to get user profile"}, status_code=400) + + # Для некоторых провайдеров (X, Telegram) email может отсутствовать + email = profile.get("email") + if not email: + # Генерируем временный email на основе провайдера и ID + email = f"{provider}_{profile.get('id', 'unknown')}@oauth.local" + + # Регистрируем/обновляем пользователя + with local_session() as session: + # Сначала ищем пользователя по OAuth + author = Author.find_by_oauth(provider, profile["id"], session) + + if author: + # Пользователь найден по OAuth - обновляем данные + author.set_oauth_account(provider, profile["id"], email=profile.get("email")) + + # Обновляем основные данные автора если они пустые + if profile.get("name") and not author.name: + author.name = profile["name"] # type: ignore[assignment] + if profile.get("picture") and not author.pic: + author.pic = profile["picture"] # type: ignore[assignment] + author.updated_at = int(time.time()) # type: ignore[assignment] + author.last_seen = int(time.time()) # type: ignore[assignment] + + else: + # Ищем пользователя по email если есть настоящий email + author = None + if email and email != f"{provider}_{profile.get('id', 'unknown')}@oauth.local": + author = session.query(Author).filter(Author.email == email).first() + + if author: + # Пользователь найден по email - добавляем OAuth данные + author.set_oauth_account(provider, profile["id"], email=profile.get("email")) + + # Обновляем данные автора если нужно + if profile.get("name") and not author.name: + author.name = profile["name"] # type: ignore[assignment] + if profile.get("picture") and not author.pic: + author.pic = profile["picture"] # type: ignore[assignment] + author.updated_at = int(time.time()) # type: ignore[assignment] + author.last_seen = int(time.time()) # type: ignore[assignment] + + else: + # Создаем нового пользователя + slug = generate_unique_slug(profile["name"] or f"{provider}_{profile.get('id', 'user')}") + + author = Author( + email=email, + name=profile["name"] or f"{provider.title()} User", + slug=slug, + pic=profile.get("picture"), + email_verified=True if profile.get("email") else False, + created_at=int(time.time()), + updated_at=int(time.time()), + last_seen=int(time.time()), + ) + session.add(author) + session.flush() # Получаем ID автора + + # Добавляем OAuth данные для нового пользователя + author.set_oauth_account(provider, profile["id"], email=profile.get("email")) + + session.commit() + + # Создаем токен сессии + session_token = await TokenStorage.create_session(str(author.id)) + + # Очищаем OAuth сессию + request.session.pop("code_verifier", None) + request.session.pop("provider", None) + request.session.pop("state", None) + + # Возвращаем redirect с cookie + response = RedirectResponse(url="/auth/success", status_code=307) + response.set_cookie( + "session_token", + session_token, + httponly=True, + secure=True, + samesite="lax", + max_age=30 * 24 * 60 * 60, # 30 дней + ) + return response + + except Exception as e: + logger.error(f"OAuth callback error: {e}") + return JSONResponse({"error": "OAuth callback failed"}, status_code=500) diff --git a/auth/orm.py b/auth/orm.py index b812a0d7..1cf02143 100644 --- a/auth/orm.py +++ b/auth/orm.py @@ -5,7 +5,7 @@ from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String from sqlalchemy.orm import relationship from auth.identity import Password -from services.db import Base +from services.db import BaseModel as Base # Общие table_args для всех моделей DEFAULT_TABLE_ARGS = {"extend_existing": True} @@ -91,7 +91,7 @@ class RolePermission(Base): __tablename__ = "role_permission" __table_args__ = {"extend_existing": True} - id = None + id = None # type: ignore role = Column(ForeignKey("role.id"), primary_key=True, index=True) permission = Column(ForeignKey("permission.id"), primary_key=True, index=True) @@ -124,7 +124,7 @@ class AuthorRole(Base): __tablename__ = "author_role" __table_args__ = {"extend_existing": True} - id = None + id = None # type: ignore community = Column(ForeignKey("community.id"), primary_key=True, index=True, default=1) author = Column(ForeignKey("author.id"), primary_key=True, index=True) role = Column(ForeignKey("role.id"), primary_key=True, index=True) @@ -152,16 +152,14 @@ class Author(Base): pic = Column(String, nullable=True, comment="Picture") links = Column(JSON, nullable=True, comment="Links") - # Дополнительные поля из User - oauth = Column(String, nullable=True, comment="OAuth provider") - oid = Column(String, nullable=True, comment="OAuth ID") - muted = Column(Boolean, default=False, comment="Is author muted") + # OAuth аккаунты - JSON с данными всех провайдеров + # Формат: {"google": {"id": "123", "email": "user@gmail.com"}, "github": {"id": "456"}} + oauth = Column(JSON, nullable=True, default=dict, comment="OAuth accounts data") # Поля аутентификации email = Column(String, unique=True, nullable=True, comment="Email") phone = Column(String, unique=True, nullable=True, comment="Phone") password = Column(String, nullable=True, comment="Password hash") - is_active = Column(Boolean, default=True, nullable=False) email_verified = Column(Boolean, default=False) phone_verified = Column(Boolean, default=False) failed_login_attempts = Column(Integer, default=0) @@ -205,28 +203,28 @@ class Author(Base): def verify_password(self, password: str) -> bool: """Проверяет пароль пользователя""" - return Password.verify(password, self.password) if self.password else False + return Password.verify(password, str(self.password)) if self.password else False def set_password(self, password: str): """Устанавливает пароль пользователя""" - self.password = Password.encode(password) + self.password = Password.encode(password) # type: ignore[assignment] def increment_failed_login(self): """Увеличивает счетчик неудачных попыток входа""" - self.failed_login_attempts += 1 + self.failed_login_attempts += 1 # type: ignore[assignment] if self.failed_login_attempts >= 5: - self.account_locked_until = int(time.time()) + 300 # 5 минут + self.account_locked_until = int(time.time()) + 300 # type: ignore[assignment] # 5 минут def reset_failed_login(self): """Сбрасывает счетчик неудачных попыток входа""" - self.failed_login_attempts = 0 - self.account_locked_until = None + self.failed_login_attempts = 0 # type: ignore[assignment] + self.account_locked_until = None # type: ignore[assignment] def is_locked(self) -> bool: """Проверяет, заблокирован ли аккаунт""" if not self.account_locked_until: return False - return self.account_locked_until > int(time.time()) + return bool(self.account_locked_until > int(time.time())) @property def username(self) -> str: @@ -237,9 +235,9 @@ class Author(Base): Returns: str: slug, email или phone пользователя """ - return self.slug or self.email or self.phone or "" + return str(self.slug or self.email or self.phone or "") - def dict(self, access=False) -> Dict: + def dict(self, access: bool = False) -> Dict: """ Сериализует объект Author в словарь с учетом прав доступа. @@ -266,3 +264,66 @@ class Author(Base): result[field] = None return result + + @classmethod + def find_by_oauth(cls, provider: str, provider_id: str, session): + """ + Находит автора по OAuth провайдеру и ID + + Args: + provider (str): Имя OAuth провайдера (google, github и т.д.) + provider_id (str): ID пользователя у провайдера + session: Сессия базы данных + + Returns: + Author или None: Найденный автор или None если не найден + """ + # Ищем авторов, у которых есть данный провайдер с данным ID + authors = session.query(cls).filter(cls.oauth.isnot(None)).all() + for author in authors: + if author.oauth and provider in author.oauth: + if author.oauth[provider].get("id") == provider_id: + return author + return None + + def set_oauth_account(self, provider: str, provider_id: str, email: str = None): + """ + Устанавливает OAuth аккаунт для автора + + Args: + provider (str): Имя OAuth провайдера (google, github и т.д.) + provider_id (str): ID пользователя у провайдера + email (str, optional): Email от провайдера + """ + if not self.oauth: + self.oauth = {} # type: ignore[assignment] + + oauth_data = {"id": provider_id} + if email: + oauth_data["email"] = email + + self.oauth[provider] = oauth_data # type: ignore[index] + + def get_oauth_account(self, provider: str): + """ + Получает OAuth аккаунт провайдера + + Args: + provider (str): Имя OAuth провайдера + + Returns: + dict или None: Данные OAuth аккаунта или None если не найден + """ + if not self.oauth: + return None + return self.oauth.get(provider) + + def remove_oauth_account(self, provider: str): + """ + Удаляет OAuth аккаунт провайдера + + Args: + provider (str): Имя OAuth провайдера + """ + if self.oauth and provider in self.oauth: + del self.oauth[provider] diff --git a/auth/permissions.py b/auth/permissions.py index bcb18792..44393ce4 100644 --- a/auth/permissions.py +++ b/auth/permissions.py @@ -5,7 +5,7 @@ на основе его роли в этом сообществе. """ -from typing import List, Union +from typing import Union from sqlalchemy.orm import Session @@ -98,7 +98,7 @@ class ContextualPermissionCheck: permission_id = f"{resource}:{operation}" # Запрос на проверку разрешений для указанных ролей - has_permission = ( + return ( session.query(RolePermission) .join(Role, Role.id == RolePermission.role) .join(Permission, Permission.id == RolePermission.permission) @@ -107,10 +107,8 @@ class ContextualPermissionCheck: is not None ) - return has_permission - @staticmethod - def get_user_community_roles(session: Session, author_id: int, community_slug: str) -> List[CommunityRole]: + def get_user_community_roles(session: Session, author_id: int, community_slug: str) -> list[CommunityRole]: """ Получает список ролей пользователя в сообществе. @@ -180,7 +178,7 @@ class ContextualPermissionCheck: if not community_follower: # Создаем новую запись CommunityFollower - community_follower = CommunityFollower(author=author_id, community=community.id) + community_follower = CommunityFollower(follower=author_id, community=community.id) session.add(community_follower) # Назначаем роль diff --git a/auth/sessions.py b/auth/sessions.py index 84692752..96293b4d 100644 --- a/auth/sessions.py +++ b/auth/sessions.py @@ -1,11 +1,10 @@ from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List, Optional +from typing import Any, Optional from pydantic import BaseModel from auth.jwtcodec import JWTCodec, TokenPayload from services.redis import redis -from settings import SESSION_TOKEN_LIFE_SPAN from utils.logger import root_logger as logger @@ -103,7 +102,7 @@ class SessionManager: pipeline.hset(token_key, mapping={"user_id": user_id, "username": username}) pipeline.expire(token_key, 30 * 24 * 60 * 60) - result = await pipeline.execute() + await pipeline.execute() logger.info(f"[SessionManager.create_session] Сессия успешно создана для пользователя {user_id}") return token @@ -130,7 +129,7 @@ class SessionManager: logger.debug(f"[SessionManager.verify_session] Успешно декодирован токен, user_id={payload.user_id}") except Exception as e: - logger.error(f"[SessionManager.verify_session] Ошибка при декодировании токена: {str(e)}") + logger.error(f"[SessionManager.verify_session] Ошибка при декодировании токена: {e!s}") return None # Получаем данные из payload @@ -205,9 +204,9 @@ class SessionManager: return payload @classmethod - async def get_user_sessions(cls, user_id: str) -> List[Dict[str, Any]]: + async def get_user_sessions(cls, user_id: str) -> list[dict[str, Any]]: """ - Получает список активных сессий пользователя. + Получает все активные сессии пользователя. Args: user_id: ID пользователя @@ -219,13 +218,15 @@ class SessionManager: tokens = await redis.smembers(user_sessions_key) sessions = [] - for token in tokens: - session_key = cls._make_session_key(user_id, token) + # Convert set to list for iteration + for token in list(tokens): + token_str: str = str(token) + session_key = cls._make_session_key(user_id, token_str) session_data = await redis.hgetall(session_key) - if session_data: + if session_data and token: session = dict(session_data) - session["token"] = token + session["token"] = token_str sessions.append(session) return sessions @@ -275,17 +276,19 @@ class SessionManager: tokens = await redis.smembers(user_sessions_key) count = 0 - for token in tokens: - session_key = cls._make_session_key(user_id, token) + # Convert set to list for iteration + for token in list(tokens): + token_str: str = str(token) + session_key = cls._make_session_key(user_id, token_str) # Удаляем данные сессии deleted = await redis.delete(session_key) count += deleted # Также удаляем ключ в формате TokenStorage - token_payload = JWTCodec.decode(token) + token_payload = JWTCodec.decode(token_str) if token_payload: - token_key = f"{user_id}-{token_payload.username}-{token}" + token_key = f"{user_id}-{token_payload.username}-{token_str}" await redis.delete(token_key) # Очищаем список токенов @@ -294,7 +297,7 @@ class SessionManager: return count @classmethod - async def get_session_data(cls, user_id: str, token: str) -> Optional[Dict[str, Any]]: + async def get_session_data(cls, user_id: str, token: str) -> Optional[dict[str, Any]]: """ Получает данные сессии. @@ -310,7 +313,7 @@ class SessionManager: session_data = await redis.execute("HGETALL", session_key) return session_data if session_data else None except Exception as e: - logger.error(f"[SessionManager.get_session_data] Ошибка: {str(e)}") + logger.error(f"[SessionManager.get_session_data] Ошибка: {e!s}") return None @classmethod @@ -336,7 +339,7 @@ class SessionManager: await pipe.execute() return True except Exception as e: - logger.error(f"[SessionManager.revoke_session] Ошибка: {str(e)}") + logger.error(f"[SessionManager.revoke_session] Ошибка: {e!s}") return False @classmethod @@ -362,8 +365,10 @@ class SessionManager: pipe = redis.pipeline() # Формируем список ключей для удаления - for token in tokens: - session_key = cls._make_session_key(user_id, token) + # Convert set to list for iteration + for token in list(tokens): + token_str: str = str(token) + session_key = cls._make_session_key(user_id, token_str) await pipe.delete(session_key) # Удаляем список сессий @@ -372,11 +377,11 @@ class SessionManager: return True except Exception as e: - logger.error(f"[SessionManager.revoke_all_sessions] Ошибка: {str(e)}") + logger.error(f"[SessionManager.revoke_all_sessions] Ошибка: {e!s}") return False @classmethod - async def refresh_session(cls, user_id: str, old_token: str, device_info: dict = None) -> Optional[str]: + async def refresh_session(cls, user_id: int, old_token: str, device_info: Optional[dict] = None) -> Optional[str]: """ Обновляет сессию пользователя, заменяя старый токен новым. @@ -389,8 +394,9 @@ class SessionManager: str: Новый токен сессии или None в случае ошибки """ try: + user_id_str = str(user_id) # Получаем данные старой сессии - old_session_key = cls._make_session_key(user_id, old_token) + old_session_key = cls._make_session_key(user_id_str, old_token) old_session_data = await redis.hgetall(old_session_key) if not old_session_data: @@ -402,12 +408,12 @@ class SessionManager: device_info = old_session_data.get("device_info") # Создаем новую сессию - new_token = await cls.create_session(user_id, old_session_data.get("username", ""), device_info) + new_token = await cls.create_session(user_id_str, old_session_data.get("username", ""), device_info) # Отзываем старую сессию - await cls.revoke_session(user_id, old_token) + await cls.revoke_session(user_id_str, old_token) return new_token except Exception as e: - logger.error(f"[SessionManager.refresh_session] Ошибка: {str(e)}") + logger.error(f"[SessionManager.refresh_session] Ошибка: {e!s}") return None diff --git a/auth/state.py b/auth/state.py index 6a9c7157..e90eb981 100644 --- a/auth/state.py +++ b/auth/state.py @@ -2,6 +2,8 @@ Классы состояния авторизации """ +from typing import Optional + class AuthState: """ @@ -9,15 +11,15 @@ class AuthState: Используется в аутентификационных middleware и функциях. """ - def __init__(self): - self.logged_in = False - self.author_id = None - self.token = None - self.username = None - self.is_admin = False - self.is_editor = False - self.error = None + def __init__(self) -> None: + self.logged_in: bool = False + self.author_id: Optional[str] = None + self.token: Optional[str] = None + self.username: Optional[str] = None + self.is_admin: bool = False + self.is_editor: bool = False + self.error: Optional[str] = None - def __bool__(self): + def __bool__(self) -> bool: """Возвращает True если пользователь авторизован""" return self.logged_in diff --git a/auth/tokenstorage.py b/auth/tokenstorage.py index 969e4668..352aefd7 100644 --- a/auth/tokenstorage.py +++ b/auth/tokenstorage.py @@ -1,436 +1,671 @@ import json +import secrets import time -from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, Literal, Optional, Union from auth.jwtcodec import JWTCodec from auth.validations import AuthInput from services.redis import redis -from settings import ONETIME_TOKEN_LIFE_SPAN, SESSION_TOKEN_LIFE_SPAN from utils.logger import root_logger as logger +# Типы токенов +TokenType = Literal["session", "verification", "oauth_access", "oauth_refresh"] + +# TTL по умолчанию для разных типов токенов +DEFAULT_TTL = { + "session": 30 * 24 * 60 * 60, # 30 дней + "verification": 3600, # 1 час + "oauth_access": 3600, # 1 час + "oauth_refresh": 86400 * 30, # 30 дней +} + class TokenStorage: """ - Класс для работы с хранилищем токенов в Redis + Единый менеджер всех типов токенов в системе: + - Токены сессий (session) + - Токены подтверждения (verification) + - OAuth токены (oauth_access, oauth_refresh) """ @staticmethod - def _make_token_key(user_id: str, username: str, token: str) -> str: + def _make_token_key(token_type: TokenType, identifier: str, token: Optional[str] = None) -> str: """ - Создает ключ для хранения токена + Создает унифицированный ключ для токена Args: - user_id: ID пользователя - username: Имя пользователя - token: Токен + token_type: Тип токена + identifier: Идентификатор (user_id, user_id:provider, etc) + token: Сам токен (для session и verification) Returns: str: Ключ токена """ - # Сохраняем в старом формате для обратной совместимости - return f"{user_id}-{username}-{token}" + if token_type == "session": + return f"session:{token}" + if token_type == "verification": + return f"verification_token:{token}" + if token_type == "oauth_access": + return f"oauth_access:{identifier}" + if token_type == "oauth_refresh": + return f"oauth_refresh:{identifier}" + raise ValueError(f"Неизвестный тип токена: {token_type}") @staticmethod - def _make_session_key(user_id: str, token: str) -> str: - """ - Создает ключ в новом формате SessionManager - - Args: - user_id: ID пользователя - token: Токен - - Returns: - str: Ключ сессии - """ - return f"session:{user_id}:{token}" - - @staticmethod - def _make_user_sessions_key(user_id: str) -> str: - """ - Создает ключ для списка сессий пользователя - - Args: - user_id: ID пользователя - - Returns: - str: Ключ списка сессий - """ - return f"user_sessions:{user_id}" + def _make_user_tokens_key(user_id: str, token_type: TokenType) -> str: + """Создает ключ для списка токенов пользователя""" + return f"user_tokens:{user_id}:{token_type}" @classmethod - async def create_session(cls, user_id: str, username: str, device_info: Optional[Dict[str, str]] = None) -> str: + async def create_token( + cls, + token_type: TokenType, + user_id: str, + data: Dict[str, Any], + ttl: Optional[int] = None, + token: Optional[str] = None, + provider: Optional[str] = None, + ) -> str: """ - Создает новую сессию для пользователя + Универсальный метод создания токена любого типа Args: + token_type: Тип токена user_id: ID пользователя - username: Имя пользователя - device_info: Информация об устройстве (опционально) + data: Данные токена + ttl: Время жизни (по умолчанию из DEFAULT_TTL) + token: Существующий токен (для verification) + provider: OAuth провайдер (для oauth токенов) Returns: - str: Токен сессии + str: Токен или ключ токена """ - logger.debug(f"[TokenStorage.create_session] Начало создания сессии для пользователя {user_id}") + if ttl is None: + ttl = DEFAULT_TTL[token_type] - # Генерируем JWT токен с явным указанием времени истечения - expiration_date = datetime.now(tz=timezone.utc) + timedelta(days=30) - token = JWTCodec.encode({"id": user_id, "email": username}, exp=expiration_date) - logger.debug(f"[TokenStorage.create_session] Создан JWT токен длиной {len(token)}") + # Подготавливаем данные токена + token_data = {"user_id": user_id, "token_type": token_type, "created_at": int(time.time()), **data} - # Формируем ключи для Redis - token_key = cls._make_token_key(user_id, username, token) - logger.debug(f"[TokenStorage.create_session] Сформированы ключи: token_key={token_key}") + if token_type == "session": + # Генерируем новый токен сессии + session_token = cls.generate_token() + token_key = cls._make_token_key(token_type, user_id, session_token) - # Формируем ключи в новом формате SessionManager для совместимости - session_key = cls._make_session_key(user_id, token) - user_sessions_key = cls._make_user_sessions_key(user_id) + # Сохраняем данные сессии + for field, value in token_data.items(): + await redis.hset(token_key, field, str(value)) + await redis.expire(token_key, ttl) - # Готовим данные для сохранения - token_data = { - "user_id": user_id, - "username": username, - "created_at": time.time(), - "expires_at": time.time() + 30 * 24 * 60 * 60, # 30 дней - } + # Добавляем в список сессий пользователя + user_tokens_key = cls._make_user_tokens_key(user_id, token_type) + await redis.sadd(user_tokens_key, session_token) + await redis.expire(user_tokens_key, ttl) - if device_info: - token_data.update(device_info) + logger.info(f"Создан токен сессии для пользователя {user_id}") + return session_token - logger.debug(f"[TokenStorage.create_session] Сформированы данные сессии: {token_data}") + if token_type == "verification": + # Используем переданный токен или генерируем новый + verification_token = token or secrets.token_urlsafe(32) + token_key = cls._make_token_key(token_type, user_id, verification_token) - # Сохраняем в Redis старый формат - pipeline = redis.pipeline() - pipeline.hset(token_key, mapping=token_data) - pipeline.expire(token_key, 30 * 24 * 60 * 60) # 30 дней + # Отменяем предыдущие токены того же типа + verification_type = data.get("verification_type", "unknown") + await cls._cancel_verification_tokens(user_id, verification_type) - # Также сохраняем в новом формате SessionManager для обеспечения совместимости - pipeline.hset(session_key, mapping=token_data) - pipeline.expire(session_key, 30 * 24 * 60 * 60) # 30 дней - pipeline.sadd(user_sessions_key, token) - pipeline.expire(user_sessions_key, 30 * 24 * 60 * 60) # 30 дней + # Сохраняем токен подтверждения + await redis.serialize_and_set(token_key, token_data, ex=ttl) - results = await pipeline.execute() - logger.info(f"[TokenStorage.create_session] Сессия успешно создана для пользователя {user_id}") + logger.info(f"Создан токен подтверждения {verification_type} для пользователя {user_id}") + return verification_token - return token + if token_type in ["oauth_access", "oauth_refresh"]: + if not provider: + raise ValueError("OAuth токены требуют указания провайдера") + + identifier = f"{user_id}:{provider}" + token_key = cls._make_token_key(token_type, identifier) + + # Добавляем провайдера в данные + token_data["provider"] = provider + + # Сохраняем OAuth токен + await redis.serialize_and_set(token_key, token_data, ex=ttl) + + logger.info(f"Создан {token_type} токен для пользователя {user_id}, провайдер {provider}") + return token_key + + raise ValueError(f"Неподдерживаемый тип токена: {token_type}") @classmethod - async def exists(cls, token_key: str) -> bool: + async def get_token_data( + cls, + token_type: TokenType, + token_or_identifier: str, + user_id: Optional[str] = None, + provider: Optional[str] = None, + ) -> Optional[Dict[str, Any]]: """ - Проверяет существование токена по ключу + Универсальный метод получения данных токена Args: - token_key: Ключ токена + token_type: Тип токена + token_or_identifier: Токен или идентификатор + user_id: ID пользователя (для OAuth) + provider: OAuth провайдер Returns: - bool: True, если токен существует + Dict с данными токена или None """ - exists = await redis.exists(token_key) - return bool(exists) + try: + if token_type == "session": + token_key = cls._make_token_key(token_type, "", token_or_identifier) + token_data = await redis.hgetall(token_key) + if token_data: + # Обновляем время последней активности + await redis.hset(token_key, "last_activity", str(int(time.time()))) + return {k: v for k, v in token_data.items()} + return None + + if token_type == "verification": + token_key = cls._make_token_key(token_type, "", token_or_identifier) + return await redis.get_and_deserialize(token_key) + + if token_type in ["oauth_access", "oauth_refresh"]: + if not user_id or not provider: + raise ValueError("OAuth токены требуют user_id и provider") + + identifier = f"{user_id}:{provider}" + token_key = cls._make_token_key(token_type, identifier) + token_data = await redis.get_and_deserialize(token_key) + + if token_data: + # Добавляем информацию о TTL + ttl = await redis.execute("TTL", token_key) + if ttl > 0: + token_data["ttl_remaining"] = ttl + return token_data + + return None + + except Exception as e: + logger.error(f"Ошибка получения токена {token_type}: {e}") + return None @classmethod - async def validate_token(cls, token: str) -> Tuple[bool, Optional[Dict[str, Any]]]: + async def validate_token( + cls, token: str, token_type: Optional[TokenType] = None + ) -> tuple[bool, Optional[dict[str, Any]]]: """ Проверяет валидность токена Args: - token: JWT токен + token: Токен для проверки + token_type: Тип токена (если не указан - определяется автоматически) Returns: - Tuple[bool, Dict[str, Any]]: (Валиден ли токен, данные токена) + Tuple[bool, Dict]: (Валиден ли токен, данные токена) """ try: - # Декодируем JWT токен - payload = JWTCodec.decode(token) - if not payload: - logger.warning(f"[TokenStorage.validate_token] Токен не валиден (не удалось декодировать)") - return False, None + # Для JWT токенов (сессии) - декодируем + if not token_type or token_type == "session": + payload = JWTCodec.decode(token) + if payload: + user_id = payload.user_id + username = payload.username - user_id = payload.user_id - username = payload.username + # Проверяем в разных форматах для совместимости + old_token_key = f"{user_id}-{username}-{token}" + new_token_key = cls._make_token_key("session", user_id, token) - # Формируем ключи для Redis в обоих форматах - token_key = cls._make_token_key(user_id, username, token) - session_key = cls._make_session_key(user_id, token) + old_exists = await redis.exists(old_token_key) + new_exists = await redis.exists(new_token_key) - # Проверяем в обоих форматах для совместимости - old_exists = await redis.exists(token_key) - new_exists = await redis.exists(session_key) + if old_exists or new_exists: + # Получаем данные из актуального хранилища + if new_exists: + token_data = await redis.hgetall(new_token_key) + else: + token_data = await redis.hgetall(old_token_key) + # Миграция в новый формат + if not new_exists: + for field, value in token_data.items(): + await redis.hset(new_token_key, field, value) + await redis.expire(new_token_key, DEFAULT_TTL["session"]) - if old_exists or new_exists: - logger.info(f"[TokenStorage.validate_token] Токен валиден для пользователя {user_id}") + return True, {k: v for k, v in token_data.items()} - # Получаем данные токена из актуального хранилища - if new_exists: - token_data = await redis.hgetall(session_key) - else: - token_data = await redis.hgetall(token_key) + # Для токенов подтверждения - прямая проверка + if not token_type or token_type == "verification": + token_key = cls._make_token_key("verification", "", token) + token_data = await redis.get_and_deserialize(token_key) + if token_data: + return True, token_data - # Если найден только в старом формате, создаем запись в новом формате - if not new_exists: - logger.info(f"[TokenStorage.validate_token] Миграция токена в новый формат: {session_key}") - await redis.hset(session_key, mapping=token_data) - await redis.expire(session_key, 30 * 24 * 60 * 60) - await redis.sadd(cls._make_user_sessions_key(user_id), token) - - return True, token_data - else: - logger.warning(f"[TokenStorage.validate_token] Токен не найден в Redis: {token_key}") - return False, None + return False, None except Exception as e: - logger.error(f"[TokenStorage.validate_token] Ошибка при проверке токена: {e}") + logger.error(f"Ошибка валидации токена: {e}") return False, None @classmethod - async def invalidate_token(cls, token: str) -> bool: + async def revoke_token( + cls, + token_type: TokenType, + token_or_identifier: str, + user_id: Optional[str] = None, + provider: Optional[str] = None, + ) -> bool: """ - Инвалидирует токен + Универсальный метод отзыва токена Args: - token: JWT токен + token_type: Тип токена + token_or_identifier: Токен или идентификатор + user_id: ID пользователя + provider: OAuth провайдер Returns: - bool: True, если токен успешно инвалидирован + bool: Успех операции """ try: - # Декодируем JWT токен - payload = JWTCodec.decode(token) - if not payload: - logger.warning(f"[TokenStorage.invalidate_token] Токен не валиден (не удалось декодировать)") - return False + if token_type == "session": + # Декодируем JWT для получения данных + payload = JWTCodec.decode(token_or_identifier) + if payload: + user_id = payload.user_id + username = payload.username - user_id = payload.user_id - username = payload.username + # Удаляем в обоих форматах + old_token_key = f"{user_id}-{username}-{token_or_identifier}" + new_token_key = cls._make_token_key(token_type, user_id, token_or_identifier) + user_tokens_key = cls._make_user_tokens_key(user_id, token_type) - # Формируем ключи для Redis в обоих форматах - token_key = cls._make_token_key(user_id, username, token) - session_key = cls._make_session_key(user_id, token) - user_sessions_key = cls._make_user_sessions_key(user_id) + result1 = await redis.delete(old_token_key) + result2 = await redis.delete(new_token_key) + result3 = await redis.srem(user_tokens_key, token_or_identifier) - # Удаляем токен из Redis в обоих форматах - pipeline = redis.pipeline() - pipeline.delete(token_key) - pipeline.delete(session_key) - pipeline.srem(user_sessions_key, token) - results = await pipeline.execute() + return result1 > 0 or result2 > 0 or result3 > 0 - success = any(results) - if success: - logger.info(f"[TokenStorage.invalidate_token] Токен успешно инвалидирован для пользователя {user_id}") - else: - logger.warning(f"[TokenStorage.invalidate_token] Токен не найден: {token_key}") + elif token_type == "verification": + token_key = cls._make_token_key(token_type, "", token_or_identifier) + result = await redis.delete(token_key) + return result > 0 - return success + elif token_type in ["oauth_access", "oauth_refresh"]: + if not user_id or not provider: + raise ValueError("OAuth токены требуют user_id и provider") + + identifier = f"{user_id}:{provider}" + token_key = cls._make_token_key(token_type, identifier) + result = await redis.delete(token_key) + return result > 0 + + return False except Exception as e: - logger.error(f"[TokenStorage.invalidate_token] Ошибка при инвалидации токена: {e}") + logger.error(f"Ошибка отзыва токена {token_type}: {e}") return False @classmethod - async def invalidate_all_tokens(cls, user_id: str) -> int: + async def revoke_user_tokens(cls, user_id: str, token_type: Optional[TokenType] = None) -> int: """ - Инвалидирует все токены пользователя + Отзывает все токены пользователя определенного типа или все Args: user_id: ID пользователя + token_type: Тип токенов для отзыва (None = все типы) Returns: - int: Количество инвалидированных токенов + int: Количество отозванных токенов """ + count = 0 + try: - # Получаем список сессий пользователя - user_sessions_key = cls._make_user_sessions_key(user_id) - tokens = await redis.smembers(user_sessions_key) + types_to_revoke = ( + [token_type] if token_type else ["session", "verification", "oauth_access", "oauth_refresh"] + ) - if not tokens: - logger.warning(f"[TokenStorage.invalidate_all_tokens] Нет активных сессий пользователя {user_id}") - return 0 + for t_type in types_to_revoke: + if t_type == "session": + user_tokens_key = cls._make_user_tokens_key(user_id, t_type) + tokens = await redis.smembers(user_tokens_key) - count = 0 - for token in tokens: - # Декодируем JWT токен - try: - payload = JWTCodec.decode(token) - if payload: - username = payload.username + for token in tokens: + token_str = token.decode("utf-8") if isinstance(token, bytes) else str(token) + success = await cls.revoke_token(t_type, token_str, user_id) + if success: + count += 1 - # Формируем ключи для Redis - token_key = cls._make_token_key(user_id, username, token) - session_key = cls._make_session_key(user_id, token) + await redis.delete(user_tokens_key) - # Удаляем токен из Redis - pipeline = redis.pipeline() - pipeline.delete(token_key) - pipeline.delete(session_key) - results = await pipeline.execute() + elif t_type == "verification": + # Ищем все токены подтверждения пользователя + pattern = "verification_token:*" + keys = await redis.keys(pattern) + for key in keys: + token_data = await redis.get_and_deserialize(key) + if token_data and token_data.get("user_id") == user_id: + await redis.delete(key) + count += 1 + + elif t_type in ["oauth_access", "oauth_refresh"]: + # Ищем OAuth токены по паттерну + pattern = f"{t_type}:{user_id}:*" + keys = await redis.keys(pattern) + + for key in keys: + await redis.delete(key) count += 1 - except Exception as e: - logger.error(f"[TokenStorage.invalidate_all_tokens] Ошибка при обработке токена: {e}") - continue - # Удаляем список сессий пользователя - await redis.delete(user_sessions_key) - - logger.info(f"[TokenStorage.invalidate_all_tokens] Инвалидировано {count} токенов пользователя {user_id}") + logger.info(f"Отозвано {count} токенов для пользователя {user_id}") return count except Exception as e: - logger.error(f"[TokenStorage.invalidate_all_tokens] Ошибка при инвалидации всех токенов: {e}") - return 0 + logger.error(f"Ошибка отзыва токенов пользователя: {e}") + return count + + @staticmethod + async def _cancel_verification_tokens(user_id: str, verification_type: str) -> None: + """Отменяет предыдущие токены подтверждения определенного типа""" + try: + pattern = "verification_token:*" + keys = await redis.keys(pattern) + + for key in keys: + token_data = await redis.get_and_deserialize(key) + if ( + token_data + and token_data.get("user_id") == user_id + and token_data.get("verification_type") == verification_type + ): + await redis.delete(key) + + except Exception as e: + logger.error(f"Ошибка отмены токенов подтверждения: {e}") + + # === УДОБНЫЕ МЕТОДЫ ДЛЯ СЕССИЙ === + + @classmethod + async def create_session( + cls, + user_id: str, + auth_data: Optional[dict] = None, + username: Optional[str] = None, + device_info: Optional[dict] = None, + ) -> str: + """Создает токен сессии""" + session_data = {} + + if auth_data: + session_data["auth_data"] = json.dumps(auth_data) + if username: + session_data["username"] = username + if device_info: + session_data["device_info"] = json.dumps(device_info) + + return await cls.create_token("session", user_id, session_data) @classmethod async def get_session_data(cls, token: str) -> Optional[Dict[str, Any]]: - """ - Получает данные сессии - - Args: - token: JWT токен - - Returns: - Dict[str, Any]: Данные сессии или None - """ - valid, data = await cls.validate_token(token) + """Получает данные сессии""" + valid, data = await cls.validate_token(token, "session") return data if valid else None + # === УДОБНЫЕ МЕТОДЫ ДЛЯ ТОКЕНОВ ПОДТВЕРЖДЕНИЯ === + + @classmethod + async def create_verification_token( + cls, + user_id: str, + verification_type: str, + data: Dict[str, Any], + ttl: Optional[int] = None, + ) -> str: + """Создает токен подтверждения""" + token_data = {"verification_type": verification_type, **data} + + # TTL по типу подтверждения + if ttl is None: + verification_ttls = { + "email_change": 3600, # 1 час + "phone_change": 600, # 10 минут + "password_reset": 1800, # 30 минут + } + ttl = verification_ttls.get(verification_type, 3600) + + return await cls.create_token("verification", user_id, token_data, ttl) + + @classmethod + async def confirm_verification_token(cls, token_str: str) -> Optional[Dict[str, Any]]: + """Подтверждает и использует токен подтверждения (одноразовый)""" + token_data = await cls.get_token_data("verification", token_str) + if token_data: + # Удаляем токен после использования + await cls.revoke_token("verification", token_str) + return token_data + return None + + # === УДОБНЫЕ МЕТОДЫ ДЛЯ OAUTH ТОКЕНОВ === + + @classmethod + async def store_oauth_tokens( + cls, + user_id: str, + provider: str, + access_token: str, + refresh_token: Optional[str] = None, + expires_in: Optional[int] = None, + additional_data: Optional[Dict[str, Any]] = None, + ) -> bool: + """Сохраняет OAuth токены""" + try: + # Сохраняем access token + access_data = { + "token": access_token, + "provider": provider, + "expires_in": expires_in, + **(additional_data or {}), + } + + access_ttl = expires_in if expires_in else DEFAULT_TTL["oauth_access"] + await cls.create_token("oauth_access", user_id, access_data, access_ttl, provider=provider) + + # Сохраняем refresh token если есть + if refresh_token: + refresh_data = { + "token": refresh_token, + "provider": provider, + } + await cls.create_token("oauth_refresh", user_id, refresh_data, provider=provider) + + return True + + except Exception as e: + logger.error(f"Ошибка сохранения OAuth токенов: {e}") + return False + + @classmethod + async def get_oauth_token(cls, user_id: int, provider: str, token_type: str = "access") -> Optional[Dict[str, Any]]: + """Получает OAuth токен""" + oauth_type = f"oauth_{token_type}" + if oauth_type in ["oauth_access", "oauth_refresh"]: + return await cls.get_token_data(oauth_type, "", user_id, provider) # type: ignore[arg-type] + return None + + @classmethod + async def revoke_oauth_tokens(cls, user_id: str, provider: str) -> bool: + """Удаляет все OAuth токены для провайдера""" + try: + result1 = await cls.revoke_token("oauth_access", "", user_id, provider) + result2 = await cls.revoke_token("oauth_refresh", "", user_id, provider) + return result1 or result2 + except Exception as e: + logger.error(f"Ошибка удаления OAuth токенов: {e}") + return False + + # === ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ === + + @staticmethod + def generate_token() -> str: + """Генерирует криптографически стойкий токен""" + return secrets.token_urlsafe(32) + + @staticmethod + async def cleanup_expired_tokens() -> int: + """Очищает истекшие токены (Redis делает это автоматически)""" + # Redis автоматически удаляет истекшие ключи + # Здесь можем очистить связанные структуры данных + try: + user_session_keys = await redis.keys("user_tokens:*:session") + cleaned_count = 0 + + for user_tokens_key in user_session_keys: + tokens = await redis.smembers(user_tokens_key) + active_tokens = [] + + for token in tokens: + token_str = token.decode("utf-8") if isinstance(token, bytes) else str(token) + session_key = f"session:{token_str}" + exists = await redis.exists(session_key) + if exists: + active_tokens.append(token_str) + else: + cleaned_count += 1 + + # Обновляем список активных токенов + if active_tokens: + await redis.delete(user_tokens_key) + for token in active_tokens: + await redis.sadd(user_tokens_key, token) + else: + await redis.delete(user_tokens_key) + + if cleaned_count > 0: + logger.info(f"Очищено {cleaned_count} ссылок на истекшие токены") + + return cleaned_count + + except Exception as e: + logger.error(f"Ошибка очистки токенов: {e}") + return 0 + + # === ОБРАТНАЯ СОВМЕСТИМОСТЬ === + @staticmethod async def get(token_key: str) -> Optional[str]: - """ - Получает токен из хранилища. - - Args: - token_key: Ключ токена - - Returns: - str или None, если токен не найден - """ - logger.debug(f"[tokenstorage.get] Запрос токена: {token_key}") - return await redis.get(token_key) + """Обратная совместимость - получение токена по ключу""" + result = await redis.get(token_key) + if isinstance(result, bytes): + return result.decode("utf-8") + return result @staticmethod - async def exists(token_key: str) -> bool: - """ - Проверяет наличие токена в хранилище. - - Args: - token_key: Ключ токена - - Returns: - bool: True, если токен существует - """ - return bool(await redis.execute("EXISTS", token_key)) - - @staticmethod - async def save_token(token_key: str, data: Dict[str, Any], life_span: int) -> bool: - """ - Сохраняет токен в хранилище с указанным временем жизни. - - Args: - token_key: Ключ токена - data: Данные токена - life_span: Время жизни токена в секундах - - Returns: - bool: True, если токен успешно сохранен - """ + async def save_token(token_key: str, token_data: Dict[str, Any], life_span: int = 3600) -> bool: + """Обратная совместимость - сохранение токена""" try: - # Если данные не строка, преобразуем их в JSON - value = json.dumps(data) if isinstance(data, dict) else data - - # Сохраняем токен и устанавливаем время жизни - await redis.set(token_key, value, ex=life_span) - - return True + return await redis.serialize_and_set(token_key, token_data, ex=life_span) except Exception as e: - logger.error(f"[tokenstorage.save_token] Ошибка сохранения токена: {str(e)}") + logger.error(f"Ошибка сохранения токена {token_key}: {e}") return False @staticmethod - async def create_onetime(user: AuthInput) -> str: - """ - Создает одноразовый токен для пользователя. - - Args: - user: Объект пользователя - - Returns: - str: Сгенерированный токен - """ - life_span = ONETIME_TOKEN_LIFE_SPAN - exp = datetime.now(tz=timezone.utc) + timedelta(seconds=life_span) - one_time_token = JWTCodec.encode(user, exp) - - # Сохраняем токен в Redis - token_key = f"{user.id}-{user.username}-{one_time_token}" - await TokenStorage.save_token(token_key, "TRUE", life_span) - - return one_time_token + async def get_token(token_key: str) -> Optional[Dict[str, Any]]: + """Обратная совместимость - получение данных токена""" + try: + return await redis.get_and_deserialize(token_key) + except Exception as e: + logger.error(f"Ошибка получения токена {token_key}: {e}") + return None @staticmethod - async def revoke(token: str) -> bool: - """ - Отзывает токен. - - Args: - token: Токен для отзыва - - Returns: - bool: True, если токен успешно отозван - """ + async def delete_token(token_key: str) -> bool: + """Обратная совместимость - удаление токена""" try: - logger.debug("[tokenstorage.revoke] Отзыв токена") - - # Декодируем токен - payload = JWTCodec.decode(token) - if not payload: - logger.warning("[tokenstorage.revoke] Невозможно декодировать токен") - return False - - # Формируем ключи - token_key = f"{payload.user_id}-{payload.username}-{token}" - user_sessions_key = f"user_sessions:{payload.user_id}" - - # Удаляем токен и запись из списка сессий пользователя - pipe = redis.pipeline() - await pipe.delete(token_key) - await pipe.srem(user_sessions_key, token) - await pipe.execute() - - return True + result = await redis.delete(token_key) + return result > 0 except Exception as e: - logger.error(f"[tokenstorage.revoke] Ошибка отзыва токена: {str(e)}") + logger.error(f"Ошибка удаления токена {token_key}: {e}") return False - @staticmethod - async def revoke_all(user: AuthInput) -> bool: - """ - Отзывает все токены пользователя. + # Остальные методы для обратной совместимости... + async def exists(self, token_key: str) -> bool: + """Совместимость - проверка существования""" + return bool(await redis.exists(token_key)) - Args: - user: Объект пользователя + async def invalidate_token(self, token: str) -> bool: + """Совместимость - инвалидация токена""" + return await self.revoke_token("session", token) - Returns: - bool: True, если все токены успешно отозваны - """ + async def invalidate_all_tokens(self, user_id: str) -> int: + """Совместимость - инвалидация всех токенов""" + return await self.revoke_user_tokens(user_id) + + def generate_session_token(self) -> str: + """Совместимость - генерация токена сессии""" + return self.generate_token() + + async def get_session(self, session_token: str) -> Optional[Dict[str, Any]]: + """Совместимость - получение сессии""" + return await self.get_session_data(session_token) + + async def revoke_session(self, session_token: str) -> bool: + """Совместимость - отзыв сессии""" + return await self.revoke_token("session", session_token) + + async def revoke_all_user_sessions(self, user_id: Union[int, str]) -> bool: + """Совместимость - отзыв всех сессий""" + count = await self.revoke_user_tokens(str(user_id), "session") + return count > 0 + + async def get_user_sessions(self, user_id: Union[int, str]) -> list[Dict[str, Any]]: + """Совместимость - получение сессий пользователя""" try: - # Формируем ключи - user_sessions_key = f"user_sessions:{user.id}" + user_tokens_key = f"user_tokens:{user_id}:session" + tokens = await redis.smembers(user_tokens_key) - # Получаем все токены пользователя - tokens = await redis.smembers(user_sessions_key) - if not tokens: - return True + sessions = [] + for token in tokens: + token_str = token.decode("utf-8") if isinstance(token, bytes) else str(token) + session_data = await self.get_session_data(token_str) + if session_data: + session_data["token"] = token_str + sessions.append(session_data) - # Формируем список ключей для удаления - keys_to_delete = [f"{user.id}-{user.username}-{token}" for token in tokens] - keys_to_delete.append(user_sessions_key) + return sessions - # Удаляем все токены и список сессий - await redis.delete(*keys_to_delete) - - return True except Exception as e: - logger.error(f"[tokenstorage.revoke_all] Ошибка отзыва всех токенов: {str(e)}") + logger.error(f"Ошибка получения сессий пользователя: {e}") + return [] + + async def revoke_all_tokens_for_user(self, user: AuthInput) -> bool: + """Совместимость - отзыв всех токенов пользователя""" + user_id = getattr(user, "id", 0) or 0 + count = await self.revoke_user_tokens(str(user_id)) + return count > 0 + + async def get_one_time_token_value(self, token_key: str) -> Optional[str]: + """Совместимость - одноразовые токены""" + token_data = await self.get_token(token_key) + if token_data and token_data.get("valid"): + return "TRUE" + return None + + async def save_one_time_token(self, user: AuthInput, one_time_token: str, life_span: int = 300) -> bool: + """Совместимость - сохранение одноразового токена""" + user_id = getattr(user, "id", 0) or 0 + token_key = f"{user_id}-{user.username}-{one_time_token}" + token_data = {"valid": True, "user_id": user_id, "username": user.username} + return await self.save_token(token_key, token_data, life_span) + + async def extend_token_lifetime(self, token_key: str, additional_seconds: int = 3600) -> bool: + """Совместимость - продление времени жизни""" + token_data = await self.get_token(token_key) + if not token_data: return False + return await self.save_token(token_key, token_data, additional_seconds) + + async def cleanup_expired_sessions(self) -> None: + """Совместимость - очистка сессий""" + await self.cleanup_expired_tokens() diff --git a/auth/validations.py b/auth/validations.py index f1b2a6a4..6b54af4e 100644 --- a/auth/validations.py +++ b/auth/validations.py @@ -1,6 +1,6 @@ import re from datetime import datetime -from typing import Dict, List, Optional, Union +from typing import Optional, Union from pydantic import BaseModel, Field, field_validator @@ -19,7 +19,8 @@ class AuthInput(BaseModel): @classmethod def validate_user_id(cls, v: str) -> str: if not v.strip(): - raise ValueError("user_id cannot be empty") + msg = "user_id cannot be empty" + raise ValueError(msg) return v @@ -35,7 +36,8 @@ class UserRegistrationInput(BaseModel): def validate_email(cls, v: str) -> str: """Validate email format""" if not re.match(EMAIL_PATTERN, v): - raise ValueError("Invalid email format") + msg = "Invalid email format" + raise ValueError(msg) return v.lower() @field_validator("password") @@ -43,13 +45,17 @@ class UserRegistrationInput(BaseModel): def validate_password_strength(cls, v: str) -> str: """Validate password meets security requirements""" if not any(c.isupper() for c in v): - raise ValueError("Password must contain at least one uppercase letter") + msg = "Password must contain at least one uppercase letter" + raise ValueError(msg) if not any(c.islower() for c in v): - raise ValueError("Password must contain at least one lowercase letter") + msg = "Password must contain at least one lowercase letter" + raise ValueError(msg) if not any(c.isdigit() for c in v): - raise ValueError("Password must contain at least one number") + msg = "Password must contain at least one number" + raise ValueError(msg) if not any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in v): - raise ValueError("Password must contain at least one special character") + msg = "Password must contain at least one special character" + raise ValueError(msg) return v @@ -63,7 +69,8 @@ class UserLoginInput(BaseModel): @classmethod def validate_email(cls, v: str) -> str: if not re.match(EMAIL_PATTERN, v): - raise ValueError("Invalid email format") + msg = "Invalid email format" + raise ValueError(msg) return v.lower() @@ -74,7 +81,7 @@ class TokenPayload(BaseModel): username: str exp: datetime iat: datetime - scopes: Optional[List[str]] = [] + scopes: Optional[list[str]] = [] class OAuthInput(BaseModel): @@ -89,7 +96,8 @@ class OAuthInput(BaseModel): def validate_provider(cls, v: str) -> str: valid_providers = ["google", "github", "facebook"] if v.lower() not in valid_providers: - raise ValueError(f"Provider must be one of: {', '.join(valid_providers)}") + msg = f"Provider must be one of: {', '.join(valid_providers)}" + raise ValueError(msg) return v.lower() @@ -99,18 +107,20 @@ class AuthResponse(BaseModel): success: bool token: Optional[str] = None error: Optional[str] = None - user: Optional[Dict[str, Union[str, int, bool]]] = None + user: Optional[dict[str, Union[str, int, bool]]] = None @field_validator("error") @classmethod def validate_error_if_not_success(cls, v: Optional[str], info) -> Optional[str]: if not info.data.get("success") and not v: - raise ValueError("Error message required when success is False") + msg = "Error message required when success is False" + raise ValueError(msg) return v @field_validator("token") @classmethod def validate_token_if_success(cls, v: Optional[str], info) -> Optional[str]: if info.data.get("success") and not v: - raise ValueError("Token required when success is True") + msg = "Token required when success is True" + raise ValueError(msg) return v diff --git a/cache/cache.py b/cache/cache.py index b8f9b3dd..5597990c 100644 --- a/cache/cache.py +++ b/cache/cache.py @@ -29,7 +29,7 @@ for new cache operations. import asyncio import json -from typing import Any, List, Optional +from typing import Any, Callable, Dict, List, Optional, Type, Union import orjson from sqlalchemy import and_, join, select @@ -39,7 +39,7 @@ from orm.shout import Shout, ShoutAuthor, ShoutTopic from orm.topic import Topic, TopicFollower from services.db import local_session from services.redis import redis -from utils.encoders import CustomJSONEncoder +from utils.encoders import fast_json_dumps from utils.logger import root_logger as logger DEFAULT_FOLLOWS = { @@ -63,10 +63,13 @@ CACHE_KEYS = { "SHOUTS": "shouts:{}", } +# Type alias for JSON encoder +JSONEncoderType = Type[json.JSONEncoder] + # Cache topic data -async def cache_topic(topic: dict): - payload = json.dumps(topic, cls=CustomJSONEncoder) +async def cache_topic(topic: dict) -> None: + payload = fast_json_dumps(topic) await asyncio.gather( redis.execute("SET", f"topic:id:{topic['id']}", payload), redis.execute("SET", f"topic:slug:{topic['slug']}", payload), @@ -74,8 +77,8 @@ async def cache_topic(topic: dict): # Cache author data -async def cache_author(author: dict): - payload = json.dumps(author, cls=CustomJSONEncoder) +async def cache_author(author: dict) -> None: + payload = fast_json_dumps(author) await asyncio.gather( redis.execute("SET", f"author:slug:{author['slug'].strip()}", str(author["id"])), redis.execute("SET", f"author:id:{author['id']}", payload), @@ -83,21 +86,29 @@ async def cache_author(author: dict): # Cache follows data -async def cache_follows(follower_id: int, entity_type: str, entity_id: int, is_insert=True): +async def cache_follows(follower_id: int, entity_type: str, entity_id: int, is_insert: bool = True) -> None: key = f"author:follows-{entity_type}s:{follower_id}" follows_str = await redis.execute("GET", key) - follows = orjson.loads(follows_str) if follows_str else DEFAULT_FOLLOWS[entity_type] + + if follows_str: + follows = orjson.loads(follows_str) + # Для большинства типов используем пустой список ID, кроме communities + elif entity_type == "community": + follows = DEFAULT_FOLLOWS.get("communities", []) + else: + follows = [] + if is_insert: if entity_id not in follows: follows.append(entity_id) else: follows = [eid for eid in follows if eid != entity_id] - await redis.execute("SET", key, json.dumps(follows, cls=CustomJSONEncoder)) + await redis.execute("SET", key, fast_json_dumps(follows)) await update_follower_stat(follower_id, entity_type, len(follows)) # Update follower statistics -async def update_follower_stat(follower_id, entity_type, count): +async def update_follower_stat(follower_id: int, entity_type: str, count: int) -> None: follower_key = f"author:id:{follower_id}" follower_str = await redis.execute("GET", follower_key) follower = orjson.loads(follower_str) if follower_str else None @@ -107,7 +118,7 @@ async def update_follower_stat(follower_id, entity_type, count): # Get author from cache -async def get_cached_author(author_id: int, get_with_stat): +async def get_cached_author(author_id: int, get_with_stat) -> dict | None: logger.debug(f"[get_cached_author] Начало выполнения для author_id: {author_id}") author_key = f"author:id:{author_id}" @@ -122,7 +133,7 @@ async def get_cached_author(author_id: int, get_with_stat): ) return cached_data - logger.debug(f"[get_cached_author] Данные не найдены в кэше, загрузка из БД") + logger.debug("[get_cached_author] Данные не найдены в кэше, загрузка из БД") # Load from database if not found in cache q = select(Author).where(Author.id == author_id) @@ -140,7 +151,7 @@ async def get_cached_author(author_id: int, get_with_stat): ) await cache_author(author_dict) - logger.debug(f"[get_cached_author] Автор кэширован") + logger.debug("[get_cached_author] Автор кэширован") return author_dict @@ -149,7 +160,7 @@ async def get_cached_author(author_id: int, get_with_stat): # Function to get cached topic -async def get_cached_topic(topic_id: int): +async def get_cached_topic(topic_id: int) -> dict | None: """ Fetch topic data from cache or database by id. @@ -169,14 +180,14 @@ async def get_cached_topic(topic_id: int): topic = session.execute(select(Topic).where(Topic.id == topic_id)).scalar_one_or_none() if topic: topic_dict = topic.dict() - await redis.execute("SET", topic_key, json.dumps(topic_dict, cls=CustomJSONEncoder)) + await redis.execute("SET", topic_key, fast_json_dumps(topic_dict)) return topic_dict return None # Get topic by slug from cache -async def get_cached_topic_by_slug(slug: str, get_with_stat): +async def get_cached_topic_by_slug(slug: str, get_with_stat) -> dict | None: topic_key = f"topic:slug:{slug}" result = await redis.execute("GET", topic_key) if result: @@ -192,7 +203,7 @@ async def get_cached_topic_by_slug(slug: str, get_with_stat): # Get list of authors by ID from cache -async def get_cached_authors_by_ids(author_ids: List[int]) -> List[dict]: +async def get_cached_authors_by_ids(author_ids: list[int]) -> list[dict]: # Fetch all author data concurrently keys = [f"author:id:{author_id}" for author_id in author_ids] results = await asyncio.gather(*(redis.execute("GET", key) for key in keys)) @@ -207,7 +218,8 @@ async def get_cached_authors_by_ids(author_ids: List[int]) -> List[dict]: await asyncio.gather(*(cache_author(author.dict()) for author in missing_authors)) for index, author in zip(missing_indices, missing_authors): authors[index] = author.dict() - return authors + # Фильтруем None значения для корректного типа возвращаемого значения + return [author for author in authors if author is not None] async def get_cached_topic_followers(topic_id: int): @@ -238,13 +250,13 @@ async def get_cached_topic_followers(topic_id: int): .all() ] - await redis.execute("SETEX", cache_key, CACHE_TTL, orjson.dumps(followers_ids)) + await redis.execute("SETEX", cache_key, CACHE_TTL, fast_json_dumps(followers_ids)) followers = await get_cached_authors_by_ids(followers_ids) logger.debug(f"Cached {len(followers)} followers for topic #{topic_id}") return followers except Exception as e: - logger.error(f"Error getting followers for topic #{topic_id}: {str(e)}") + logger.error(f"Error getting followers for topic #{topic_id}: {e!s}") return [] @@ -267,9 +279,8 @@ async def get_cached_author_followers(author_id: int): .filter(AuthorFollower.author == author_id, Author.id != author_id) .all() ] - await redis.execute("SET", f"author:followers:{author_id}", orjson.dumps(followers_ids)) - followers = await get_cached_authors_by_ids(followers_ids) - return followers + await redis.execute("SET", f"author:followers:{author_id}", fast_json_dumps(followers_ids)) + return await get_cached_authors_by_ids(followers_ids) # Get cached follower authors @@ -289,10 +300,9 @@ async def get_cached_follower_authors(author_id: int): .where(AuthorFollower.follower == author_id) ).all() ] - await redis.execute("SET", f"author:follows-authors:{author_id}", orjson.dumps(authors_ids)) + await redis.execute("SET", f"author:follows-authors:{author_id}", fast_json_dumps(authors_ids)) - authors = await get_cached_authors_by_ids(authors_ids) - return authors + return await get_cached_authors_by_ids(authors_ids) # Get cached follower topics @@ -311,7 +321,7 @@ async def get_cached_follower_topics(author_id: int): .where(TopicFollower.follower == author_id) .all() ] - await redis.execute("SET", f"author:follows-topics:{author_id}", orjson.dumps(topics_ids)) + await redis.execute("SET", f"author:follows-topics:{author_id}", fast_json_dumps(topics_ids)) topics = [] for topic_id in topics_ids: @@ -350,7 +360,7 @@ async def get_cached_author_by_id(author_id: int, get_with_stat): author = authors[0] author_dict = author.dict() await asyncio.gather( - redis.execute("SET", f"author:id:{author.id}", orjson.dumps(author_dict)), + redis.execute("SET", f"author:id:{author.id}", fast_json_dumps(author_dict)), ) return author_dict @@ -391,7 +401,7 @@ async def get_cached_topic_authors(topic_id: int): ) authors_ids = [author_id for (author_id,) in session.execute(query).all()] # Cache the retrieved author IDs - await redis.execute("SET", rkey, orjson.dumps(authors_ids)) + await redis.execute("SET", rkey, fast_json_dumps(authors_ids)) # Retrieve full author details from cached IDs if authors_ids: @@ -402,7 +412,7 @@ async def get_cached_topic_authors(topic_id: int): return [] -async def invalidate_shouts_cache(cache_keys: List[str]): +async def invalidate_shouts_cache(cache_keys: list[str]) -> None: """ Инвалидирует кэш выборок публикаций по переданным ключам. """ @@ -432,23 +442,23 @@ async def invalidate_shouts_cache(cache_keys: List[str]): logger.error(f"Error invalidating cache key {cache_key}: {e}") -async def cache_topic_shouts(topic_id: int, shouts: List[dict]): +async def cache_topic_shouts(topic_id: int, shouts: list[dict]) -> None: """Кэширует список публикаций для темы""" key = f"topic_shouts_{topic_id}" - payload = json.dumps(shouts, cls=CustomJSONEncoder) + payload = fast_json_dumps(shouts) await redis.execute("SETEX", key, CACHE_TTL, payload) -async def get_cached_topic_shouts(topic_id: int) -> List[dict]: +async def get_cached_topic_shouts(topic_id: int) -> list[dict]: """Получает кэшированный список публикаций для темы""" key = f"topic_shouts_{topic_id}" cached = await redis.execute("GET", key) if cached: return orjson.loads(cached) - return None + return [] -async def cache_related_entities(shout: Shout): +async def cache_related_entities(shout: Shout) -> None: """ Кэширует все связанные с публикацией сущности (авторов и темы) """ @@ -460,7 +470,7 @@ async def cache_related_entities(shout: Shout): await asyncio.gather(*tasks) -async def invalidate_shout_related_cache(shout: Shout, author_id: int): +async def invalidate_shout_related_cache(shout: Shout, author_id: int) -> None: """ Инвалидирует весь кэш, связанный с публикацией и её связями @@ -528,7 +538,7 @@ async def cache_by_id(entity, entity_id: int, cache_method): result = get_with_stat(caching_query) if not result or not result[0]: logger.warning(f"{entity.__name__} with id {entity_id} not found") - return + return None x = result[0] d = x.dict() await cache_method(d) @@ -546,7 +556,7 @@ async def cache_data(key: str, data: Any, ttl: Optional[int] = None) -> None: ttl: Время жизни кеша в секундах (None - бессрочно) """ try: - payload = json.dumps(data, cls=CustomJSONEncoder) + payload = fast_json_dumps(data) if ttl: await redis.execute("SETEX", key, ttl, payload) else: @@ -599,7 +609,7 @@ async def invalidate_cache_by_prefix(prefix: str) -> None: # Универсальная функция для получения и кеширования данных async def cached_query( cache_key: str, - query_func: callable, + query_func: Callable, ttl: Optional[int] = None, force_refresh: bool = False, use_key_format: bool = True, @@ -624,7 +634,7 @@ async def cached_query( actual_key = cache_key if use_key_format and "{}" in cache_key: # Look for a template match in CACHE_KEYS - for key_name, key_format in CACHE_KEYS.items(): + for key_format in CACHE_KEYS.values(): if cache_key == key_format: # We have a match, now look for the id or value to format with for param_name, param_value in query_params.items(): @@ -651,3 +661,207 @@ async def cached_query( if not force_refresh: return await get_cached_data(actual_key) raise + + +async def save_topic_to_cache(topic: Dict[str, Any]) -> None: + """Сохраняет топик в кеш""" + try: + topic_id = topic.get("id") + if not topic_id: + return + + topic_key = f"topic:{topic_id}" + payload = fast_json_dumps(topic) + await redis.execute("SET", topic_key, payload) + await redis.execute("EXPIRE", topic_key, 3600) # 1 час + logger.debug(f"Topic {topic_id} saved to cache") + except Exception as e: + logger.error(f"Failed to save topic to cache: {e}") + + +async def save_author_to_cache(author: Dict[str, Any]) -> None: + """Сохраняет автора в кеш""" + try: + author_id = author.get("id") + if not author_id: + return + + author_key = f"author:{author_id}" + payload = fast_json_dumps(author) + await redis.execute("SET", author_key, payload) + await redis.execute("EXPIRE", author_key, 1800) # 30 минут + logger.debug(f"Author {author_id} saved to cache") + except Exception as e: + logger.error(f"Failed to save author to cache: {e}") + + +async def cache_follows_by_follower(author_id: int, follows: List[Dict[str, Any]]) -> None: + """Кеширует подписки пользователя""" + try: + key = f"follows:author:{author_id}" + await redis.execute("SET", key, fast_json_dumps(follows)) + await redis.execute("EXPIRE", key, 1800) # 30 минут + logger.debug(f"Follows cached for author {author_id}") + except Exception as e: + logger.error(f"Failed to cache follows: {e}") + + +async def get_topic_from_cache(topic_id: Union[int, str]) -> Optional[Dict[str, Any]]: + """Получает топик из кеша""" + try: + topic_key = f"topic:{topic_id}" + cached_data = await redis.get(topic_key) + + if cached_data: + if isinstance(cached_data, bytes): + cached_data = cached_data.decode("utf-8") + return json.loads(cached_data) + return None + except Exception as e: + logger.error(f"Failed to get topic from cache: {e}") + return None + + +async def get_author_from_cache(author_id: Union[int, str]) -> Optional[Dict[str, Any]]: + """Получает автора из кеша""" + try: + author_key = f"author:{author_id}" + cached_data = await redis.get(author_key) + + if cached_data: + if isinstance(cached_data, bytes): + cached_data = cached_data.decode("utf-8") + return json.loads(cached_data) + return None + except Exception as e: + logger.error(f"Failed to get author from cache: {e}") + return None + + +async def cache_topic_with_content(topic_dict: Dict[str, Any]) -> None: + """Кеширует топик с контентом""" + try: + topic_id = topic_dict.get("id") + if topic_id: + topic_key = f"topic_content:{topic_id}" + await redis.execute("SET", topic_key, fast_json_dumps(topic_dict)) + await redis.execute("EXPIRE", topic_key, 7200) # 2 часа + logger.debug(f"Topic content {topic_id} cached") + except Exception as e: + logger.error(f"Failed to cache topic content: {e}") + + +async def get_cached_topic_content(topic_id: Union[int, str]) -> Optional[Dict[str, Any]]: + """Получает кешированный контент топика""" + try: + topic_key = f"topic_content:{topic_id}" + cached_data = await redis.get(topic_key) + + if cached_data: + if isinstance(cached_data, bytes): + cached_data = cached_data.decode("utf-8") + return json.loads(cached_data) + return None + except Exception as e: + logger.error(f"Failed to get cached topic content: {e}") + return None + + +async def save_shouts_to_cache(shouts: List[Dict[str, Any]], cache_key: str = "recent_shouts") -> None: + """Сохраняет статьи в кеш""" + try: + payload = fast_json_dumps(shouts) + await redis.execute("SET", cache_key, payload) + await redis.execute("EXPIRE", cache_key, 900) # 15 минут + logger.debug(f"Shouts saved to cache with key: {cache_key}") + except Exception as e: + logger.error(f"Failed to save shouts to cache: {e}") + + +async def get_shouts_from_cache(cache_key: str = "recent_shouts") -> Optional[List[Dict[str, Any]]]: + """Получает статьи из кеша""" + try: + cached_data = await redis.get(cache_key) + + if cached_data: + if isinstance(cached_data, bytes): + cached_data = cached_data.decode("utf-8") + return json.loads(cached_data) + return None + except Exception as e: + logger.error(f"Failed to get shouts from cache: {e}") + return None + + +async def cache_search_results(query: str, data: List[Dict[str, Any]], ttl: int = 600) -> None: + """Кеширует результаты поиска""" + try: + search_key = f"search:{query.lower().replace(' ', '_')}" + payload = fast_json_dumps(data) + await redis.execute("SET", search_key, payload) + await redis.execute("EXPIRE", search_key, ttl) + logger.debug(f"Search results cached for query: {query}") + except Exception as e: + logger.error(f"Failed to cache search results: {e}") + + +async def get_cached_search_results(query: str) -> Optional[List[Dict[str, Any]]]: + """Получает кешированные результаты поиска""" + try: + search_key = f"search:{query.lower().replace(' ', '_')}" + cached_data = await redis.get(search_key) + + if cached_data: + if isinstance(cached_data, bytes): + cached_data = cached_data.decode("utf-8") + return json.loads(cached_data) + return None + except Exception as e: + logger.error(f"Failed to get cached search results: {e}") + return None + + +async def invalidate_topic_cache(topic_id: Union[int, str]) -> None: + """Инвалидирует кеш топика""" + try: + topic_key = f"topic:{topic_id}" + content_key = f"topic_content:{topic_id}" + await redis.delete(topic_key) + await redis.delete(content_key) + logger.debug(f"Cache invalidated for topic {topic_id}") + except Exception as e: + logger.error(f"Failed to invalidate topic cache: {e}") + + +async def invalidate_author_cache(author_id: Union[int, str]) -> None: + """Инвалидирует кеш автора""" + try: + author_key = f"author:{author_id}" + follows_key = f"follows:author:{author_id}" + await redis.delete(author_key) + await redis.delete(follows_key) + logger.debug(f"Cache invalidated for author {author_id}") + except Exception as e: + logger.error(f"Failed to invalidate author cache: {e}") + + +async def clear_all_cache() -> None: + """Очищает весь кеш (использовать осторожно)""" + try: + # Get all cache keys + topic_keys = await redis.keys("topic:*") + author_keys = await redis.keys("author:*") + search_keys = await redis.keys("search:*") + follows_keys = await redis.keys("follows:*") + + all_keys = topic_keys + author_keys + search_keys + follows_keys + + if all_keys: + for key in all_keys: + await redis.delete(key) + logger.info(f"Cleared {len(all_keys)} cache entries") + else: + logger.info("No cache entries to clear") + + except Exception as e: + logger.error(f"Failed to clear cache: {e}") diff --git a/cache/precache.py b/cache/precache.py index 76f1c1a3..a8f80f0e 100644 --- a/cache/precache.py +++ b/cache/precache.py @@ -1,5 +1,4 @@ import asyncio -import json from sqlalchemy import and_, join, select @@ -10,23 +9,23 @@ from orm.topic import Topic, TopicFollower from resolvers.stat import get_with_stat from services.db import local_session from services.redis import redis -from utils.encoders import CustomJSONEncoder +from utils.encoders import fast_json_dumps from utils.logger import root_logger as logger # Предварительное кеширование подписчиков автора -async def precache_authors_followers(author_id, session): - authors_followers = set() +async def precache_authors_followers(author_id, session) -> None: + authors_followers: set[int] = set() followers_query = select(AuthorFollower.follower).where(AuthorFollower.author == author_id) result = session.execute(followers_query) authors_followers.update(row[0] for row in result if row[0]) - followers_payload = json.dumps(list(authors_followers), cls=CustomJSONEncoder) + followers_payload = fast_json_dumps(list(authors_followers)) await redis.execute("SET", f"author:followers:{author_id}", followers_payload) # Предварительное кеширование подписок автора -async def precache_authors_follows(author_id, session): +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_shouts_query = select(ShoutReactionsFollower.shout).where(ShoutReactionsFollower.follower == author_id) @@ -35,9 +34,9 @@ async def precache_authors_follows(author_id, session): follows_authors = {row[0] for row in session.execute(follows_authors_query) if row[0]} follows_shouts = {row[0] for row in session.execute(follows_shouts_query) if row[0]} - topics_payload = json.dumps(list(follows_topics), cls=CustomJSONEncoder) - authors_payload = json.dumps(list(follows_authors), cls=CustomJSONEncoder) - shouts_payload = json.dumps(list(follows_shouts), cls=CustomJSONEncoder) + topics_payload = fast_json_dumps(list(follows_topics)) + authors_payload = fast_json_dumps(list(follows_authors)) + shouts_payload = fast_json_dumps(list(follows_shouts)) await asyncio.gather( redis.execute("SET", f"author:follows-topics:{author_id}", topics_payload), @@ -47,7 +46,7 @@ async def precache_authors_follows(author_id, session): # Предварительное кеширование авторов тем -async def precache_topics_authors(topic_id: int, session): +async def precache_topics_authors(topic_id: int, session) -> None: topic_authors_query = ( select(ShoutAuthor.author) .select_from(join(ShoutTopic, Shout, ShoutTopic.shout == Shout.id)) @@ -62,40 +61,94 @@ async def precache_topics_authors(topic_id: int, session): ) topic_authors = {row[0] for row in session.execute(topic_authors_query) if row[0]} - authors_payload = json.dumps(list(topic_authors), cls=CustomJSONEncoder) + authors_payload = fast_json_dumps(list(topic_authors)) await redis.execute("SET", f"topic:authors:{topic_id}", authors_payload) # Предварительное кеширование подписчиков тем -async def precache_topics_followers(topic_id: int, session): +async def precache_topics_followers(topic_id: int, session) -> None: followers_query = select(TopicFollower.follower).where(TopicFollower.topic == topic_id) topic_followers = {row[0] for row in session.execute(followers_query) if row[0]} - followers_payload = json.dumps(list(topic_followers), cls=CustomJSONEncoder) + followers_payload = fast_json_dumps(list(topic_followers)) await redis.execute("SET", f"topic:followers:{topic_id}", followers_payload) -async def precache_data(): +async def precache_data() -> None: logger.info("precaching...") try: - key = "authorizer_env" - # cache reset - value = await redis.execute("HGETALL", key) + # Список паттернов ключей, которые нужно сохранить при FLUSHDB + preserve_patterns = [ + "migrated_views_*", # Данные миграции просмотров + "session:*", # Сессии пользователей + "env_vars:*", # Переменные окружения + "oauth_*", # OAuth токены + ] + + # Сохраняем все важные ключи перед очисткой + all_keys_to_preserve = [] + preserved_data = {} + + for pattern in preserve_patterns: + keys = await redis.execute("KEYS", pattern) + if keys: + all_keys_to_preserve.extend(keys) + logger.info(f"Найдено {len(keys)} ключей по паттерну '{pattern}'") + + if all_keys_to_preserve: + logger.info(f"Сохраняем {len(all_keys_to_preserve)} важных ключей перед FLUSHDB") + for key in all_keys_to_preserve: + try: + # Определяем тип ключа и сохраняем данные + key_type = await redis.execute("TYPE", key) + if key_type == "hash": + preserved_data[key] = await redis.execute("HGETALL", key) + elif key_type == "string": + preserved_data[key] = await redis.execute("GET", key) + elif key_type == "set": + preserved_data[key] = await redis.execute("SMEMBERS", key) + elif key_type == "list": + preserved_data[key] = await redis.execute("LRANGE", key, 0, -1) + elif key_type == "zset": + preserved_data[key] = await redis.execute("ZRANGE", key, 0, -1, "WITHSCORES") + except Exception as e: + logger.error(f"Ошибка при сохранении ключа {key}: {e}") + continue + await redis.execute("FLUSHDB") logger.info("redis: FLUSHDB") - # Преобразуем словарь в список аргументов для HSET - if value: - # Если значение - словарь, преобразуем его в плоский список для HSET - if isinstance(value, dict): - flattened = [] - for field, val in value.items(): - flattened.extend([field, val]) - await redis.execute("HSET", key, *flattened) - else: - # Предполагаем, что значение уже содержит список - await redis.execute("HSET", key, *value) - logger.info(f"redis hash '{key}' was restored") + # Восстанавливаем все сохранённые ключи + if preserved_data: + logger.info(f"Восстанавливаем {len(preserved_data)} сохранённых ключей") + for key, data in preserved_data.items(): + try: + if isinstance(data, dict) and data: + # Hash + flattened = [] + for field, val in data.items(): + flattened.extend([field, val]) + if flattened: + await redis.execute("HSET", key, *flattened) + elif isinstance(data, str) and data: + # String + await redis.execute("SET", key, data) + elif isinstance(data, list) and data: + # List или ZSet + if any(isinstance(item, (list, tuple)) and len(item) == 2 for item in data): + # ZSet with scores + for item in data: + if isinstance(item, (list, tuple)) and len(item) == 2: + await redis.execute("ZADD", key, item[1], item[0]) + else: + # Regular list + await redis.execute("LPUSH", key, *data) + elif isinstance(data, set) and data: + # Set + await redis.execute("SADD", key, *data) + except Exception as e: + logger.error(f"Ошибка при восстановлении ключа {key}: {e}") + continue with local_session() as session: # topics diff --git a/cache/revalidator.py b/cache/revalidator.py index 6553940c..76ebdf3a 100644 --- a/cache/revalidator.py +++ b/cache/revalidator.py @@ -1,4 +1,5 @@ import asyncio +import contextlib from cache.cache import ( cache_author, @@ -15,16 +16,21 @@ CACHE_REVALIDATION_INTERVAL = 300 # 5 minutes class CacheRevalidationManager: - def __init__(self, interval=CACHE_REVALIDATION_INTERVAL): + def __init__(self, interval=CACHE_REVALIDATION_INTERVAL) -> None: """Инициализация менеджера с заданным интервалом проверки (в секундах).""" self.interval = interval - self.items_to_revalidate = {"authors": set(), "topics": set(), "shouts": set(), "reactions": set()} + self.items_to_revalidate: dict[str, set[str]] = { + "authors": set(), + "topics": set(), + "shouts": set(), + "reactions": set(), + } self.lock = asyncio.Lock() self.running = True self.MAX_BATCH_SIZE = 10 # Максимальное количество элементов для поштучной обработки self._redis = redis # Добавлена инициализация _redis для доступа к Redis-клиенту - async def start(self): + async def start(self) -> None: """Запуск фонового воркера для ревалидации кэша.""" # Проверяем, что у нас есть соединение с Redis if not self._redis._client: @@ -36,7 +42,7 @@ class CacheRevalidationManager: self.task = asyncio.create_task(self.revalidate_cache()) - async def revalidate_cache(self): + async def revalidate_cache(self) -> None: """Циклическая проверка и ревалидация кэша каждые self.interval секунд.""" try: while self.running: @@ -47,7 +53,7 @@ class CacheRevalidationManager: except Exception as e: logger.error(f"An error occurred in the revalidation worker: {e}") - async def process_revalidation(self): + async def process_revalidation(self) -> None: """Обновление кэша для всех сущностей, требующих ревалидации.""" # Проверяем соединение с Redis if not self._redis._client: @@ -61,9 +67,12 @@ class CacheRevalidationManager: if author_id == "all": await invalidate_cache_by_prefix("authors") break - author = await get_cached_author(author_id, get_with_stat) - if author: - await cache_author(author) + try: + author = await get_cached_author(int(author_id), get_with_stat) + if author: + await cache_author(author) + except ValueError: + logger.warning(f"Invalid author_id: {author_id}") self.items_to_revalidate["authors"].clear() # Ревалидация кэша тем @@ -73,9 +82,12 @@ class CacheRevalidationManager: if topic_id == "all": await invalidate_cache_by_prefix("topics") break - topic = await get_cached_topic(topic_id) - if topic: - await cache_topic(topic) + try: + topic = await get_cached_topic(int(topic_id)) + if topic: + await cache_topic(topic) + except ValueError: + logger.warning(f"Invalid topic_id: {topic_id}") self.items_to_revalidate["topics"].clear() # Ревалидация шаутов (публикаций) @@ -146,26 +158,24 @@ class CacheRevalidationManager: self.items_to_revalidate["reactions"].clear() - def mark_for_revalidation(self, entity_id, entity_type): + def mark_for_revalidation(self, entity_id, entity_type) -> None: """Отметить сущность для ревалидации.""" if entity_id and entity_type: self.items_to_revalidate[entity_type].add(entity_id) - def invalidate_all(self, entity_type): + def invalidate_all(self, entity_type) -> None: """Пометить для инвалидации все элементы указанного типа.""" logger.debug(f"Marking all {entity_type} for invalidation") # Особый флаг для полной инвалидации self.items_to_revalidate[entity_type].add("all") - async def stop(self): + async def stop(self) -> None: """Остановка фонового воркера.""" self.running = False if hasattr(self, "task"): self.task.cancel() - try: + with contextlib.suppress(asyncio.CancelledError): await self.task - except asyncio.CancelledError: - pass revalidation_manager = CacheRevalidationManager() diff --git a/cache/triggers.py b/cache/triggers.py index 22e451d8..b06a32b2 100644 --- a/cache/triggers.py +++ b/cache/triggers.py @@ -9,7 +9,7 @@ from services.db import local_session from utils.logger import root_logger as logger -def mark_for_revalidation(entity, *args): +def mark_for_revalidation(entity, *args) -> None: """Отметка сущности для ревалидации.""" entity_type = ( "authors" @@ -26,7 +26,7 @@ def mark_for_revalidation(entity, *args): revalidation_manager.mark_for_revalidation(entity.id, entity_type) -def after_follower_handler(mapper, connection, target, is_delete=False): +def after_follower_handler(mapper, connection, target, is_delete=False) -> None: """Обработчик добавления, обновления или удаления подписки.""" entity_type = None if isinstance(target, AuthorFollower): @@ -44,7 +44,7 @@ def after_follower_handler(mapper, connection, target, is_delete=False): revalidation_manager.mark_for_revalidation(target.follower, "authors") -def after_shout_handler(mapper, connection, target): +def after_shout_handler(mapper, connection, target) -> None: """Обработчик изменения статуса публикации""" if not isinstance(target, Shout): return @@ -63,7 +63,7 @@ def after_shout_handler(mapper, connection, target): revalidation_manager.mark_for_revalidation(target.id, "shouts") -def after_reaction_handler(mapper, connection, target): +def after_reaction_handler(mapper, connection, target) -> None: """Обработчик для комментариев""" if not isinstance(target, Reaction): return @@ -104,7 +104,7 @@ def after_reaction_handler(mapper, connection, target): revalidation_manager.mark_for_revalidation(topic.id, "topics") -def events_register(): +def events_register() -> None: """Регистрация обработчиков событий для всех сущностей.""" event.listen(ShoutAuthor, "after_insert", mark_for_revalidation) event.listen(ShoutAuthor, "after_update", mark_for_revalidation) @@ -115,7 +115,7 @@ def events_register(): event.listen( AuthorFollower, "after_delete", - lambda *args: after_follower_handler(*args, is_delete=True), + lambda mapper, connection, target: after_follower_handler(mapper, connection, target, is_delete=True), ) event.listen(TopicFollower, "after_insert", after_follower_handler) @@ -123,7 +123,7 @@ def events_register(): event.listen( TopicFollower, "after_delete", - lambda *args: after_follower_handler(*args, is_delete=True), + lambda mapper, connection, target: after_follower_handler(mapper, connection, target, is_delete=True), ) event.listen(ShoutReactionsFollower, "after_insert", after_follower_handler) @@ -131,7 +131,7 @@ def events_register(): event.listen( ShoutReactionsFollower, "after_delete", - lambda *args: after_follower_handler(*args, is_delete=True), + lambda mapper, connection, target: after_follower_handler(mapper, connection, target, is_delete=True), ) event.listen(Reaction, "after_update", mark_for_revalidation) diff --git a/dev.py b/dev.py index 05da3979..53cd6f1e 100644 --- a/dev.py +++ b/dev.py @@ -1,13 +1,15 @@ import os import subprocess from pathlib import Path +from typing import Optional from granian import Granian +from granian.constants import Interfaces from utils.logger import root_logger as logger -def check_mkcert_installed(): +def check_mkcert_installed() -> Optional[bool]: """ Проверяет, установлен ли инструмент mkcert в системе @@ -18,7 +20,7 @@ def check_mkcert_installed(): True """ try: - subprocess.run(["mkcert", "-version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + subprocess.run(["mkcert", "-version"], capture_output=True, check=False) return True except FileNotFoundError: return False @@ -58,9 +60,9 @@ def generate_certificates(domain="localhost", cert_file="localhost.pem", key_fil logger.info(f"Создание сертификатов для {domain} с помощью mkcert...") result = subprocess.run( ["mkcert", "-cert-file", cert_file, "-key-file", key_file, domain], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + capture_output=True, text=True, + check=False, ) if result.returncode != 0: @@ -70,11 +72,11 @@ def generate_certificates(domain="localhost", cert_file="localhost.pem", key_fil logger.info(f"Сертификаты созданы: {cert_file}, {key_file}") return cert_file, key_file except Exception as e: - logger.error(f"Не удалось создать сертификаты: {str(e)}") + logger.error(f"Не удалось создать сертификаты: {e!s}") return None, None -def run_server(host="0.0.0.0", port=8000, workers=1): +def run_server(host="0.0.0.0", port=8000, workers=1) -> None: """ Запускает сервер Granian с поддержкой HTTPS при необходимости @@ -107,7 +109,7 @@ def run_server(host="0.0.0.0", port=8000, workers=1): address=host, port=port, workers=workers, - interface="asgi", + interface=Interfaces.ASGI, target="main:app", ssl_cert=Path(cert_file), ssl_key=Path(key_file), @@ -115,7 +117,7 @@ def run_server(host="0.0.0.0", port=8000, workers=1): server.serve() except Exception as e: # В случае проблем с Granian, пробуем запустить через Uvicorn - logger.error(f"Ошибка при запуске Granian: {str(e)}") + logger.error(f"Ошибка при запуске Granian: {e!s}") if __name__ == "__main__": diff --git a/docs/README.md b/docs/README.md index 556f5183..b189a2a8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,11 @@ JWT_SECRET_KEY = "your-secret-key" # секретный ключ для JWT т SESSION_TOKEN_LIFE_SPAN = 60 * 60 * 24 * 30 # время жизни сессии (30 дней) ``` +### Authentication & Security +- [Security System](security.md) - Password and email management +- [OAuth Token Management](oauth.md) - OAuth provider token storage in Redis +- [Following System](follower.md) - User subscription system + ### Реакции и комментарии Модуль обработки пользовательских реакций и комментариев. @@ -51,7 +56,7 @@ SESSION_TOKEN_LIFE_SPAN = 60 * 60 * 24 * 30 # время жизни сесси - Проверка доступа по email или правам в системе RBAC Маршруты: -- `/admin` - административная панель с проверкой прав доступа +- `/admin` - административная панель с проверкой прав доступа ## Запуск сервера @@ -93,4 +98,4 @@ python run.py --https --domain "localhost.localdomain" **Преимущества mkcert:** - Сертификаты распознаются браузером как доверенные (нет предупреждений) - Работает на всех платформах (macOS, Linux, Windows) -- Простая установка и настройка \ No newline at end of file +- Простая установка и настройка diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 00000000..79051dbf --- /dev/null +++ b/docs/api.md @@ -0,0 +1,40 @@ + + +## API Documentation + +### GraphQL Schema +- Mutations: Authentication, content management, security +- Queries: Content retrieval, user data +- Types: Author, Topic, Shout, Community + +### Key Features + +#### Security Management +- Password change with validation +- Email change with confirmation +- Two-factor authentication flow +- Protected fields for user privacy + +#### Content Management +- Publication system with drafts +- Topic and community organization +- Author collaboration tools +- Real-time notifications + +#### Following System +- Subscribe to authors and topics +- Cache-optimized operations +- Consistent UI state management + +## Database + +### Models +- `Author` - User accounts with RBAC +- `Shout` - Publications and articles +- `Topic` - Content categorization +- `Community` - User groups + +### Cache System +- Redis-based caching +- Automatic cache invalidation +- Optimized for real-time updates diff --git a/docs/auth.md b/docs/auth.md index b7a17039..bb90e4db 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -247,12 +247,12 @@ import { useAuthContext } from '../auth' export const AdminPanel: Component = () => { const auth = useAuthContext() - + // Проверяем наличие роли админа if (!auth.hasRole('admin')) { return
Доступ запрещен
} - + return (

Панель администратора

@@ -349,25 +349,25 @@ from auth.decorators import login_required from auth.models import Author @login_required -async def update_article(_, info, article_id: int, data: dict): +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 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 ``` @@ -381,25 +381,24 @@ from auth.password import hash_password def create_admin(email: str, password: str): """Создание администратора""" - + # Получаем роль админа admin_role = db.query(Role).filter(Role.id == 'admin').first() - + # Создаем пользователя admin = Author( email=email, password=hash_password(password), - is_active=True, email_verified=True ) - + # Назначаем роль admin.roles.append(admin_role) - + # Сохраняем db.add(admin) db.commit() - + return admin ``` @@ -554,19 +553,19 @@ export const ProfileForm: Component = () => { // 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 } @@ -575,24 +574,24 @@ 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 (
@@ -613,7 +612,7 @@ from auth.models import Author async def notify_login(user: Author, ip: str, device: str): """Отправка уведомления о новом входе""" - + # Формируем текст text = f""" Новый вход в аккаунт: @@ -621,14 +620,14 @@ async def notify_login(user: Author, ip: str, device: str): Устройство: {device} Время: {datetime.now()} """ - + # Отправляем email await send_email( to=user.email, subject='Новый вход в аккаунт', text=text ) - + # Логируем logger.info(f'New login for user {user.id} from {ip}') ``` @@ -647,14 +646,14 @@ async def test_google_oauth_success(client, mock_google): '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 @@ -673,10 +672,10 @@ def test_user_permissions(): operation='edit' ) role.permissions.append(permission) - + user = Author(email='test@test.com') user.roles.append(role) - + # Проверяем разрешения assert user.has_permission('articles', 'edit') assert not user.has_permission('articles', 'delete') @@ -696,23 +695,23 @@ 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) ``` @@ -722,11 +721,11 @@ class RateLimitMiddleware(BaseHTTPMiddleware): # auth/login.py async def handle_login_attempt(user: Author, success: bool): """Обработка попытки входа""" - + if not success: # Увеличиваем счетчик неудачных попыток user.increment_failed_login() - + if user.is_locked(): # Аккаунт заблокирован raise AuthError( @@ -756,7 +755,7 @@ def log_auth_event( ): """ Логирование событий авторизации - + Args: event_type: Тип события (login, logout, etc) user_id: ID пользователя @@ -796,4 +795,4 @@ login_duration = Histogram( 'auth_login_duration_seconds', 'Time spent processing login' ) -``` \ No newline at end of file +``` diff --git a/docs/caching.md b/docs/caching.md index e8b55a58..dd2ac5c2 100644 --- a/docs/caching.md +++ b/docs/caching.md @@ -150,15 +150,15 @@ class CacheRevalidationManager: def __init__(self, interval=CACHE_REVALIDATION_INTERVAL): # ... self._redis = redis # Прямая ссылка на сервис Redis - + async def start(self): # Проверка и установка соединения с Redis # ... - + async def process_revalidation(self): # Обработка элементов для ревалидации # ... - + def mark_for_revalidation(self, entity_id, entity_type): # Добавляет сущность в очередь на ревалидацию # ... @@ -213,14 +213,14 @@ async def precache_data(): async def get_topics_with_stats(limit=10, offset=0, by="title"): # Формирование ключа кеша по конвенции cache_key = f"topics:stats:limit={limit}:offset={offset}:sort={by}" - + cached_data = await get_cached_data(cache_key) if cached_data: return cached_data - + # Выполнение запроса к базе данных result = ... # логика получения данных - + await cache_data(cache_key, result, ttl=300) return result ``` @@ -232,16 +232,16 @@ async def get_topics_with_stats(limit=10, offset=0, by="title"): async def fetch_data(limit, offset, by): # Логика получения данных return result - + # Формирование ключа кеша по конвенции cache_key = f"topics:stats:limit={limit}:offset={offset}:sort={by}" - + return await cached_query( - cache_key, - fetch_data, - ttl=300, - limit=limit, - offset=offset, + cache_key, + fetch_data, + ttl=300, + limit=limit, + offset=offset, by=by ) ``` @@ -252,10 +252,10 @@ async def get_topics_with_stats(limit=10, offset=0, by="title"): async def update_author(author_id, data): # Обновление данных в базе # ... - + # Инвалидация только кеша этого автора await invalidate_authors_cache(author_id) - + return result ``` diff --git a/docs/comments-pagination.md b/docs/comments-pagination.md index 0f8f7261..4e269ded 100644 --- a/docs/comments-pagination.md +++ b/docs/comments-pagination.md @@ -150,7 +150,7 @@ const { data } = await client.query({ 1. Для эффективной работы со сложными ветками обсуждений рекомендуется: - Сначала загружать только корневые комментарии с первыми N ответами - - При наличии дополнительных ответов (когда `stat.comments_count > first_replies.length`) + - При наличии дополнительных ответов (когда `stat.comments_count > first_replies.length`) добавить кнопку "Показать все ответы" - При нажатии на кнопку загружать дополнительные ответы с помощью запроса с указанным `parentId` @@ -162,4 +162,4 @@ const { data } = await client.query({ 3. Для улучшения производительности: - Кешировать результаты запросов на клиенте - Использовать оптимистичные обновления при добавлении/редактировании комментариев - - При необходимости загружать комментарии порциями (ленивая загрузка) \ No newline at end of file + - При необходимости загружать комментарии порциями (ленивая загрузка) diff --git a/docs/features.md b/docs/features.md index 1a5a7678..fae5cccc 100644 --- a/docs/features.md +++ b/docs/features.md @@ -2,7 +2,7 @@ - Интеграция с Google Analytics для отслеживания просмотров публикаций - Подсчет уникальных пользователей и общего количества просмотров -- Автоматическое обновление статистики при запросе данных публикации +- Автоматическое обновление статистики при запросе данных публикации ## Мультидоменная авторизация @@ -36,4 +36,4 @@ - Использование поля `stat.comments_count` для отображения количества ответов на комментарий - Добавление специального поля `first_replies` для хранения первых ответов на комментарий - Поддержка различных методов сортировки (новые, старые, популярные) -- Оптимизированные SQL запросы для минимизации нагрузки на базу данных \ No newline at end of file +- Оптимизированные SQL запросы для минимизации нагрузки на базу данных diff --git a/docs/follower.md b/docs/follower.md index a3c0d291..06e8d666 100644 --- a/docs/follower.md +++ b/docs/follower.md @@ -137,7 +137,7 @@ if sub: else: return {"error": "following was not found", f"{entity_type}s": follows} # follows was [] -# UNFOLLOW - After (FIXED) +# UNFOLLOW - After (FIXED) if sub: # ... process unfollow # Invalidate cache @@ -166,7 +166,7 @@ if existing_sub: else: # ... create subscription -# Always invalidate cache and get current state +# Always invalidate cache and get current state await redis.execute("DEL", f"author:follows-{entity_type}s:{follower_id}") existing_follows = await get_cached_follows_method(follower_id) return {f"{entity_type}s": existing_follows, "error": error} @@ -213,7 +213,7 @@ python test_unfollow_fix.py ### Test Coverage - ✅ Unfollow existing subscription -- ✅ Unfollow non-existent subscription +- ✅ Unfollow non-existent subscription - ✅ Cache invalidation - ✅ Proper error handling -- ✅ UI state consistency \ No newline at end of file +- ✅ UI state consistency diff --git a/docs/load_shouts.md b/docs/load_shouts.md index d0a6d897..6ace74fd 100644 --- a/docs/load_shouts.md +++ b/docs/load_shouts.md @@ -77,4 +77,4 @@ - Проверка прав доступа - Фильтрация удаленного контента - Защита от SQL-инъекций -- Валидация входных данных \ No newline at end of file +- Валидация входных данных diff --git a/docs/oauth-deployment.md b/docs/oauth-deployment.md index b02e9e62..c61df78d 100644 --- a/docs/oauth-deployment.md +++ b/docs/oauth-deployment.md @@ -40,7 +40,7 @@ CREATE TABLE oauth_links ( provider_data JSONB, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - + UNIQUE(provider, provider_id) ); @@ -86,13 +86,13 @@ async def oauth_redirect(provider: str, state: str, redirect_uri: str): # Валидация провайдера if provider not in ["google", "facebook", "github", "vk", "yandex"]: raise HTTPException(400, "Unsupported provider") - + # Сохранение state в Redis await store_oauth_state(state, redirect_uri) - + # Генерация URL провайдера oauth_url = generate_provider_url(provider, state, redirect_uri) - + return RedirectResponse(url=oauth_url) @router.get("/{provider}/callback") @@ -101,16 +101,16 @@ async def oauth_callback(provider: str, code: str, state: str): stored_data = await get_oauth_state(state) if not stored_data: raise HTTPException(400, "Invalid state") - + # Обмен code на user_data user_data = await exchange_code_for_user_data(provider, code) - + # Создание/поиск пользователя user = await get_or_create_user_from_oauth(provider, user_data) - + # Генерация JWT access_token = generate_jwt_token(user.id) - + # Редирект с токеном return RedirectResponse( url=f"{stored_data['redirect_uri']}?state={state}&access_token={access_token}" @@ -196,4 +196,4 @@ tail -f /var/log/app/oauth.log | grep "oauth" # Frontend логи (browser console) # Фильтр: "[oauth]" или "[SessionProvider]" -``` \ No newline at end of file +``` diff --git a/docs/oauth-implementation.md b/docs/oauth-implementation.md index b54a8149..78a91ff5 100644 --- a/docs/oauth-implementation.md +++ b/docs/oauth-implementation.md @@ -7,7 +7,7 @@ // src/context/session.tsx const oauth = (provider: string) => { console.info('[oauth] Starting OAuth flow for provider:', provider) - + if (isServer) { console.warn('[oauth] OAuth not available during SSR') return @@ -16,10 +16,10 @@ const oauth = (provider: string) => { // Генерируем state для OAuth const state = crypto.randomUUID() localStorage.setItem('oauth_state', state) - + // Формируем URL для OAuth const oauthUrl = `${coreApiUrl}/auth/oauth/${provider}?state=${state}&redirect_uri=${encodeURIComponent(window.location.origin)}` - + // Перенаправляем на OAuth провайдера window.location.href = oauthUrl } @@ -29,7 +29,7 @@ const oauth = (provider: string) => { ```typescript // Обработка OAuth параметров в SessionProvider createEffect( - on([() => searchParams?.state, () => searchParams?.access_token, () => searchParams?.token], + on([() => searchParams?.state, () => searchParams?.access_token, () => searchParams?.token], ([state, access_token, token]) => { // OAuth обработка if (state && access_token) { @@ -54,7 +54,7 @@ createEffect( console.info('[SessionProvider] Processing password reset token') changeSearchParams({ mode: 'change-password', m: 'auth', token }, { replace: true }) } - }, + }, { defer: true } ) ) @@ -75,26 +75,26 @@ async def oauth_redirect( ): """ Инициация OAuth flow с внешним провайдером - + Args: provider: Провайдер OAuth (google, facebook, github) state: CSRF токен от клиента redirect_uri: URL для редиректа после авторизации - + Returns: RedirectResponse: Редирект на провайдера OAuth """ - + # Валидация провайдера if provider not in SUPPORTED_PROVIDERS: raise HTTPException(status_code=400, detail="Unsupported OAuth provider") - + # Сохранение state в сессии/Redis для проверки await store_oauth_state(state, redirect_uri) - + # Генерация URL провайдера oauth_url = generate_provider_url(provider, state, redirect_uri) - + return RedirectResponse(url=oauth_url) ``` @@ -109,34 +109,34 @@ async def oauth_callback( ): """ Обработка callback от OAuth провайдера - + Args: provider: Провайдер OAuth code: Authorization code от провайдера state: CSRF токен для проверки - + Returns: RedirectResponse: Редирект обратно на фронтенд с токеном """ - + # Проверка state stored_data = await get_oauth_state(state) if not stored_data: raise HTTPException(status_code=400, detail="Invalid or expired state") - + # Обмен code на access_token try: user_data = await exchange_code_for_user_data(provider, code) except OAuthException as e: logger.error(f"OAuth error for {provider}: {e}") return RedirectResponse(url=f"{stored_data['redirect_uri']}?error=oauth_failed") - + # Поиск/создание пользователя user = await get_or_create_user_from_oauth(provider, user_data) - + # Генерация JWT токена access_token = generate_jwt_token(user.id) - + # Редирект обратно на фронтенд redirect_url = f"{stored_data['redirect_uri']}?state={state}&access_token={access_token}" return RedirectResponse(url=redirect_url) @@ -196,32 +196,32 @@ class OAuthUser(BaseModel): #### User Creation/Linking ```python async def get_or_create_user_from_oauth( - provider: str, + provider: str, oauth_data: OAuthUser ) -> User: """ Поиск существующего пользователя или создание нового - + Args: provider: OAuth провайдер oauth_data: Данные пользователя от провайдера - + Returns: User: Пользователь в системе """ - + # Поиск по OAuth связке oauth_link = await OAuthLink.get_by_provider_and_id( provider=provider, provider_id=oauth_data.provider_id ) - + if oauth_link: return await User.get(oauth_link.user_id) - + # Поиск по email existing_user = await User.get_by_email(oauth_data.email) - + if existing_user: # Привязка OAuth к существующему пользователю await OAuthLink.create( @@ -231,7 +231,7 @@ async def get_or_create_user_from_oauth( provider_data=oauth_data.raw_data ) return existing_user - + # Создание нового пользователя new_user = await User.create( email=oauth_data.email, @@ -241,7 +241,7 @@ async def get_or_create_user_from_oauth( registration_method='oauth', registration_provider=provider ) - + # Создание OAuth связки await OAuthLink.create( user_id=new_user.id, @@ -249,7 +249,7 @@ async def get_or_create_user_from_oauth( provider_id=oauth_data.provider_id, provider_data=oauth_data.raw_data ) - + return new_user ``` @@ -263,8 +263,8 @@ from datetime import timedelta redis_client = redis.Redis() async def store_oauth_state( - state: str, - redirect_uri: str, + state: str, + redirect_uri: str, ttl: timedelta = timedelta(minutes=10) ): """Сохранение OAuth state с TTL""" @@ -298,7 +298,7 @@ def validate_redirect_uri(uri: str) -> bool: "discours.io", "new.discours.io" ] - + parsed = urlparse(uri) return any(domain in parsed.netloc for domain in allowed_domains) ``` @@ -315,7 +315,7 @@ CREATE TABLE oauth_links ( provider_data JSONB, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - + UNIQUE(provider, provider_id), INDEX(user_id), INDEX(provider, provider_id) @@ -330,7 +330,7 @@ CREATE TABLE oauth_links ( GOOGLE_CLIENT_ID=your_google_client_id GOOGLE_CLIENT_SECRET=your_google_client_secret -# Facebook OAuth +# Facebook OAuth FACEBOOK_APP_ID=your_facebook_app_id FACEBOOK_APP_SECRET=your_facebook_app_secret @@ -389,7 +389,7 @@ def test_oauth_callback(): email="test@example.com", name="Test User" ) - + response = client.get("/auth/oauth/google/callback?code=test_code&state=test_state") assert response.status_code == 307 assert "access_token=" in response.headers["location"] @@ -402,16 +402,16 @@ def test_oauth_callback(): // tests/oauth.spec.ts test('OAuth flow with Google', async ({ page }) => { await page.goto('/login') - + // Click Google OAuth button await page.click('[data-testid="oauth-google"]') - + // Should redirect to Google await page.waitForURL(/accounts\.google\.com/) - + // Mock successful OAuth (in test environment) await page.goto('/?state=test&access_token=mock_token') - + // Should be logged in await expect(page.locator('[data-testid="user-menu"]')).toBeVisible() }) @@ -427,4 +427,4 @@ test('OAuth flow with Google', async ({ page }) => { - [ ] Добавить rate limiting для OAuth endpoints - [ ] Настроить мониторинг OAuth ошибок - [ ] Протестировать все провайдеры в staging -- [ ] Добавить логирование OAuth событий \ No newline at end of file +- [ ] Добавить логирование OAuth событий diff --git a/docs/oauth-setup.md b/docs/oauth-setup.md new file mode 100644 index 00000000..416dfc94 --- /dev/null +++ b/docs/oauth-setup.md @@ -0,0 +1,123 @@ +# OAuth Providers Setup Guide + +This guide explains how to set up OAuth authentication for various social platforms. + +## Supported Providers + +The platform supports the following OAuth providers: +- Google +- GitHub +- Facebook +- X (Twitter) +- Telegram +- VK (VKontakte) +- Yandex + +## Environment Variables + +Add the following environment variables to your `.env` file: + +```bash +# Google OAuth +OAUTH_CLIENTS_GOOGLE_ID=your_google_client_id +OAUTH_CLIENTS_GOOGLE_KEY=your_google_client_secret + +# GitHub OAuth +OAUTH_CLIENTS_GITHUB_ID=your_github_client_id +OAUTH_CLIENTS_GITHUB_KEY=your_github_client_secret + +# Facebook OAuth +OAUTH_CLIENTS_FACEBOOK_ID=your_facebook_app_id +OAUTH_CLIENTS_FACEBOOK_KEY=your_facebook_app_secret + +# X (Twitter) OAuth +OAUTH_CLIENTS_X_ID=your_x_client_id +OAUTH_CLIENTS_X_KEY=your_x_client_secret + +# Telegram OAuth +OAUTH_CLIENTS_TELEGRAM_ID=your_telegram_bot_token +OAUTH_CLIENTS_TELEGRAM_KEY=your_telegram_bot_secret + +# VK OAuth +OAUTH_CLIENTS_VK_ID=your_vk_app_id +OAUTH_CLIENTS_VK_KEY=your_vk_secure_key + +# Yandex OAuth +OAUTH_CLIENTS_YANDEX_ID=your_yandex_client_id +OAUTH_CLIENTS_YANDEX_KEY=your_yandex_client_secret +``` + +## Provider Setup Instructions + +### Google +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Create a new project or select existing +3. Enable Google+ API and OAuth 2.0 +4. Create OAuth 2.0 Client ID credentials +5. Add your callback URLs: `https://yourdomain.com/oauth/google/callback` + +### GitHub +1. Go to [GitHub Developer Settings](https://github.com/settings/developers) +2. Create a new OAuth App +3. Set Authorization callback URL: `https://yourdomain.com/oauth/github/callback` + +### Facebook +1. Go to [Facebook Developers](https://developers.facebook.com/) +2. Create a new app +3. Add Facebook Login product +4. Configure Valid OAuth redirect URIs: `https://yourdomain.com/oauth/facebook/callback` + +### X (Twitter) +1. Go to [Twitter Developer Portal](https://developer.twitter.com/) +2. Create a new app +3. Enable OAuth 2.0 authentication +4. Set Callback URLs: `https://yourdomain.com/oauth/x/callback` +5. **Note**: X doesn't provide email addresses through their API + +### Telegram +1. Create a bot with [@BotFather](https://t.me/botfather) +2. Use `/newbot` command and follow instructions +3. Get your bot token +4. Configure domain settings with `/setdomain` command +5. **Note**: Telegram doesn't provide email addresses + +### VK (VKontakte) +1. Go to [VK for Developers](https://vk.com/dev) +2. Create a new application +3. Set Authorized redirect URI: `https://yourdomain.com/oauth/vk/callback` +4. **Note**: Email access requires special permissions from VK + +### Yandex +1. Go to [Yandex OAuth](https://oauth.yandex.com/) +2. Create a new application +3. Set Callback URI: `https://yourdomain.com/oauth/yandex/callback` +4. Select required permissions: `login:email login:info` + +## Email Handling + +Some providers (X, Telegram) don't provide email addresses. In these cases: +- A temporary email is generated: `{provider}_{user_id}@oauth.local` +- Users can update their email in profile settings later +- `email_verified` is set to `false` for generated emails + +## Usage in Frontend + +OAuth URLs: +``` +/oauth/google +/oauth/github +/oauth/facebook +/oauth/x +/oauth/telegram +/oauth/vk +/oauth/yandex +``` + +Each provider accepts a `state` parameter for CSRF protection and a `redirect_uri` for post-authentication redirects. + +## Security Notes + +- All OAuth flows use PKCE (Proof Key for Code Exchange) for additional security +- State parameters are stored in Redis with 10-minute TTL +- OAuth sessions are one-time use only +- Failed authentications are logged for monitoring diff --git a/docs/oauth.md b/docs/oauth.md new file mode 100644 index 00000000..29be14c3 --- /dev/null +++ b/docs/oauth.md @@ -0,0 +1,329 @@ +# OAuth Token Management + +## Overview +Система управления OAuth токенами с использованием Redis для безопасного и производительного хранения токенов доступа и обновления от различных провайдеров. + +## Архитектура + +### Redis Storage +OAuth токены хранятся в Redis с автоматическим истечением (TTL): +- `oauth_access:{user_id}:{provider}` - access tokens +- `oauth_refresh:{user_id}:{provider}` - refresh tokens + +### Поддерживаемые провайдеры +- Google OAuth 2.0 +- Facebook Login +- GitHub OAuth + +## API Documentation + +### OAuthTokenStorage Class + +#### store_access_token() +Сохраняет access token в Redis с автоматическим TTL. + +```python +await OAuthTokenStorage.store_access_token( + user_id=123, + provider="google", + access_token="ya29.a0AfH6SM...", + expires_in=3600, + additional_data={"scope": "profile email"} +) +``` + +#### store_refresh_token() +Сохраняет refresh token с длительным TTL (30 дней по умолчанию). + +```python +await OAuthTokenStorage.store_refresh_token( + user_id=123, + provider="google", + refresh_token="1//04...", + ttl=2592000 # 30 дней +) +``` + +#### get_access_token() +Получает действующий access token из Redis. + +```python +token_data = await OAuthTokenStorage.get_access_token(123, "google") +if token_data: + access_token = token_data["token"] + expires_in = token_data["expires_in"] +``` + +#### refresh_access_token() +Обновляет access token (и опционально refresh token). + +```python +success = await OAuthTokenStorage.refresh_access_token( + user_id=123, + provider="google", + new_access_token="ya29.new_token...", + expires_in=3600, + new_refresh_token="1//04new..." # опционально +) +``` + +#### delete_tokens() +Удаляет все токены пользователя для провайдера. + +```python +await OAuthTokenStorage.delete_tokens(123, "google") +``` + +#### get_user_providers() +Получает список OAuth провайдеров для пользователя. + +```python +providers = await OAuthTokenStorage.get_user_providers(123) +# ["google", "github"] +``` + +#### extend_token_ttl() +Продлевает срок действия токена. + +```python +# Продлить access token на 30 минут +success = await OAuthTokenStorage.extend_token_ttl(123, "google", "access", 1800) + +# Продлить refresh token на 7 дней +success = await OAuthTokenStorage.extend_token_ttl(123, "google", "refresh", 604800) +``` + +#### get_token_info() +Получает подробную информацию о токенах включая TTL. + +```python +info = await OAuthTokenStorage.get_token_info(123, "google") +# { +# "user_id": 123, +# "provider": "google", +# "access_token": {"exists": True, "ttl": 3245}, +# "refresh_token": {"exists": True, "ttl": 2589600} +# } +``` + +## Data Structures + +### Access Token Structure +```json +{ + "token": "ya29.a0AfH6SM...", + "provider": "google", + "user_id": 123, + "created_at": 1640995200, + "expires_in": 3600, + "scope": "profile email", + "token_type": "Bearer" +} +``` + +### Refresh Token Structure +```json +{ + "token": "1//04...", + "provider": "google", + "user_id": 123, + "created_at": 1640995200 +} +``` + +## Security Considerations + +### Token Expiration +- **Access tokens**: TTL основан на `expires_in` от провайдера (обычно 1 час) +- **Refresh tokens**: TTL 30 дней по умолчанию +- **Автоматическая очистка**: Redis автоматически удаляет истекшие токены +- **Внутренняя система истечения**: Использует SET + EXPIRE для точного контроля TTL + +### Redis Expiration Benefits +- **Гибкость**: Можно изменять TTL существующих токенов через EXPIRE +- **Мониторинг**: Команда TTL показывает оставшееся время жизни токена +- **Расширение**: Возможность продления срока действия токенов без перезаписи +- **Атомарность**: Separate SET/EXPIRE operations для лучшего контроля + +### Access Control +- Токены доступны только владельцу аккаунта +- Нет доступа к токенам через GraphQL API +- Токены не хранятся в основной базе данных + +### Provider Isolation +- Токены разных провайдеров хранятся отдельно +- Удаление токенов одного провайдера не влияет на другие +- Поддержка множественных OAuth подключений + +## Integration Examples + +### OAuth Login Flow +```python +# После успешной авторизации через OAuth провайдера +async def handle_oauth_callback(user_id: int, provider: str, tokens: dict): + # Сохраняем токены в Redis + await OAuthTokenStorage.store_access_token( + user_id=user_id, + provider=provider, + access_token=tokens["access_token"], + expires_in=tokens.get("expires_in", 3600) + ) + + if "refresh_token" in tokens: + await OAuthTokenStorage.store_refresh_token( + user_id=user_id, + provider=provider, + refresh_token=tokens["refresh_token"] + ) +``` + +### Token Refresh +```python +async def refresh_oauth_token(user_id: int, provider: str): + # Получаем refresh token + refresh_data = await OAuthTokenStorage.get_refresh_token(user_id, provider) + if not refresh_data: + return False + + # Обмениваем refresh token на новый access token + new_tokens = await exchange_refresh_token( + provider, refresh_data["token"] + ) + + # Сохраняем новые токены + return await OAuthTokenStorage.refresh_access_token( + user_id=user_id, + provider=provider, + new_access_token=new_tokens["access_token"], + expires_in=new_tokens.get("expires_in"), + new_refresh_token=new_tokens.get("refresh_token") + ) +``` + +### API Integration +```python +async def make_oauth_request(user_id: int, provider: str, endpoint: str): + # Получаем действующий access token + token_data = await OAuthTokenStorage.get_access_token(user_id, provider) + + if not token_data: + # Токен отсутствует, требуется повторная авторизация + raise OAuthTokenMissing() + + # Делаем запрос к API провайдера + headers = {"Authorization": f"Bearer {token_data['token']}"} + response = await httpx.get(endpoint, headers=headers) + + if response.status_code == 401: + # Токен истек, пытаемся обновить + if await refresh_oauth_token(user_id, provider): + # Повторяем запрос с новым токеном + token_data = await OAuthTokenStorage.get_access_token(user_id, provider) + headers = {"Authorization": f"Bearer {token_data['token']}"} + response = await httpx.get(endpoint, headers=headers) + + return response.json() +``` + +### TTL Monitoring and Management +```python +async def monitor_token_expiration(user_id: int, provider: str): + """Мониторинг и управление сроком действия токенов""" + + # Получаем информацию о токенах + info = await OAuthTokenStorage.get_token_info(user_id, provider) + + # Проверяем access token + if info["access_token"]["exists"]: + ttl = info["access_token"]["ttl"] + if ttl < 300: # Меньше 5 минут + logger.warning(f"Access token expires soon: {ttl}s") + # Автоматически обновляем токен + await refresh_oauth_token(user_id, provider) + + # Проверяем refresh token + if info["refresh_token"]["exists"]: + ttl = info["refresh_token"]["ttl"] + if ttl < 86400: # Меньше 1 дня + logger.warning(f"Refresh token expires soon: {ttl}s") + # Уведомляем пользователя о необходимости повторной авторизации + +async def extend_session_if_active(user_id: int, provider: str): + """Продлевает сессию для активных пользователей""" + + # Проверяем активность пользователя + if await is_user_active(user_id): + # Продлеваем access token на 1 час + success = await OAuthTokenStorage.extend_token_ttl( + user_id, provider, "access", 3600 + ) + if success: + logger.info(f"Extended access token for active user {user_id}") +``` + +## Migration from Database + +Если у вас уже есть OAuth токены в базе данных, используйте этот скрипт для миграции: + +```python +async def migrate_oauth_tokens(): + """Миграция OAuth токенов из БД в Redis""" + with local_session() as session: + # Предполагая, что токены хранились в таблице authors + authors = session.query(Author).filter( + or_( + Author.provider_access_token.is_not(None), + Author.provider_refresh_token.is_not(None) + ) + ).all() + + for author in authors: + # Получаем провайдер из oauth вместо старого поля oauth + if author.oauth: + for provider in author.oauth.keys(): + if author.provider_access_token: + await OAuthTokenStorage.store_access_token( + user_id=author.id, + provider=provider, + access_token=author.provider_access_token + ) + + if author.provider_refresh_token: + await OAuthTokenStorage.store_refresh_token( + user_id=author.id, + provider=provider, + refresh_token=author.provider_refresh_token + ) + + print(f"Migrated OAuth tokens for {len(authors)} users") +``` + +## Performance Benefits + +### Redis Advantages +- **Скорость**: Доступ к токенам за микросекунды +- **Масштабируемость**: Не нагружает основную БД +- **Автоматическая очистка**: TTL убирает истекшие токены +- **Память**: Эффективное использование памяти Redis + +### Reduced Database Load +- OAuth токены больше не записываются в основную БД +- Уменьшено количество записей в таблице authors +- Faster user queries без JOIN к токенам + +## Monitoring and Maintenance + +### Redis Memory Usage +```bash +# Проверка использования памяти OAuth токенами +redis-cli --scan --pattern "oauth_*" | wc -l +redis-cli memory usage oauth_access:123:google +``` + +### Cleanup Statistics +```python +# Периодическая очистка и логирование (опционально) +async def oauth_cleanup_job(): + cleaned = await OAuthTokenStorage.cleanup_expired_tokens() + logger.info(f"OAuth cleanup completed, {cleaned} tokens processed") +``` diff --git a/docs/rating.md b/docs/rating.md index bee0e775..d7032671 100644 --- a/docs/rating.md +++ b/docs/rating.md @@ -52,7 +52,7 @@ Rate another author (karma system). - Excludes deleted reactions - Excludes comment reactions -#### Comments Rating +#### Comments Rating - Calculated from LIKE/DISLIKE reactions on author's comments - Each LIKE: +1 - Each DISLIKE: -1 @@ -79,4 +79,4 @@ Rate another author (karma system). - All ratings exclude deleted content - Reactions are unique per user/content - Rating calculations are optimized with SQLAlchemy -- System supports both direct author rating and content-based rating \ No newline at end of file +- System supports both direct author rating and content-based rating diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 00000000..254678c6 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,212 @@ +# Security System + +## Overview +Система безопасности обеспечивает управление паролями и email адресами пользователей через специализированные GraphQL мутации с использованием Redis для хранения токенов. + +## GraphQL API + +### Мутации + +#### updateSecurity +Универсальная мутация для смены пароля и/или email пользователя с полной валидацией и безопасностью. + +**Parameters:** +- `email: String` - Новый email (опционально) +- `old_password: String` - Текущий пароль (обязательно для любых изменений) +- `new_password: String` - Новый пароль (опционально) + +**Returns:** +```typescript +type SecurityUpdateResult { + success: Boolean! + error: String + author: Author +} +``` + +**Примеры использования:** + +```graphql +# Смена пароля +mutation { + updateSecurity( + old_password: "current123" + new_password: "newPassword456" + ) { + success + error + author { + id + name + email + } + } +} + +# Смена email +mutation { + updateSecurity( + email: "newemail@example.com" + old_password: "current123" + ) { + success + error + author { + id + name + email + } + } +} + +# Одновременная смена пароля и email +mutation { + updateSecurity( + email: "newemail@example.com" + old_password: "current123" + new_password: "newPassword456" + ) { + success + error + author { + id + name + email + } + } +} +``` + +#### confirmEmailChange +Подтверждение смены email по токену, полученному на новый email адрес. + +**Parameters:** +- `token: String!` - Токен подтверждения + +**Returns:** `SecurityUpdateResult` + +#### cancelEmailChange +Отмена процесса смены email. + +**Returns:** `SecurityUpdateResult` + +### Валидация и Ошибки + +```typescript +const ERRORS = { + NOT_AUTHENTICATED: "User not authenticated", + INCORRECT_OLD_PASSWORD: "incorrect old password", + PASSWORDS_NOT_MATCH: "New passwords do not match", + EMAIL_ALREADY_EXISTS: "email already exists", + INVALID_EMAIL: "Invalid email format", + WEAK_PASSWORD: "Password too weak", + SAME_PASSWORD: "New password must be different from current", + VALIDATION_ERROR: "Validation failed", + INVALID_TOKEN: "Invalid token", + TOKEN_EXPIRED: "Token expired", + NO_PENDING_EMAIL: "No pending email change" +} +``` + +## Логика смены email + +1. **Инициация смены:** + - Пользователь вызывает `updateSecurity` с новым email + - Генерируется токен подтверждения `token_urlsafe(32)` + - Данные смены email сохраняются в Redis с ключом `email_change:{user_id}` + - Устанавливается автоматическое истечение токена (1 час) + - Отправляется письмо на новый email с токеном + +2. **Подтверждение:** + - Пользователь получает письмо с токеном + - Вызывает `confirmEmailChange` с токеном + - Система проверяет токен и срок действия в Redis + - Если токен валиден, email обновляется в базе данных + - Данные смены email удаляются из Redis + +3. **Отмена:** + - Пользователь может отменить смену через `cancelEmailChange` + - Данные смены email удаляются из Redis + +## Redis Storage + +### Хранение токенов смены email +```json +{ + "key": "email_change:{user_id}", + "value": { + "user_id": 123, + "old_email": "old@example.com", + "new_email": "new@example.com", + "token": "random_token_32_chars", + "expires_at": 1640995200 + }, + "ttl": 3600 // 1 час +} +``` + +### Хранение OAuth токенов +```json +{ + "key": "oauth_access:{user_id}:{provider}", + "value": { + "token": "oauth_access_token", + "provider": "google", + "user_id": 123, + "created_at": 1640995200, + "expires_in": 3600, + "scope": "profile email" + }, + "ttl": 3600 // время из expires_in или 1 час по умолчанию +} +``` + +```json +{ + "key": "oauth_refresh:{user_id}:{provider}", + "value": { + "token": "oauth_refresh_token", + "provider": "google", + "user_id": 123, + "created_at": 1640995200 + }, + "ttl": 2592000 // 30 дней по умолчанию +} +``` + +### Преимущества Redis хранения +- **Автоматическое истечение**: TTL в Redis автоматически удаляет истекшие токены +- **Производительность**: Быстрый доступ к данным токенов +- **Масштабируемость**: Не нагружает основную базу данных +- **Безопасность**: Токены не хранятся в основной БД +- **Простота**: Не требует миграции схемы базы данных +- **OAuth токены**: Централизованное управление токенами всех OAuth провайдеров + +## Безопасность + +### Требования к паролю +- Минимум 8 символов +- Не может совпадать с текущим паролем + +### Аутентификация +- Все операции требуют валидного токена аутентификации +- Старый пароль обязателен для подтверждения личности + +### Валидация email +- Проверка формата email через регулярное выражение +- Проверка уникальности email в системе +- Защита от race conditions при смене email + +### Токены безопасности +- Генерация токенов через `secrets.token_urlsafe(32)` +- Автоматическое истечение через 1 час +- Удаление токенов после использования или отмены + +## Database Schema + +Система не требует изменений в схеме базы данных. Все токены и временные данные хранятся в Redis. + +### Защищенные поля +Следующие поля показываются только владельцу аккаунта: +- `email` +- `password` diff --git a/env.d.ts b/env.d.ts index da5a5e5d..b54b4c98 100644 --- a/env.d.ts +++ b/env.d.ts @@ -3,7 +3,7 @@ interface ImportMetaEnv { readonly VITE_API_URL: string } - + interface ImportMeta { readonly env: ImportMetaEnv -} \ No newline at end of file +} diff --git a/index.html b/index.html index 8feec1e6..a7ee6fc4 100644 --- a/index.html +++ b/index.html @@ -17,4 +17,4 @@
- \ No newline at end of file + diff --git a/main.py b/main.py index 80e83f44..a2c50380 100644 --- a/main.py +++ b/main.py @@ -9,7 +9,7 @@ from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.middleware.cors import CORSMiddleware from starlette.requests import Request -from starlette.responses import JSONResponse, Response +from starlette.responses import JSONResponse from starlette.routing import Mount, Route from starlette.staticfiles import StaticFiles @@ -30,11 +30,11 @@ DEVMODE = os.getenv("DOKKU_APP_TYPE", "false").lower() == "false" DIST_DIR = join(os.path.dirname(__file__), "dist") # Директория для собранных файлов INDEX_HTML = join(os.path.dirname(__file__), "index.html") -# Импортируем резолверы +# Импортируем резолверы ПЕРЕД созданием схемы import_module("resolvers") # Создаем схему GraphQL -schema = make_executable_schema(load_schema_from_path("schema/"), resolvers) +schema = make_executable_schema(load_schema_from_path("schema/"), list(resolvers)) # Создаем middleware с правильным порядком middleware = [ @@ -96,12 +96,11 @@ async def graphql_handler(request: Request): # Применяем middleware для установки cookie # Используем метод process_result из auth_middleware для корректной обработки # cookie на основе результатов операций login/logout - response = await auth_middleware.process_result(request, result) - return response + return await auth_middleware.process_result(request, result) except asyncio.CancelledError: return JSONResponse({"error": "Request cancelled"}, status_code=499) except Exception as e: - logger.error(f"GraphQL error: {str(e)}") + logger.error(f"GraphQL error: {e!s}") # Логируем более подробную информацию для отладки import traceback @@ -109,7 +108,7 @@ async def graphql_handler(request: Request): return JSONResponse({"error": str(e)}, status_code=500) -async def shutdown(): +async def shutdown() -> None: """Остановка сервера и освобождение ресурсов""" logger.info("Остановка сервера") @@ -126,7 +125,7 @@ async def shutdown(): os.unlink(DEV_SERVER_PID_FILE_NAME) -async def dev_start(): +async def dev_start() -> None: """ Инициализация сервера в DEV режиме. @@ -142,10 +141,9 @@ async def dev_start(): # Если PID-файл уже существует, проверяем, не запущен ли уже сервер с этим PID if exists(pid_path): try: - with open(pid_path, "r", encoding="utf-8") as f: + with open(pid_path, encoding="utf-8") as f: old_pid = int(f.read().strip()) # Проверяем, существует ли процесс с таким PID - import signal try: os.kill(old_pid, 0) # Сигнал 0 только проверяет существование процесса @@ -153,16 +151,16 @@ async def dev_start(): except OSError: print(f"[info] Stale PID file found, previous process {old_pid} not running") except (ValueError, FileNotFoundError): - print(f"[warning] Invalid PID file found, recreating") + print("[warning] Invalid PID file found, recreating") # Создаем или перезаписываем PID-файл with open(pid_path, "w", encoding="utf-8") as f: f.write(str(os.getpid())) print(f"[main] process started in DEV mode with PID {os.getpid()}") except Exception as e: - logger.error(f"[main] Error during server startup: {str(e)}") + logger.error(f"[main] Error during server startup: {e!s}") # Не прерываем запуск сервера из-за ошибки в этой функции - print(f"[warning] Error during DEV mode initialization: {str(e)}") + print(f"[warning] Error during DEV mode initialization: {e!s}") async def lifespan(_app): diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 00000000..d4f10577 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,87 @@ +[mypy] +# Основные настройки +python_version = 3.12 +warn_return_any = False +warn_unused_configs = True +disallow_untyped_defs = False +disallow_incomplete_defs = False +no_implicit_optional = False +explicit_package_bases = True +namespace_packages = True +check_untyped_defs = False + +# Игнорируем missing imports для внешних библиотек +ignore_missing_imports = True + +# Временно исключаем все проблематичные файлы +exclude = ^(tests/.*|alembic/.*|orm/.*|auth/.*|resolvers/.*|services/db\.py|services/schema\.py)$ + +# Настройки для конкретных модулей +[mypy-graphql.*] +ignore_missing_imports = True + +[mypy-ariadne.*] +ignore_missing_imports = True + +[mypy-starlette.*] +ignore_missing_imports = True + +[mypy-orjson.*] +ignore_missing_imports = True + +[mypy-pytest.*] +ignore_missing_imports = True + +[mypy-pydantic.*] +ignore_missing_imports = True + +[mypy-granian.*] +ignore_missing_imports = True + +[mypy-jwt.*] +ignore_missing_imports = True + +[mypy-httpx.*] +ignore_missing_imports = True + +[mypy-trafilatura.*] +ignore_missing_imports = True + +[mypy-sentry_sdk.*] +ignore_missing_imports = True + +[mypy-colorlog.*] +ignore_missing_imports = True + +[mypy-google.*] +ignore_missing_imports = True + +[mypy-txtai.*] +ignore_missing_imports = True + +[mypy-h11.*] +ignore_missing_imports = True + +[mypy-hiredis.*] +ignore_missing_imports = True + +[mypy-htmldate.*] +ignore_missing_imports = True + +[mypy-httpcore.*] +ignore_missing_imports = True + +[mypy-courlan.*] +ignore_missing_imports = True + +[mypy-certifi.*] +ignore_missing_imports = True + +[mypy-charset_normalizer.*] +ignore_missing_imports = True + +[mypy-anyio.*] +ignore_missing_imports = True + +[mypy-sniffio.*] +ignore_missing_imports = True diff --git a/orm/collection.py b/orm/collection.py index 2b1696d6..58eaf1e7 100644 --- a/orm/collection.py +++ b/orm/collection.py @@ -2,7 +2,7 @@ import time from sqlalchemy import Column, ForeignKey, Integer, String -from services.db import Base +from services.db import BaseModel as Base class ShoutCollection(Base): diff --git a/orm/community.py b/orm/community.py index f7613c2f..e0f5ece5 100644 --- a/orm/community.py +++ b/orm/community.py @@ -1,11 +1,12 @@ import enum import time -from sqlalchemy import Column, ForeignKey, Integer, String, Text, distinct, func +from sqlalchemy import JSON, Boolean, Column, ForeignKey, Integer, String, Text, distinct, func from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.orm import relationship from auth.orm import Author -from services.db import Base +from services.db import BaseModel class CommunityRole(enum.Enum): @@ -14,28 +15,36 @@ class CommunityRole(enum.Enum): ARTIST = "artist" # + can be credited as featured artist EXPERT = "expert" # + can add proof or disproof to shouts, can manage topics EDITOR = "editor" # + can manage topics, comments and community settings + ADMIN = "admin" @classmethod def as_string_array(cls, roles): return [role.value for role in roles] + @classmethod + def from_string(cls, value: str) -> "CommunityRole": + return cls(value) -class CommunityFollower(Base): - __tablename__ = "community_author" - author = Column(ForeignKey("author.id"), primary_key=True) +class CommunityFollower(BaseModel): + __tablename__ = "community_follower" + community = Column(ForeignKey("community.id"), primary_key=True) - joined_at = Column(Integer, nullable=False, default=lambda: int(time.time())) - roles = Column(Text, nullable=True, comment="Roles (comma-separated)") + follower = Column(ForeignKey("author.id"), primary_key=True) + roles = Column(String, nullable=True) - def set_roles(self, roles): - self.roles = CommunityRole.as_string_array(roles) + def __init__(self, community: int, follower: int, roles: list[str] | None = None) -> None: + self.community = community # type: ignore[assignment] + self.follower = follower # type: ignore[assignment] + if roles: + self.roles = ",".join(roles) # type: ignore[assignment] - def get_roles(self): - return [CommunityRole(role) for role in self.roles] + def get_roles(self) -> list[CommunityRole]: + roles_str = getattr(self, "roles", "") + return [CommunityRole(role) for role in roles_str.split(",")] if roles_str else [] -class Community(Base): +class Community(BaseModel): __tablename__ = "community" name = Column(String, nullable=False) @@ -44,6 +53,12 @@ class Community(Base): pic = Column(String, nullable=False, default="") created_at = Column(Integer, nullable=False, default=lambda: int(time.time())) created_by = Column(ForeignKey("author.id"), nullable=False) + settings = Column(JSON, nullable=True) + updated_at = Column(Integer, nullable=True) + deleted_at = Column(Integer, nullable=True) + private = Column(Boolean, default=False) + + followers = relationship("Author", secondary="community_follower") @hybrid_property def stat(self): @@ -54,12 +69,39 @@ class Community(Base): return self.roles.split(",") if self.roles else [] @role_list.setter - def role_list(self, value): - self.roles = ",".join(value) if value else None + def role_list(self, value) -> None: + self.roles = ",".join(value) if value else None # type: ignore[assignment] + + def is_followed_by(self, author_id: int) -> bool: + # Check if the author follows this community + from services.db import local_session + + with local_session() as session: + follower = ( + session.query(CommunityFollower) + .filter(CommunityFollower.community == self.id, CommunityFollower.follower == author_id) + .first() + ) + return follower is not None + + def get_role(self, author_id: int) -> CommunityRole | None: + # Get the role of the author in this community + from services.db import local_session + + with local_session() as session: + follower = ( + session.query(CommunityFollower) + .filter(CommunityFollower.community == self.id, CommunityFollower.follower == author_id) + .first() + ) + if follower and follower.roles: + roles = follower.roles.split(",") + return CommunityRole.from_string(roles[0]) if roles else None + return None class CommunityStats: - def __init__(self, community): + def __init__(self, community) -> None: self.community = community @property @@ -71,7 +113,7 @@ class CommunityStats: @property def followers(self): return ( - self.community.session.query(func.count(CommunityFollower.author)) + self.community.session.query(func.count(CommunityFollower.follower)) .filter(CommunityFollower.community == self.community.id) .scalar() ) @@ -93,7 +135,7 @@ class CommunityStats: ) -class CommunityAuthor(Base): +class CommunityAuthor(BaseModel): __tablename__ = "community_author" id = Column(Integer, primary_key=True) @@ -106,5 +148,5 @@ class CommunityAuthor(Base): return self.roles.split(",") if self.roles else [] @role_list.setter - def role_list(self, value): - self.roles = ",".join(value) if value else None + def role_list(self, value) -> None: + self.roles = ",".join(value) if value else None # type: ignore[assignment] diff --git a/orm/draft.py b/orm/draft.py index 1933d80f..3e0699d0 100644 --- a/orm/draft.py +++ b/orm/draft.py @@ -5,7 +5,7 @@ from sqlalchemy.orm import relationship from auth.orm import Author from orm.topic import Topic -from services.db import Base +from services.db import BaseModel as Base class DraftTopic(Base): @@ -29,76 +29,27 @@ class DraftAuthor(Base): class Draft(Base): __tablename__ = "draft" # required - created_at: int = Column(Integer, nullable=False, default=lambda: int(time.time())) - # Колонки для связей с автором - created_by: int = Column("created_by", ForeignKey("author.id"), nullable=False) - community: int = Column("community", ForeignKey("community.id"), nullable=False, default=1) + created_at = Column(Integer, nullable=False, default=lambda: int(time.time())) + created_by = Column(ForeignKey("author.id"), nullable=False) + community = Column(ForeignKey("community.id"), nullable=False, default=1) # optional - layout: str = Column(String, nullable=True, default="article") - slug: str = Column(String, unique=True) - title: str = Column(String, nullable=True) - subtitle: str | None = Column(String, nullable=True) - lead: str | None = Column(String, nullable=True) - body: str = Column(String, nullable=False, comment="Body") - media: dict | None = Column(JSON, nullable=True) - cover: str | None = Column(String, nullable=True, comment="Cover image url") - cover_caption: str | None = Column(String, nullable=True, comment="Cover image alt caption") - lang: str = Column(String, nullable=False, default="ru", comment="Language") - seo: str | None = Column(String, nullable=True) # JSON + layout = Column(String, nullable=True, default="article") + slug = Column(String, unique=True) + title = Column(String, nullable=True) + subtitle = Column(String, nullable=True) + lead = Column(String, nullable=True) + body = Column(String, nullable=False, comment="Body") + media = Column(JSON, nullable=True) + cover = Column(String, nullable=True, comment="Cover image url") + cover_caption = Column(String, nullable=True, comment="Cover image alt caption") + lang = Column(String, nullable=False, default="ru", comment="Language") + seo = Column(String, nullable=True) # JSON # auto - updated_at: int | None = Column(Integer, nullable=True, index=True) - deleted_at: int | None = Column(Integer, nullable=True, index=True) - updated_by: int | None = Column("updated_by", ForeignKey("author.id"), nullable=True) - deleted_by: int | None = Column("deleted_by", ForeignKey("author.id"), nullable=True) - - # --- Relationships --- - # Только many-to-many связи через вспомогательные таблицы - authors = relationship(Author, secondary="draft_author", lazy="select") - topics = relationship(Topic, secondary="draft_topic", lazy="select") - - # Связь с Community (если нужна как объект, а не ID) - # community = relationship("Community", foreign_keys=[community_id], lazy="joined") - # Пока оставляем community_id как ID - - # Связь с публикацией (один-к-одному или один-к-нулю) - # Загружается через joinedload в резолвере - publication = relationship( - "Shout", - primaryjoin="Draft.id == Shout.draft", - foreign_keys="Shout.draft", - uselist=False, - lazy="noload", # Не грузим по умолчанию, только через options - viewonly=True, # Указываем, что это связь только для чтения - ) - - def dict(self): - """ - Сериализует объект Draft в словарь. - Гарантирует, что поля topics и authors всегда будут списками. - """ - return { - "id": self.id, - "created_at": self.created_at, - "created_by": self.created_by, - "community": self.community, - "layout": self.layout, - "slug": self.slug, - "title": self.title, - "subtitle": self.subtitle, - "lead": self.lead, - "body": self.body, - "media": self.media or [], - "cover": self.cover, - "cover_caption": self.cover_caption, - "lang": self.lang, - "seo": self.seo, - "updated_at": self.updated_at, - "deleted_at": self.deleted_at, - "updated_by": self.updated_by, - "deleted_by": self.deleted_by, - # Гарантируем, что topics и authors всегда будут списками - "topics": [topic.dict() for topic in (self.topics or [])], - "authors": [author.dict() for author in (self.authors or [])], - } + updated_at = Column(Integer, nullable=True, index=True) + deleted_at = Column(Integer, nullable=True, index=True) + updated_by = Column(ForeignKey("author.id"), nullable=True) + deleted_by = Column(ForeignKey("author.id"), nullable=True) + authors = relationship(Author, secondary="draft_author") + topics = relationship(Topic, secondary="draft_topic") diff --git a/orm/invite.py b/orm/invite.py index fe0cbf27..d81a210e 100644 --- a/orm/invite.py +++ b/orm/invite.py @@ -3,7 +3,7 @@ import enum from sqlalchemy import Column, ForeignKey, String from sqlalchemy.orm import relationship -from services.db import Base +from services.db import BaseModel as Base class InviteStatus(enum.Enum): @@ -29,7 +29,7 @@ class Invite(Base): shout = relationship("Shout") def set_status(self, status: InviteStatus): - self.status = status.value + self.status = status.value # type: ignore[assignment] def get_status(self) -> InviteStatus: return InviteStatus.from_string(self.status) diff --git a/orm/notification.py b/orm/notification.py index b52e25de..b7270b73 100644 --- a/orm/notification.py +++ b/orm/notification.py @@ -5,7 +5,7 @@ from sqlalchemy import JSON, Column, ForeignKey, Integer, String from sqlalchemy.orm import relationship from auth.orm import Author -from services.db import Base +from services.db import BaseModel as Base class NotificationEntity(enum.Enum): @@ -51,13 +51,13 @@ class Notification(Base): seen = relationship(Author, secondary="notification_seen") def set_entity(self, entity: NotificationEntity): - self.entity = entity.value + self.entity = entity.value # type: ignore[assignment] def get_entity(self) -> NotificationEntity: return NotificationEntity.from_string(self.entity) def set_action(self, action: NotificationAction): - self.action = action.value + self.action = action.value # type: ignore[assignment] def get_action(self) -> NotificationAction: return NotificationAction.from_string(self.action) diff --git a/orm/reaction.py b/orm/reaction.py index 3178caca..a7379b9b 100644 --- a/orm/reaction.py +++ b/orm/reaction.py @@ -3,7 +3,7 @@ from enum import Enum as Enumeration from sqlalchemy import Column, ForeignKey, Integer, String -from services.db import Base +from services.db import BaseModel as Base class ReactionKind(Enumeration): diff --git a/orm/shout.py b/orm/shout.py index b98126be..7319ecec 100644 --- a/orm/shout.py +++ b/orm/shout.py @@ -6,7 +6,7 @@ from sqlalchemy.orm import relationship from auth.orm import Author from orm.reaction import Reaction from orm.topic import Topic -from services.db import Base +from services.db import BaseModel as Base class ShoutTopic(Base): @@ -71,70 +71,41 @@ class ShoutAuthor(Base): class Shout(Base): """ Публикация в системе. - - Attributes: - body (str) - slug (str) - cover (str) : "Cover image url" - cover_caption (str) : "Cover image alt caption" - lead (str) - title (str) - subtitle (str) - layout (str) - media (dict) - authors (list[Author]) - topics (list[Topic]) - reactions (list[Reaction]) - lang (str) - version_of (int) - oid (str) - seo (str) : JSON - draft (int) - created_at (int) - updated_at (int) - published_at (int) - featured_at (int) - deleted_at (int) - created_by (int) - updated_by (int) - deleted_by (int) - community (int) """ __tablename__ = "shout" - created_at: int = Column(Integer, nullable=False, default=lambda: int(time.time())) - updated_at: int | None = Column(Integer, nullable=True, index=True) - published_at: int | None = Column(Integer, nullable=True, index=True) - featured_at: int | None = Column(Integer, nullable=True, index=True) - deleted_at: int | None = Column(Integer, nullable=True, index=True) + created_at = Column(Integer, nullable=False, default=lambda: int(time.time())) + updated_at = Column(Integer, nullable=True, index=True) + published_at = Column(Integer, nullable=True, index=True) + featured_at = Column(Integer, nullable=True, index=True) + deleted_at = Column(Integer, nullable=True, index=True) - created_by: int = Column(ForeignKey("author.id"), nullable=False) - updated_by: int | None = Column(ForeignKey("author.id"), nullable=True) - deleted_by: int | None = Column(ForeignKey("author.id"), nullable=True) - community: int = Column(ForeignKey("community.id"), nullable=False) + created_by = Column(ForeignKey("author.id"), nullable=False) + updated_by = Column(ForeignKey("author.id"), nullable=True) + deleted_by = Column(ForeignKey("author.id"), nullable=True) + community = Column(ForeignKey("community.id"), nullable=False) - body: str = Column(String, nullable=False, comment="Body") - slug: str = Column(String, unique=True) - cover: str | None = Column(String, nullable=True, comment="Cover image url") - cover_caption: str | None = Column(String, nullable=True, comment="Cover image alt caption") - lead: str | None = Column(String, nullable=True) - title: str = Column(String, nullable=False) - subtitle: str | None = Column(String, nullable=True) - layout: str = Column(String, nullable=False, default="article") - media: dict | None = Column(JSON, nullable=True) + body = Column(String, nullable=False, comment="Body") + slug = Column(String, unique=True) + cover = Column(String, nullable=True, comment="Cover image url") + cover_caption = Column(String, nullable=True, comment="Cover image alt caption") + lead = Column(String, nullable=True) + title = Column(String, nullable=False) + subtitle = Column(String, nullable=True) + layout = Column(String, nullable=False, default="article") + media = Column(JSON, nullable=True) authors = relationship(Author, secondary="shout_author") topics = relationship(Topic, secondary="shout_topic") reactions = relationship(Reaction) - lang: str = Column(String, nullable=False, default="ru", comment="Language") - version_of: int | None = Column(ForeignKey("shout.id"), nullable=True) - oid: str | None = Column(String, nullable=True) + lang = Column(String, nullable=False, default="ru", comment="Language") + version_of = Column(ForeignKey("shout.id"), nullable=True) + oid = Column(String, nullable=True) + seo = Column(String, nullable=True) # JSON - seo: str | None = Column(String, nullable=True) # JSON - - draft: int | None = Column(ForeignKey("draft.id"), nullable=True) + draft = Column(ForeignKey("draft.id"), nullable=True) # Определяем индексы __table_args__ = ( diff --git a/orm/topic.py b/orm/topic.py index 4be1897d..60f2657c 100644 --- a/orm/topic.py +++ b/orm/topic.py @@ -2,7 +2,7 @@ import time from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String -from services.db import Base +from services.db import BaseModel as Base class TopicFollower(Base): diff --git a/panel/App.tsx b/panel/App.tsx index ed700be1..479935d2 100644 --- a/panel/App.tsx +++ b/panel/App.tsx @@ -38,11 +38,11 @@ const App: Component = () => { const checkAuthentication = async () => { setCheckingAuth(true) setLoading(true) - + try { // Проверяем состояние авторизации const authed = isAuthenticated() - + // Если токен есть, но он невалидный, авторизация не удалась if (authed) { const token = getAuthTokenFromCookie() || localStorage.getItem('auth_token') diff --git a/panel/admin.tsx b/panel/admin.tsx index 66542cb2..8bbe05e9 100644 --- a/panel/admin.tsx +++ b/panel/admin.tsx @@ -136,28 +136,28 @@ const AdminPage: Component = (props) => { const page = parseInt(urlParams.get('page') || '1'); const limit = parseInt(urlParams.get('limit') || '10'); const search = urlParams.get('search') || ''; - + setPagination({ ...pagination(), page, limit }); setSearchQuery(search); - + // Загружаем данные при монтировании loadUsers() loadRoles() }) - + // Обновление URL при изменении параметров пагинации createEffect(() => { const pagData = pagination(); const search = searchQuery(); - + const urlParams = new URLSearchParams(); urlParams.set('page', pagData.page.toString()); urlParams.set('limit', pagData.limit.toString()); - + if (search) { urlParams.set('search', search); } - + const newUrl = `${window.location.pathname}?${urlParams.toString()}`; window.history.replaceState({}, '', newUrl); }); @@ -335,7 +335,7 @@ const AdminPage: Component = (props) => { } `, { - user: { + user: { id: userId, roles: newRoles, community: 1 // Добавляем обязательный параметр community @@ -358,7 +358,7 @@ const AdminPage: Component = (props) => { // Показываем сообщение об успехе и обновляем список пользователей setSuccessMessage('Роли пользователя успешно обновлены') - + // Перезагружаем список пользователей loadUsers() @@ -367,12 +367,12 @@ const AdminPage: Component = (props) => { } catch (err) { console.error('Ошибка обновления ролей:', err) let errorMessage = err instanceof Error ? err.message : 'Ошибка обновления ролей'; - + // Если ошибка связана с недостающим полем community if (errorMessage.includes('author_role.community')) { errorMessage = 'Ошибка: для роли author требуется указать community. Обратитесь к администратору.'; } - + setError(errorMessage) } } @@ -398,39 +398,39 @@ const AdminPage: Component = (props) => { */ function formatDateRelative(timestamp?: number): string { if (!timestamp) return 'Н/Д' - + const now = Math.floor(Date.now() / 1000) const diff = now - timestamp - + // Меньше минуты if (diff < 60) { return 'только что' } - + // Меньше часа if (diff < 3600) { const minutes = Math.floor(diff / 60) return `${minutes} ${getMinutesForm(minutes)} назад` } - + // Меньше суток if (diff < 86400) { const hours = Math.floor(diff / 3600) return `${hours} ${getHoursForm(hours)} назад` } - + // Меньше 30 дней if (diff < 2592000) { const days = Math.floor(diff / 86400) return `${days} ${getDaysForm(days)} назад` } - + // Меньше года if (diff < 31536000) { const months = Math.floor(diff / 2592000) return `${months} ${getMonthsForm(months)} назад` } - + // Больше года const years = Math.floor(diff / 31536000) return `${years} ${getYearsForm(years)} назад` @@ -759,7 +759,7 @@ const AdminPage: Component = (props) => { } catch (err) { console.error('Ошибка загрузки переменных окружения:', err) setError('Не удалось загрузить переменные окружения: ' + (err as Error).message) - + // Если ошибка авторизации - перенаправляем на логин if ( err instanceof Error && @@ -799,7 +799,7 @@ const AdminPage: Component = (props) => { } catch (err) { console.error('Ошибка обновления переменной:', err) setError('Ошибка при обновлении переменной: ' + (err as Error).message) - + // Если ошибка авторизации - перенаправляем на логин if ( err instanceof Error && @@ -855,7 +855,7 @@ const AdminPage: Component = (props) => { */ const handleTabChange = (tab: string) => { setActiveTab(tab) - + if (tab === 'env' && envSections().length === 0) { loadEnvVariables() } @@ -912,17 +912,17 @@ const AdminPage: Component = (props) => {