userlist-demo-ready
All checks were successful
Deploy on push / deploy (push) Successful in 6s

This commit is contained in:
Untone 2025-05-20 00:00:24 +03:00
parent dc5ad46df9
commit 1d64811880
17 changed files with 1347 additions and 447 deletions

View File

@ -59,6 +59,13 @@
- Унифицирован стиль кода и именования - Унифицирован стиль кода и именования
### Исправлено ### Исправлено
- Исправлена критическая проблема с JWT-токенами авторизации:
- Устранена ошибка декодирования токенов `int() argument must be a string, a bytes-like object or a real number, not 'NoneType'`
- Обновлен механизм создания токенов для гарантированного задания срока истечения (exp)
- Улучшена обработка ошибок в модуле аутентификации для предотвращения создания невалидных токенов
- Стандартизован формат параметра exp в JWT: теперь всегда используется timestamp вместо datetime
- Добавлена проверка наличия обязательных полей при декодировании токенов
- Оптимизирована совместимость между разными способами хранения сессий
- Исправлена проблема с перенаправлением в SolidJS, которое сбрасывало состояние приложения: - Исправлена проблема с перенаправлением в SolidJS, которое сбрасывало состояние приложения:
- Обновлена функция logout для использования колбэка навигации вместо жесткого редиректа - Обновлена функция logout для использования колбэка навигации вместо жесткого редиректа
- Добавлен компонент LoginPage для авторизации без перезагрузки страницы - Добавлен компонент LoginPage для авторизации без перезагрузки страницы
@ -107,8 +114,8 @@
- Страница входа для неавторизованных пользователей в админке - Страница входа для неавторизованных пользователей в админке
- Публичное GraphQL API для модуля аутентификации: - Публичное GraphQL API для модуля аутентификации:
- Типы: `AuthResult`, `Permission`, `SessionInfo`, `OAuthProvider` - Типы: `AuthResult`, `Permission`, `SessionInfo`, `OAuthProvider`
- Мутации: `login`, `registerUser`, `sendLink`, `confirmEmail`, `getSession`, `changePassword` - Мутации: `login`, `registerUser`, `sendLink`, `confirmEmail`, `getSession`, `changePassword`, `refreshToken`
- Запросы: `signOut`, `me`, `isEmailUsed`, `getOAuthProviders` - Запросы: `logout`, `me`, `isEmailUsed`, `getOAuthProviders`
### Changed ### Changed
- Переработана структура модуля auth для лучшей модульности - Переработана структура модуля auth для лучшей модульности

View File

@ -13,20 +13,42 @@ from settings import (
SESSION_COOKIE_SECURE, SESSION_COOKIE_SECURE,
SESSION_COOKIE_SAMESITE, SESSION_COOKIE_SAMESITE,
SESSION_COOKIE_MAX_AGE, SESSION_COOKIE_MAX_AGE,
SESSION_TOKEN_HEADER,
) )
async def logout(request: Request): async def logout(request: Request):
""" """
Выход из системы с удалением сессии и cookie. Выход из системы с удалением сессии и cookie.
Поддерживает получение токена из:
1. HTTP-only cookie
2. Заголовка Authorization
""" """
# Получаем токен из cookie или заголовка token = None
token = request.cookies.get(SESSION_COOKIE_NAME) # Получаем токен из cookie
if SESSION_COOKIE_NAME in request.cookies:
token = request.cookies.get(SESSION_COOKIE_NAME)
logger.debug(f"[auth] logout: Получен токен из cookie {SESSION_COOKIE_NAME}")
# Если токен не найден в cookie, проверяем заголовок
if not token: if not token:
# Проверяем заголовок авторизации # Сначала проверяем основной заголовок авторизации
auth_header = request.headers.get(SESSION_TOKEN_HEADER)
if auth_header:
if auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
logger.debug(f"[auth] logout: Получен Bearer токен из заголовка {SESSION_TOKEN_HEADER}")
else:
token = auth_header.strip()
logger.debug(f"[auth] logout: Получен прямой токен из заголовка {SESSION_TOKEN_HEADER}")
# Если токен не найден в основном заголовке, проверяем стандартный Authorization
if not token and "Authorization" in request.headers:
auth_header = request.headers.get("Authorization") auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "): if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:] # Отрезаем "Bearer " token = auth_header[7:].strip()
logger.debug("[auth] logout: Получен Bearer токен из заголовка Authorization")
# Если токен найден, отзываем его # Если токен найден, отзываем его
if token: if token:
@ -41,12 +63,19 @@ async def logout(request: Request):
logger.warning("[auth] logout: Не удалось получить user_id из токена") logger.warning("[auth] logout: Не удалось получить user_id из токена")
except Exception as e: except Exception as e:
logger.error(f"[auth] logout: Ошибка при отзыве токена: {e}") logger.error(f"[auth] logout: Ошибка при отзыве токена: {e}")
else:
logger.warning("[auth] logout: Токен не найден в запросе")
# Создаем ответ с редиректом на страницу входа # Создаем ответ с редиректом на страницу входа
response = RedirectResponse(url="/") response = RedirectResponse(url="/")
# Удаляем cookie с токеном # Удаляем cookie с токеном
response.delete_cookie(SESSION_COOKIE_NAME) response.delete_cookie(
key=SESSION_COOKIE_NAME,
secure=SESSION_COOKIE_SECURE,
httponly=SESSION_COOKIE_HTTPONLY,
samesite=SESSION_COOKIE_SAMESITE
)
logger.info("[auth] logout: Cookie успешно удалена") logger.info("[auth] logout: Cookie успешно удалена")
return response return response
@ -55,21 +84,53 @@ async def logout(request: Request):
async def refresh_token(request: Request): async def refresh_token(request: Request):
""" """
Обновление токена аутентификации. Обновление токена аутентификации.
Поддерживает получение токена из:
1. HTTP-only cookie
2. Заголовка Authorization
Возвращает новый токен как в HTTP-only cookie, так и в теле ответа.
""" """
# Получаем текущий токен из cookie или заголовка token = None
token = request.cookies.get(SESSION_COOKIE_NAME) source = None
# Получаем текущий токен из cookie
if SESSION_COOKIE_NAME in request.cookies:
token = request.cookies.get(SESSION_COOKIE_NAME)
source = "cookie"
logger.debug(f"[auth] refresh_token: Токен получен из cookie {SESSION_COOKIE_NAME}")
# Если токен не найден в cookie, проверяем заголовок авторизации
if not token: if not token:
# Проверяем основной заголовок авторизации
auth_header = request.headers.get(SESSION_TOKEN_HEADER)
if auth_header:
if auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
source = "header"
logger.debug(f"[auth] refresh_token: Токен получен из заголовка {SESSION_TOKEN_HEADER} (Bearer)")
else:
token = auth_header.strip()
source = "header"
logger.debug(f"[auth] refresh_token: Токен получен из заголовка {SESSION_TOKEN_HEADER} (прямой)")
# Если токен не найден в основном заголовке, проверяем стандартный Authorization
if not token and "Authorization" in request.headers:
auth_header = request.headers.get("Authorization") auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "): if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:] # Отрезаем "Bearer " token = auth_header[7:].strip()
source = "header"
logger.debug("[auth] refresh_token: Токен получен из заголовка Authorization")
if not token: if not token:
logger.warning("[auth] refresh_token: Токен не найден в запросе")
return JSONResponse({"success": False, "error": "Токен не найден"}, status_code=401) return JSONResponse({"success": False, "error": "Токен не найден"}, status_code=401)
try: try:
# Получаем информацию о пользователе из токена # Получаем информацию о пользователе из токена
user_id, _ = await verify_internal_auth(token) user_id, _ = await verify_internal_auth(token)
if not user_id: if not user_id:
logger.warning("[auth] refresh_token: Недействительный токен")
return JSONResponse({"success": False, "error": "Недействительный токен"}, status_code=401) return JSONResponse({"success": False, "error": "Недействительный токен"}, status_code=401)
# Получаем пользователя из базы данных # Получаем пользователя из базы данных
@ -77,6 +138,7 @@ async def refresh_token(request: Request):
author = session.query(Author).filter(Author.id == user_id).first() author = session.query(Author).filter(Author.id == user_id).first()
if not author: if not author:
logger.warning(f"[auth] refresh_token: Пользователь с ID {user_id} не найден")
return JSONResponse({"success": False, "error": "Пользователь не найден"}, status_code=404) return JSONResponse({"success": False, "error": "Пользователь не найден"}, status_code=404)
# Обновляем сессию (создаем новую и отзываем старую) # Обновляем сессию (создаем новую и отзываем старую)
@ -84,6 +146,7 @@ async def refresh_token(request: Request):
new_token = await SessionManager.refresh_session(user_id, token, device_info) new_token = await SessionManager.refresh_session(user_id, token, device_info)
if not new_token: if not new_token:
logger.error(f"[auth] refresh_token: Не удалось обновить токен для пользователя {user_id}")
return JSONResponse( return JSONResponse(
{"success": False, "error": "Не удалось обновить токен"}, status_code=500 {"success": False, "error": "Не удалось обновить токен"}, status_code=500
) )
@ -92,12 +155,13 @@ async def refresh_token(request: Request):
response = JSONResponse( response = JSONResponse(
{ {
"success": True, "success": True,
"token": new_token, # Возвращаем токен в теле ответа только если он был получен из заголовка
"token": new_token if source == "header" else None,
"author": {"id": author.id, "email": author.email, "name": author.name}, "author": {"id": author.id, "email": author.email, "name": author.name},
} }
) )
# Устанавливаем cookie с новым токеном # Всегда устанавливаем cookie с новым токеном
response.set_cookie( response.set_cookie(
key=SESSION_COOKIE_NAME, key=SESSION_COOKIE_NAME,
value=new_token, value=new_token,

View File

@ -1,133 +0,0 @@
from functools import wraps
from typing import Optional
from graphql.type import GraphQLResolveInfo
from sqlalchemy.orm import exc
from starlette.authentication import AuthenticationBackend
from starlette.requests import HTTPConnection
from auth.credentials import AuthCredentials
from auth.exceptions import OperationNotAllowed
from auth.sessions import SessionManager
from auth.orm import Author
from services.db import local_session
from settings import SESSION_TOKEN_HEADER
class JWTAuthenticate(AuthenticationBackend):
async def authenticate(self, request: HTTPConnection) -> Optional[AuthCredentials]:
"""
Аутентификация пользователя по JWT токену.
Args:
request: HTTP запрос
Returns:
AuthCredentials при успешной аутентификации или None при ошибке
"""
if SESSION_TOKEN_HEADER not in request.headers:
return None
auth_header = request.headers.get(SESSION_TOKEN_HEADER)
if not auth_header:
print("[auth.authenticate] no token in header %s" % SESSION_TOKEN_HEADER)
return None
# Обработка формата "Bearer <token>"
token = auth_header
if auth_header.startswith("Bearer "):
token = auth_header.replace("Bearer ", "", 1).strip()
if not token:
print("[auth.authenticate] empty token after Bearer prefix removal")
return None
# Проверяем сессию в Redis
payload = await SessionManager.verify_session(token)
if not payload:
return None
with local_session() as session:
try:
author = (
session.query(Author)
.filter(Author.id == payload.user_id)
.filter(Author.is_active == True) # noqa
.one()
)
if author.is_locked():
return None
# Получаем разрешения из ролей
scopes = author.get_permissions()
return AuthCredentials(
author_id=author.id, scopes=scopes, logged_in=True, email=author.email
)
except exc.NoResultFound:
return None
def login_required(func):
@wraps(func)
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
auth: AuthCredentials = info.context["request"].auth
if not auth or not auth.logged_in:
return {"error": "Please login first"}
return await func(parent, info, *args, **kwargs)
return wrap
def permission_required(resource: str, operation: str, func):
"""
Декоратор для проверки разрешений.
Args:
resource (str): Ресурс для проверки
operation (str): Операция для проверки
func: Декорируемая функция
"""
@wraps(func)
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
auth: AuthCredentials = info.context["request"].auth
if not auth.logged_in:
raise OperationNotAllowed(auth.error_message or "Please login")
with local_session() as session:
author = session.query(Author).filter(Author.id == auth.author_id).one()
# Проверяем базовые условия
if not author.is_active:
raise OperationNotAllowed("Account is not active")
if author.is_locked():
raise OperationNotAllowed("Account is locked")
# Проверяем разрешение
if not author.has_permission(resource, operation):
raise OperationNotAllowed(f"No permission for {operation} on {resource}")
return await func(parent, info, *args, **kwargs)
return wrap
def login_accepted(func):
@wraps(func)
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
auth: AuthCredentials = info.context["request"].auth
if auth and auth.logged_in:
with local_session() as session:
author = session.query(Author).filter(Author.id == auth.author_id).one()
info.context["author"] = author.dict()
info.context["user_id"] = author.id
else:
info.context["author"] = None
info.context["user_id"] = None
return await func(parent, info, *args, **kwargs)
return wrap

View File

@ -29,6 +29,7 @@ class AuthCredentials(BaseModel):
logged_in: bool = Field(False, description="Флаг, указывающий, авторизован ли пользователь") logged_in: bool = Field(False, description="Флаг, указывающий, авторизован ли пользователь")
error_message: str = Field("", description="Сообщение об ошибке аутентификации") error_message: str = Field("", description="Сообщение об ошибке аутентификации")
email: Optional[str] = Field(None, description="Email пользователя") 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]:
""" """

View File

@ -1,11 +1,19 @@
from functools import wraps from functools import wraps
from typing import Callable, Any, Dict, Optional from typing import Callable, Any, Dict, Optional
from graphql import GraphQLError from graphql import GraphQLError, GraphQLResolveInfo
from sqlalchemy import exc
from auth.credentials import AuthCredentials
from services.db import local_session from services.db import local_session
from auth.orm import Author from auth.orm import Author
from auth.exceptions import OperationNotAllowed from auth.exceptions import OperationNotAllowed
from utils.logger import root_logger as logger from utils.logger import root_logger as logger
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST, SESSION_COOKIE_NAME from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST, SESSION_TOKEN_HEADER, SESSION_COOKIE_NAME
from auth.sessions import SessionManager
from auth.jwtcodec import JWTCodec, InvalidToken, ExpiredToken
from auth.tokenstorage import TokenStorage
from services.redis import redis
from auth.internal import authenticate
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",") ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
@ -22,24 +30,51 @@ def get_safe_headers(request: Any) -> Dict[str, str]:
""" """
headers = {} headers = {}
try: try:
# Проверяем разные варианты доступа к заголовкам # Первый приоритет: scope из ASGI (самый надежный источник)
if hasattr(request, "_headers"):
headers.update(request._headers)
if hasattr(request, "headers"):
headers.update(request.headers)
if hasattr(request, "scope") and isinstance(request.scope, dict): if hasattr(request, "scope") and isinstance(request.scope, dict):
headers.update({ scope_headers = request.scope.get("headers", [])
k.decode("utf-8").lower(): v.decode("utf-8") if scope_headers:
for k, v in request.scope.get("headers", []) headers.update({
}) k.decode("utf-8").lower(): v.decode("utf-8")
for k, v in scope_headers
})
logger.debug(f"[decorators] Получены заголовки из request.scope: {len(headers)}")
# Второй приоритет: метод headers() или атрибут headers
if hasattr(request, "headers"):
if callable(request.headers):
h = request.headers()
if h:
headers.update({k.lower(): v for k, v in h.items()})
logger.debug(f"[decorators] Получены заголовки из request.headers() метода: {len(headers)}")
else:
h = request.headers
if hasattr(h, "items") and callable(h.items):
headers.update({k.lower(): v for k, v in h.items()})
logger.debug(f"[decorators] Получены заголовки из request.headers атрибута: {len(headers)}")
elif isinstance(h, dict):
headers.update({k.lower(): v for k, v in h.items()})
logger.debug(f"[decorators] Получены заголовки из request.headers словаря: {len(headers)}")
# Третий приоритет: атрибут _headers
if hasattr(request, "_headers") and request._headers:
headers.update({k.lower(): v for k, v in request._headers.items()})
logger.debug(f"[decorators] Получены заголовки из request._headers: {len(headers)}")
except Exception as e: except Exception as e:
logger.warning(f"Error accessing headers: {e}") logger.warning(f"[decorators] Ошибка при доступе к заголовкам: {e}")
return headers return headers
def get_auth_token(request: Any) -> Optional[str]: def get_auth_token(request: Any) -> Optional[str]:
""" """
Извлекает токен авторизации из запроса. Извлекает токен авторизации из запроса.
Порядок проверки:
1. Проверяет auth из middleware
2. Проверяет auth из scope
3. Проверяет заголовок Authorization
4. Проверяет cookie с именем auth_token
Args: Args:
request: Объект запроса request: Объект запроса
@ -48,60 +83,115 @@ def get_auth_token(request: Any) -> Optional[str]:
Optional[str]: Токен авторизации или None Optional[str]: Токен авторизации или None
""" """
try: try:
# Проверяем auth из middleware # 1. Проверяем auth из middleware (если middleware уже обработал токен)
if hasattr(request, "auth") and request.auth: if hasattr(request, "auth") and request.auth:
return getattr(request.auth, "token", None) token = getattr(request.auth, "token", None)
if token:
logger.debug(f"[decorators] Токен получен из request.auth: {len(token)}")
return token
# Проверяем заголовок # 2. Проверяем наличие auth в scope
if hasattr(request, "scope") and isinstance(request.scope, dict) and "auth" in request.scope:
auth_info = request.scope.get("auth", {})
if isinstance(auth_info, dict) and "token" in auth_info:
token = auth_info["token"]
logger.debug(f"[decorators] Токен получен из request.scope['auth']: {len(token)}")
return token
# 3. Проверяем заголовок Authorization
headers = get_safe_headers(request) headers = get_safe_headers(request)
auth_header = headers.get("authorization", "")
if auth_header.startswith("Bearer "): # Сначала проверяем основной заголовок авторизации
return auth_header[7:].strip() auth_header = headers.get(SESSION_TOKEN_HEADER.lower(), "")
if auth_header:
if auth_header.startswith("Bearer "):
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
# Затем проверяем стандартный заголовок Authorization, если основной не определен
if SESSION_TOKEN_HEADER.lower() != "authorization":
auth_header = headers.get("authorization", "")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
logger.debug(f"[decorators] Токен получен из заголовка Authorization: {len(token)}")
return token
# Проверяем cookie # 4. Проверяем cookie
if hasattr(request, "cookies"): if hasattr(request, "cookies") and request.cookies:
return request.cookies.get(SESSION_COOKIE_NAME) token = request.cookies.get(SESSION_COOKIE_NAME)
if token:
logger.debug(f"[decorators] Токен получен из cookie {SESSION_COOKIE_NAME}: {len(token)}")
return token
# Если токен не найден ни в одном из мест
logger.debug("[decorators] Токен авторизации не найден")
return None return None
except Exception as e: except Exception as e:
logger.warning(f"Error extracting auth token: {e}") logger.warning(f"[decorators] Ошибка при извлечении токена: {e}")
return None return None
def validate_graphql_context(info: Any) -> None: async def validate_graphql_context(info: Any) -> None:
""" """
Проверяет валидность GraphQL контекста. Проверяет валидность GraphQL контекста и проверяет авторизацию.
Args: Args:
info: GraphQL информация о контексте info: GraphQL информация о контексте
Raises: Raises:
GraphQLError: если контекст невалиден GraphQLError: если контекст невалиден или пользователь не авторизован
""" """
# Проверка базовой структуры контекста
if info is None or not hasattr(info, "context"): if info is None or not hasattr(info, "context"):
logger.error("Missing GraphQL context information") logger.error("[decorators] Missing GraphQL context information")
raise GraphQLError("Internal server error: missing context") raise GraphQLError("Internal server error: missing context")
request = info.context.get("request") request = info.context.get("request")
if not request: if not request:
logger.error("Missing request in context") logger.error("[decorators] Missing request in context")
raise GraphQLError("Internal server error: missing request") raise GraphQLError("Internal server error: missing request")
# Проверяем auth из контекста # Проверяем auth из контекста - если уже авторизован, просто возвращаем
auth = getattr(request, "auth", None) auth = getattr(request, "auth", None)
if not auth or not auth.logged_in: if auth and auth.logged_in:
# Пробуем получить токен logger.debug(f"[decorators] Пользователь уже авторизован: {auth.author_id}")
token = get_auth_token(request) return
if not token:
client_info = { # Если аутентификации нет в request.auth, пробуем получить ее из scope
"ip": getattr(request.client, "host", "unknown") if hasattr(request, "client") else "unknown", if hasattr(request, "scope") and "auth" in request.scope:
"headers": get_safe_headers(request) auth_cred = request.scope.get("auth")
} if isinstance(auth_cred, AuthCredentials) and auth_cred.logged_in:
logger.warning(f"No auth token found: {client_info}") logger.debug(f"[decorators] Пользователь авторизован через scope: {auth_cred.author_id}")
raise GraphQLError("Unauthorized - please login") # В этом случае мы не делаем return, чтобы также проверить токен если нужно
logger.warning(f"Found token but auth not initialized") # Если авторизации нет ни в auth, ни в scope, пробуем получить и проверить токен
raise GraphQLError("Unauthorized - session expired") token = get_auth_token(request)
if not token:
# Если токен не найден, возвращаем ошибку авторизации
client_info = {
"ip": getattr(request.client, "host", "unknown") if hasattr(request, "client") else "unknown",
"headers": get_safe_headers(request)
}
logger.warning(f"[decorators] Токен авторизации не найден: {client_info}")
raise GraphQLError("Unauthorized - please login")
# Используем единый механизм проверки токена из auth.internal
auth_state = await authenticate(request)
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}")
# Если все проверки пройдены, оставляем AuthState в scope
# AuthenticationMiddleware извлечет нужные данные оттуда при необходимости
logger.debug(f"[decorators] Токен успешно проверен для пользователя {auth_state.author_id}")
return
def admin_auth_required(resolver: Callable) -> Callable: def admin_auth_required(resolver: Callable) -> Callable:
@ -126,18 +216,28 @@ def admin_auth_required(resolver: Callable) -> Callable:
@wraps(resolver) @wraps(resolver)
async def wrapper(root: Any = None, info: Any = None, **kwargs): async def wrapper(root: Any = None, info: Any = None, **kwargs):
try: try:
validate_graphql_context(info) await validate_graphql_context(info)
auth = info.context["request"].auth auth = info.context["request"].auth
with local_session() as session: with local_session() as session:
author = session.query(Author).filter(Author.id == auth.author_id).one() try:
# Преобразуем author_id в int для совместимости с базой данных
if author.email in ADMIN_EMAILS: author_id = int(auth.author_id) if auth and auth.author_id else None
logger.info(f"Admin access granted for {author.email} (ID: {author.id})") if not author_id:
return await resolver(root, info, **kwargs) logger.error(f"[admin_auth_required] ID автора не определен: {auth}")
raise GraphQLError("Unauthorized - invalid user ID")
logger.warning(f"Admin access denied for {author.email} (ID: {author.id})")
raise GraphQLError("Unauthorized - not an admin") 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)
logger.warning(f"Admin access denied for {author.email} (ID: {author.id})")
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")
except Exception as e: except Exception as e:
error_msg = str(e) error_msg = str(e)
@ -149,61 +249,57 @@ def admin_auth_required(resolver: Callable) -> Callable:
return wrapper return wrapper
def require_permission(permission_string: str) -> Callable:
def permission_required(resource: str, operation: str, func):
""" """
Декоратор для проверки наличия указанного разрешения. Декоратор для проверки разрешений.
Принимает строку в формате "resource:permission".
Args: Args:
permission_string: Строка в формате "resource:permission" resource (str): Ресурс для проверки
operation (str): Операция для проверки
Returns: func: Декорируемая функция
Декоратор, проверяющий наличие указанного разрешения
Raises:
ValueError: если строка разрешения имеет неверный формат
Example:
>>> @require_permission("articles:edit")
... async def edit_article(root, info, article_id: int):
... return f"Editing article {article_id}"
""" """
if not isinstance(permission_string, str) or ":" not in permission_string:
raise ValueError('Permission string must be in format "resource:permission"')
resource, operation = permission_string.split(":", 1) @wraps(func)
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
if not all([resource.strip(), operation.strip()]): auth: AuthCredentials = info.context["request"].auth
raise ValueError("Both resource and permission must be non-empty") if not auth.logged_in:
raise OperationNotAllowed(auth.error_message or "Please login")
def decorator(func: Callable) -> Callable: with local_session() as session:
@wraps(func) author = session.query(Author).filter(Author.id == auth.author_id).one()
async def wrapper(parent, info: Any = None, *args, **kwargs):
try:
validate_graphql_context(info)
auth = info.context["request"].auth
with local_session() as session: # Проверяем базовые условия
author = session.query(Author).filter(Author.id == auth.author_id).one() if not author.is_active:
raise OperationNotAllowed("Account is not active")
if author.is_locked():
raise OperationNotAllowed("Account is locked")
if not author.is_active: # Проверяем разрешение
raise OperationNotAllowed("Account is not active") if not author.has_permission(resource, operation):
if author.is_locked(): raise OperationNotAllowed(f"No permission for {operation} on {resource}")
raise OperationNotAllowed("Account is locked")
if not author.has_permission(resource, operation):
logger.warning(
f"Access denied for user {auth.author_id} - no permission {resource}:{operation}"
)
raise OperationNotAllowed(f"No permission for {operation} on {resource}")
return await func(parent, info, *args, **kwargs) return await func(parent, info, *args, **kwargs)
except Exception as e: return wrap
if isinstance(e, (OperationNotAllowed, GraphQLError)):
raise e
logger.error(f"Error in require_permission: {e}")
raise OperationNotAllowed(str(e))
return wrapper
return decorator
def login_accepted(func):
@wraps(func)
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
auth: AuthCredentials = info.context["request"].auth
if auth and auth.logged_in:
with local_session() as session:
author = session.query(Author).filter(Author.id == auth.author_id).one()
info.context["author"] = author.dict()
info.context["user_id"] = author.id
else:
info.context["author"] = None
info.context["user_id"] = None
return await func(parent, info, *args, **kwargs)
return wrap

View File

@ -1,5 +1,6 @@
from typing import Optional, Tuple from typing import Optional, Tuple
import time import time
from typing import Any
from sqlalchemy.orm import exc from sqlalchemy.orm import exc
from starlette.authentication import AuthenticationBackend, BaseUser, UnauthenticatedUser from starlette.authentication import AuthenticationBackend, BaseUser, UnauthenticatedUser
@ -9,18 +10,32 @@ from auth.credentials import AuthCredentials
from auth.orm import Author from auth.orm import Author
from auth.sessions import SessionManager from auth.sessions import SessionManager
from services.db import local_session from services.db import local_session
from settings import SESSION_TOKEN_HEADER from settings import SESSION_TOKEN_HEADER, SESSION_COOKIE_NAME, ADMIN_EMAILS as ADMIN_EMAILS_LIST
from utils.logger import root_logger as logger from utils.logger import root_logger as logger
from auth.jwtcodec import JWTCodec
from auth.exceptions import ExpiredToken, InvalidToken
from auth.state import AuthState
from auth.tokenstorage import TokenStorage
from services.redis import redis
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
class AuthenticatedUser(BaseUser): class AuthenticatedUser(BaseUser):
"""Аутентифицированный пользователь для Starlette""" """Аутентифицированный пользователь для Starlette"""
def __init__(self, user_id: str, username: str = "", roles: list = None, permissions: dict = None): def __init__(self,
user_id: str,
username: str = "",
roles: list = None,
permissions: dict = None,
token: str = None
):
self.user_id = user_id self.user_id = user_id
self.username = username self.username = username
self.roles = roles or [] self.roles = roles or []
self.permissions = permissions or {} self.permissions = permissions or {}
self.token = token
@property @property
def is_authenticated(self) -> bool: def is_authenticated(self) -> bool:
@ -40,19 +55,44 @@ class InternalAuthentication(AuthenticationBackend):
async def authenticate(self, request: HTTPConnection): async def authenticate(self, request: HTTPConnection):
""" """
Аутентифицирует пользователя по токену из заголовка. Аутентифицирует пользователя по токену из заголовка или cookie.
Токен должен быть обработан заранее AuthorizationMiddleware,
который извлекает Bearer токен и преобразует его в чистый токен. Порядок поиска токена:
1. Проверяем заголовок SESSION_TOKEN_HEADER (может быть установлен middleware)
2. Проверяем scope/auth в request, куда middleware мог сохранить токен
3. Проверяем cookie
Возвращает: Возвращает:
tuple: (AuthCredentials, BaseUser) tuple: (AuthCredentials, BaseUser)
""" """
if SESSION_TOKEN_HEADER not in request.headers: token = None
return AuthCredentials(scopes={}), UnauthenticatedUser()
# 1. Проверяем заголовок
token = request.headers.get(SESSION_TOKEN_HEADER) if SESSION_TOKEN_HEADER in request.headers:
token_header = request.headers.get(SESSION_TOKEN_HEADER)
if token_header:
if token_header.startswith("Bearer "):
token = token_header.replace("Bearer ", "", 1).strip()
logger.debug(f"[auth.authenticate] Извлечен Bearer токен из заголовка {SESSION_TOKEN_HEADER}")
else:
token = token_header.strip()
logger.debug(f"[auth.authenticate] Извлечен прямой токен из заголовка {SESSION_TOKEN_HEADER}")
# 2. Проверяем scope/auth, который мог быть установлен middleware
if not token and hasattr(request, "scope") and "auth" in request.scope:
auth_data = request.scope.get("auth", {})
if isinstance(auth_data, dict) and "token" in auth_data:
token = auth_data["token"]
logger.debug(f"[auth.authenticate] Извлечен токен из request.scope['auth']")
# 3. Проверяем cookie
if not token and hasattr(request, "cookies") and SESSION_COOKIE_NAME in request.cookies:
token = request.cookies.get(SESSION_COOKIE_NAME)
logger.debug(f"[auth.authenticate] Извлечен токен из cookie {SESSION_COOKIE_NAME}")
# Если токен не найден, возвращаем неаутентифицированного пользователя
if not token: if not token:
logger.debug("[auth.authenticate] Пустой токен в заголовке") logger.debug("[auth.authenticate] Токен не найден")
return AuthCredentials(scopes={}, error_message="no token"), UnauthenticatedUser() return AuthCredentials(scopes={}, error_message="no token"), UnauthenticatedUser()
# Проверяем сессию в Redis # Проверяем сессию в Redis
@ -86,9 +126,13 @@ class InternalAuthentication(AuthenticationBackend):
author.last_seen = int(time.time()) author.last_seen = int(time.time())
session.commit() session.commit()
# Создаем объекты авторизации # Создаем объекты авторизации с сохранением токена
credentials = AuthCredentials( credentials = AuthCredentials(
author_id=author.id, scopes=scopes, logged_in=True, email=author.email author_id=author.id,
scopes=scopes,
logged_in=True,
email=author.email,
token=token
) )
user = AuthenticatedUser( user = AuthenticatedUser(
@ -96,6 +140,7 @@ class InternalAuthentication(AuthenticationBackend):
username=author.slug or author.email or "", username=author.slug or author.email or "",
roles=roles, roles=roles,
permissions=scopes, permissions=scopes,
token=token
) )
logger.debug(f"[auth.authenticate] Успешная аутентификация: {author.email}") logger.debug(f"[auth.authenticate] Успешная аутентификация: {author.email}")
@ -166,3 +211,76 @@ async def create_internal_session(author: Author, device_info: Optional[dict] =
username=author.slug or author.email or author.phone or "", username=author.slug or author.email or author.phone or "",
device_info=device_info, device_info=device_info,
) )
async def authenticate(request: Any) -> AuthState:
"""
Аутентифицирует запрос по токену из разных источников.
Порядок проверки:
1. Проверяет токен в заголовке Authorization
2. Проверяет токен в cookie
Args:
request: Запрос (обычно из middleware)
Returns:
AuthState: Состояние авторизации
"""
state = AuthState()
state.logged_in = False # Изначально считаем, что пользователь не авторизован
token = None
# Проверяем наличие auth в scope (установлено middleware)
if hasattr(request, "scope") and isinstance(request.scope, dict) and "auth" in request.scope:
auth_info = request.scope.get("auth", {})
if isinstance(auth_info, dict) and "token" in auth_info:
token = auth_info["token"]
logger.debug("[auth.authenticate] Извлечен токен из request.scope['auth']")
# Если токен не найден в scope, проверяем заголовок
if not token:
try:
headers = {}
if hasattr(request, "headers"):
if callable(request.headers):
headers = dict(request.headers())
else:
headers = dict(request.headers)
auth_header = headers.get(SESSION_TOKEN_HEADER, "")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
logger.debug(f"[auth.authenticate] Токен получен из заголовка {SESSION_TOKEN_HEADER}")
elif auth_header:
token = auth_header.strip()
logger.debug(f"[auth.authenticate] Прямой токен получен из заголовка {SESSION_TOKEN_HEADER}")
except Exception as e:
logger.error(f"[auth.authenticate] Ошибка при доступе к заголовкам: {e}")
# Если и в заголовке не найден, проверяем cookie
if not token and hasattr(request, "cookies") and request.cookies:
token = request.cookies.get(SESSION_COOKIE_NAME)
if token:
logger.debug(f"[auth.authenticate] Токен получен из cookie {SESSION_COOKIE_NAME}")
# Если токен все еще не найден, возвращаем не авторизованное состояние
if not token:
logger.debug("[auth.authenticate] Токен не найден")
return state
# Проверяем токен через SessionManager, который теперь совместим с TokenStorage
payload = await SessionManager.verify_session(token)
if not payload:
logger.warning(f"[auth.authenticate] Токен не валиден: не найдена сессия")
state.error = "Invalid or expired token"
return state
# Создаем успешное состояние авторизации
state.logged_in = True
state.author_id = payload.user_id
state.token = token
state.username = payload.username
logger.info(f"[auth.authenticate] Успешная аутентификация пользователя {state.author_id}")
return state

View File

@ -1,37 +1,71 @@
from datetime import datetime, timezone from datetime import datetime, timezone, timedelta
import jwt import jwt
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional
from utils.logger import root_logger as logger
from auth.exceptions import ExpiredToken, InvalidToken from auth.exceptions import ExpiredToken, InvalidToken
from settings import JWT_ALGORITHM, JWT_SECRET_KEY from settings import JWT_ALGORITHM, JWT_SECRET_KEY
class TokenPayload(BaseModel): class TokenPayload(BaseModel):
user_id: str user_id: str
username: str username: str
exp: datetime exp: Optional[datetime] = None
iat: datetime iat: datetime
iss: str iss: str
class JWTCodec: class JWTCodec:
@staticmethod @staticmethod
def encode(user, exp: datetime) -> str: def encode(user, exp: Optional[datetime] = None) -> str:
# Поддержка как объектов, так и словарей
if isinstance(user, dict):
# В SessionManager.create_session передается словарь {"id": user_id, "email": username}
user_id = str(user.get("id", ""))
username = user.get("email", "") or user.get("username", "")
else:
# Для объектов с атрибутами
user_id = str(getattr(user, "id", ""))
username = getattr(user, "slug", "") or getattr(user, "email", "") or getattr(user, "phone", "") or ""
logger.debug(f"[JWTCodec.encode] Кодирование токена для user_id={user_id}, username={username}")
# Если время истечения не указано, установим срок годности на 30 дней
if exp is None:
exp = datetime.now(tz=timezone.utc) + timedelta(days=30)
logger.debug(f"[JWTCodec.encode] Время истечения не указано, устанавливаем срок: {exp}")
# Важно: убедимся, что exp всегда является либо datetime, либо целым числом от timestamp
if isinstance(exp, datetime):
# Преобразуем datetime в timestamp чтобы гарантировать правильный формат
exp_timestamp = int(exp.timestamp())
else:
# Если передано что-то другое, установим значение по умолчанию
logger.warning(f"[JWTCodec.encode] Некорректный формат exp: {exp}, используем значение по умолчанию")
exp_timestamp = int((datetime.now(tz=timezone.utc) + timedelta(days=30)).timestamp())
payload = { payload = {
"user_id": user.id, "user_id": user_id,
"username": user.slug or user.email or user.phone or "", "username": username,
"exp": exp, "exp": exp_timestamp, # Используем timestamp вместо datetime
"iat": datetime.now(tz=timezone.utc), "iat": datetime.now(tz=timezone.utc),
"iss": "discours", "iss": "discours",
} }
logger.debug(f"[JWTCodec.encode] Сформирован payload: {payload}")
try: try:
return jwt.encode(payload, JWT_SECRET_KEY, JWT_ALGORITHM) token = jwt.encode(payload, JWT_SECRET_KEY, JWT_ALGORITHM)
logger.debug(f"[JWTCodec.encode] Токен успешно создан, длина: {len(token) if token else 0}")
return token
except Exception as e: except Exception as e:
print("[auth.jwtcodec] JWT encode error %r" % e) logger.error(f"[JWTCodec.encode] Ошибка при кодировании JWT: {e}")
raise
@staticmethod @staticmethod
def decode(token: str, verify_exp: bool = True): def decode(token: str, verify_exp: bool = True):
logger.debug(f"[JWTCodec.decode] Начало декодирования токена длиной {len(token) if token else 0}")
r = None r = None
payload = None payload = None
try: try:
@ -45,18 +79,33 @@ class JWTCodec:
algorithms=[JWT_ALGORITHM], algorithms=[JWT_ALGORITHM],
issuer="discours", issuer="discours",
) )
logger.debug(f"[JWTCodec.decode] Декодирован payload: {payload}")
# Убедимся, что exp существует (добавим обработку если exp отсутствует)
if "exp" not in payload:
logger.warning(f"[JWTCodec.decode] В токене отсутствует поле exp")
# Добавим exp по умолчанию, чтобы избежать ошибки при создании TokenPayload
payload["exp"] = int((datetime.now(tz=timezone.utc) + timedelta(days=30)).timestamp())
r = TokenPayload(**payload) r = TokenPayload(**payload)
# print('[auth.jwtcodec] debug token %r' % r) logger.debug(f"[JWTCodec.decode] Создан объект TokenPayload: user_id={r.user_id}, username={r.username}")
return r return r
except jwt.InvalidIssuedAtError: except jwt.InvalidIssuedAtError:
print("[auth.jwtcodec] invalid issued at: %r" % payload) logger.error(f"[JWTCodec.decode] Недействительное время выпуска токена: {payload}")
raise ExpiredToken("jwt check token issued time") raise ExpiredToken("jwt check token issued time")
except jwt.ExpiredSignatureError: except jwt.ExpiredSignatureError:
print("[auth.jwtcodec] expired signature %r" % payload) logger.error(f"[JWTCodec.decode] Истек срок действия токена: {payload}")
raise ExpiredToken("jwt check token lifetime") raise ExpiredToken("jwt check token lifetime")
except jwt.InvalidSignatureError: except jwt.InvalidSignatureError:
logger.error("[JWTCodec.decode] Недействительная подпись токена")
raise InvalidToken("jwt check signature is not valid") raise InvalidToken("jwt check signature is not valid")
except jwt.InvalidTokenError: except jwt.InvalidTokenError:
logger.error("[JWTCodec.decode] Недействительный токен")
raise InvalidToken("jwt check token is not valid") raise InvalidToken("jwt check token is not valid")
except jwt.InvalidKeyError: except jwt.InvalidKeyError:
logger.error("[JWTCodec.decode] Недействительный ключ")
raise InvalidToken("jwt check key is not valid") raise InvalidToken("jwt check key is not valid")
except Exception as e:
logger.error(f"[JWTCodec.decode] Неожиданная ошибка при декодировании: {e}")
raise InvalidToken(f"Ошибка декодирования: {str(e)}")

View File

@ -30,15 +30,34 @@ class AuthMiddleware:
# Извлекаем заголовки # Извлекаем заголовки
headers = Headers(scope=scope) headers = Headers(scope=scope)
auth_header = headers.get(SESSION_TOKEN_HEADER)
token = None token = None
token_source = None
# Сначала пробуем получить токен из заголовка Authorization # Сначала пробуем получить токен из заголовка авторизации
auth_header = headers.get(SESSION_TOKEN_HEADER)
if auth_header: if auth_header:
if auth_header.startswith("Bearer "): if auth_header.startswith("Bearer "):
token = auth_header.replace("Bearer ", "", 1).strip() token = auth_header.replace("Bearer ", "", 1).strip()
token_source = "header"
logger.debug( logger.debug(
f"[middleware] Извлечен Bearer токен из заголовка, длина: {len(token) if token else 0}" 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}"
)
# Если токен не получен из основного заголовка и это не Authorization, проверяем заголовок Authorization
if not token and SESSION_TOKEN_HEADER.lower() != "authorization":
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}"
) )
# Если токен не получен из заголовка, пробуем взять из cookie # Если токен не получен из заголовка, пробуем взять из cookie
@ -50,8 +69,9 @@ class AuthMiddleware:
name, value = item.split("=", 1) name, value = item.split("=", 1)
if name.strip() == SESSION_COOKIE_NAME: if name.strip() == SESSION_COOKIE_NAME:
token = value.strip() token = value.strip()
token_source = "cookie"
logger.debug( logger.debug(
f"[middleware] Извлечен токен из cookie, длина: {len(token) if token else 0}" f"[middleware] Извлечен токен из cookie {SESSION_COOKIE_NAME}, длина: {len(token) if token else 0}"
) )
break break
@ -71,24 +91,84 @@ class AuthMiddleware:
scope["headers"] = new_headers scope["headers"] = new_headers
# Также добавляем информацию о типе аутентификации для дальнейшего использования # Также добавляем информацию о типе аутентификации для дальнейшего использования
if "auth" not in scope: scope["auth"] = {
scope["auth"] = {"type": "bearer", "token": token} "type": "bearer",
"token": token,
"source": token_source
}
logger.debug(f"[middleware] Токен добавлен в scope для аутентификации из источника: {token_source}")
else:
logger.debug(f"[middleware] Токен не найден ни в заголовке, ни в cookie")
await self.app(scope, receive, send) await self.app(scope, receive, send)
def set_context(self, context): def set_context(self, context):
"""Сохраняет ссылку на контекст GraphQL запроса""" """Сохраняет ссылку на контекст GraphQL запроса"""
self._context = context self._context = context
logger.debug(f"[middleware] Установлен контекст GraphQL: {bool(context)}")
def set_cookie(self, key, value, **options): def set_cookie(self, key, value, **options):
"""Устанавливает cookie в ответе""" """
Устанавливает cookie в ответе
Args:
key: Имя cookie
value: Значение cookie
**options: Дополнительные параметры (httponly, secure, max_age, etc.)
"""
success = False
# Способ 1: Через response
if self._context and "response" in self._context and hasattr(self._context["response"], "set_cookie"): if self._context and "response" in self._context and hasattr(self._context["response"], "set_cookie"):
self._context["response"].set_cookie(key, value, **options) try:
self._context["response"].set_cookie(key, value, **options)
logger.debug(f"[middleware] Установлена cookie {key} через response")
success = True
except Exception as e:
logger.error(f"[middleware] Ошибка при установке cookie {key} через response: {str(e)}")
# Способ 2: Через собственный response в контексте
if not success and hasattr(self, "_response") and self._response and hasattr(self._response, "set_cookie"):
try:
self._response.set_cookie(key, value, **options)
logger.debug(f"[middleware] Установлена cookie {key} через _response")
success = True
except Exception as e:
logger.error(f"[middleware] Ошибка при установке cookie {key} через _response: {str(e)}")
if not success:
logger.error(f"[middleware] Не удалось установить cookie {key}: объекты response недоступны")
def delete_cookie(self, key, **options): def delete_cookie(self, key, **options):
"""Удаляет cookie из ответа""" """
Удаляет cookie из ответа
Args:
key: Имя cookie для удаления
**options: Дополнительные параметры
"""
success = False
# Способ 1: Через response
if self._context and "response" in self._context and hasattr(self._context["response"], "delete_cookie"): if self._context and "response" in self._context and hasattr(self._context["response"], "delete_cookie"):
self._context["response"].delete_cookie(key, **options) try:
self._context["response"].delete_cookie(key, **options)
logger.debug(f"[middleware] Удалена cookie {key} через response")
success = True
except Exception as e:
logger.error(f"[middleware] Ошибка при удалении cookie {key} через response: {str(e)}")
# Способ 2: Через собственный response в контексте
if not success and hasattr(self, "_response") and self._response and hasattr(self._response, "delete_cookie"):
try:
self._response.delete_cookie(key, **options)
logger.debug(f"[middleware] Удалена cookie {key} через _response")
success = True
except Exception as e:
logger.error(f"[middleware] Ошибка при удалении cookie {key} через _response: {str(e)}")
if not success:
logger.error(f"[middleware] Не удалось удалить cookie {key}: объекты response недоступны")
async def resolve(self, next, root, info, *args, **kwargs): async def resolve(self, next, root, info, *args, **kwargs):
""" """
@ -105,6 +185,14 @@ class AuthMiddleware:
# Добавляем себя как объект, содержащий утилитные методы # Добавляем себя как объект, содержащий утилитные методы
context["extensions"] = self context["extensions"] = self
# Проверяем наличие response в контексте
if "response" not in context or not context["response"]:
from starlette.responses import JSONResponse
context["response"] = JSONResponse({})
logger.debug("[middleware] Создан новый response объект в контексте GraphQL")
logger.debug(f"[middleware] GraphQL resolve: контекст подготовлен, добавлены расширения для работы с cookie")
return await next(root, info, *args, **kwargs) return await next(root, info, *args, **kwargs)
except Exception as e: except Exception as e:
logger.error(f"[AuthMiddleware] Ошибка в GraphQL resolve: {str(e)}") logger.error(f"[AuthMiddleware] Ошибка в GraphQL resolve: {str(e)}")

View File

@ -1,5 +1,5 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any from typing import Optional, Dict, Any, List
from pydantic import BaseModel from pydantic import BaseModel
from services.redis import redis from services.redis import redis
@ -26,88 +26,237 @@ class SessionManager:
@staticmethod @staticmethod
def _make_session_key(user_id: str, token: str) -> str: def _make_session_key(user_id: str, token: str) -> str:
"""Формирует ключ сессии в Redis""" """
return f"session:{user_id}:{token}" Создаёт ключ для сессии в Redis.
Args:
user_id: ID пользователя
token: JWT токен сессии
Returns:
str: Ключ сессии
"""
session_key = f"session:{user_id}:{token}"
logger.debug(f"[SessionManager._make_session_key] Сформирован ключ сессии: {session_key}")
return session_key
@staticmethod @staticmethod
def _make_user_sessions_key(user_id: str) -> str: def _make_user_sessions_key(user_id: str) -> str:
"""Формирует ключ для списка сессий пользователя в Redis""" """
Создаёт ключ для списка активных сессий пользователя.
Args:
user_id: ID пользователя
Returns:
str: Ключ списка сессий
"""
return f"user_sessions:{user_id}" return f"user_sessions:{user_id}"
@classmethod @classmethod
async def create_session(cls, user_id: str, username: str, device_info: dict = None) -> str: async def create_session(cls, user_id: str, username: str, device_info: Optional[dict] = None) -> str:
""" """
Создает новую сессию для пользователя. Создаёт новую сессию.
Args: Args:
user_id: ID пользователя user_id: ID пользователя
username: Имя пользователя/логин username: Имя пользователя
device_info: Информация об устройстве (опционально) device_info: Информация об устройстве (опционально)
Returns: Returns:
str: Токен сессии str: JWT токен сессии
""" """
try: # Создаём токен с явным указанием срока действия (30 дней)
# Создаем JWT токен expiration_date = datetime.now(tz=timezone.utc) + timedelta(days=30)
exp = datetime.now(tz=timezone.utc) + timedelta(seconds=SESSION_TOKEN_LIFE_SPAN) token = JWTCodec.encode({"id": user_id, "email": username}, exp=expiration_date)
session_token = JWTCodec.encode({"id": user_id, "email": username}, exp)
# Создаем данные сессии # Сохраняем сессию в Redis
session_data = SessionData( session_key = cls._make_session_key(user_id, token)
user_id=user_id, user_sessions_key = cls._make_user_sessions_key(user_id)
username=username,
created_at=datetime.now(tz=timezone.utc),
expires_at=exp,
device_info=device_info,
)
# Ключи в Redis # Сохраняем информацию о сессии
session_key = cls._make_session_key(user_id, session_token) session_data = {
user_sessions_key = cls._make_user_sessions_key(user_id) "user_id": user_id,
"username": username,
"created_at": datetime.now(tz=timezone.utc).isoformat(),
"expires_at": expiration_date.isoformat(),
}
# Сохраняем в Redis # Добавляем информацию об устройстве, если она есть
pipe = redis.pipeline() if device_info:
await pipe.hset(session_key, mapping=session_data.dict()) for key, value in device_info.items():
await pipe.expire(session_key, SESSION_TOKEN_LIFE_SPAN) session_data[f"device_{key}"] = value
await pipe.sadd(user_sessions_key, session_token)
await pipe.expire(user_sessions_key, SESSION_TOKEN_LIFE_SPAN)
await pipe.execute()
return session_token # Сохраняем сессию в Redis
except Exception as e: pipeline = redis.pipeline()
logger.error(f"[SessionManager.create_session] Ошибка: {str(e)}") # Сохраняем данные сессии
raise pipeline.hset(session_key, mapping=session_data)
# Добавляем токен в список сессий пользователя
pipeline.sadd(user_sessions_key, token)
# Устанавливаем время жизни ключей (30 дней)
pipeline.expire(session_key, 30 * 24 * 60 * 60)
pipeline.expire(user_sessions_key, 30 * 24 * 60 * 60)
# Также создаем ключ в формате, совместимом с TokenStorage для обратной совместимости
token_key = f"{user_id}-{username}-{token}"
pipeline.hset(token_key, mapping={"user_id": user_id, "username": username})
pipeline.expire(token_key, 30 * 24 * 60 * 60)
result = await pipeline.execute()
logger.info(f"[SessionManager.create_session] Сессия успешно создана для пользователя {user_id}")
return token
@classmethod @classmethod
async def verify_session(cls, token: str) -> Optional[TokenPayload]: async def verify_session(cls, token: str) -> Optional[TokenPayload]:
""" """
Проверяет валидность сессии. Проверяет сессию по токену.
Args: Args:
token: Токен сессии token: JWT токен
Returns: Returns:
TokenPayload: Данные токена или None, если токен недействителен Optional[TokenPayload]: Данные токена или None, если сессия недействительна
""" """
try: # Декодируем токен для получения payload
# Декодируем JWT payload = JWTCodec.decode(token)
payload = JWTCodec.decode(token) if not payload:
# Формируем ключ сессии
session_key = cls._make_session_key(payload.user_id, token)
# Проверяем существование сессии в Redis
session_exists = await redis.exists(session_key)
if not session_exists:
logger.debug(f"[SessionManager.verify_session] Сессия не найдена: {payload.user_id}")
return None
return payload
except Exception as e:
logger.error(f"[SessionManager.verify_session] Ошибка: {str(e)}")
return None return None
# Получаем данные из payload
user_id = payload.user_id
# Формируем ключ сессии
session_key = cls._make_session_key(user_id, token)
logger.debug(f"[SessionManager.verify_session] Сформирован ключ сессии: {session_key}")
# Проверяем существование сессии в Redis
exists = await redis.exists(session_key)
if not exists:
logger.warning(f"[SessionManager.verify_session] Сессия не найдена: {user_id}. Ключ: {session_key}")
# Проверяем также ключ в старом формате TokenStorage для обратной совместимости
token_key = f"{user_id}-{payload.username}-{token}"
old_format_exists = await redis.exists(token_key)
if old_format_exists:
logger.info(f"[SessionManager.verify_session] Найдена сессия в старом формате: {token_key}")
# Миграция: создаем запись в новом формате
session_data = {
"user_id": user_id,
"username": payload.username,
}
# Копируем сессию в новый формат
pipeline = redis.pipeline()
pipeline.hset(session_key, mapping=session_data)
pipeline.expire(session_key, 30 * 24 * 60 * 60)
pipeline.sadd(cls._make_user_sessions_key(user_id), token)
await pipeline.execute()
logger.info(f"[SessionManager.verify_session] Сессия мигрирована в новый формат: {session_key}")
return payload
# Если сессия не найдена ни в новом, ни в старом формате, проверяем все ключи в Redis
keys = await redis.keys("session:*")
logger.debug(f"[SessionManager.verify_session] Все ключи сессий в Redis: {keys}")
# Если сессии нет, возвращаем None
return None
# Если сессия найдена, возвращаем payload
return payload
@classmethod
async def get_user_sessions(cls, user_id: str) -> List[Dict[str, Any]]:
"""
Получает список активных сессий пользователя.
Args:
user_id: ID пользователя
Returns:
List[Dict[str, Any]]: Список сессий
"""
user_sessions_key = cls._make_user_sessions_key(user_id)
tokens = await redis.smembers(user_sessions_key)
sessions = []
for token in tokens:
session_key = cls._make_session_key(user_id, token)
session_data = await redis.hgetall(session_key)
if session_data:
session = dict(session_data)
session["token"] = token
sessions.append(session)
return sessions
@classmethod
async def delete_session(cls, user_id: str, token: str) -> bool:
"""
Удаляет сессию.
Args:
user_id: ID пользователя
token: JWT токен
Returns:
bool: True, если сессия успешно удалена
"""
session_key = cls._make_session_key(user_id, token)
user_sessions_key = cls._make_user_sessions_key(user_id)
# Удаляем данные сессии и токен из списка сессий пользователя
pipeline = redis.pipeline()
pipeline.delete(session_key)
pipeline.srem(user_sessions_key, token)
# Также удаляем ключ в формате TokenStorage для полной очистки
token_payload = JWTCodec.decode(token)
if token_payload:
token_key = f"{user_id}-{token_payload.username}-{token}"
pipeline.delete(token_key)
results = await pipeline.execute()
return bool(results[0]) or bool(results[1])
@classmethod
async def delete_all_sessions(cls, user_id: str) -> int:
"""
Удаляет все сессии пользователя.
Args:
user_id: ID пользователя
Returns:
int: Количество удаленных сессий
"""
user_sessions_key = cls._make_user_sessions_key(user_id)
tokens = await redis.smembers(user_sessions_key)
count = 0
for token in tokens:
session_key = cls._make_session_key(user_id, token)
# Удаляем данные сессии
deleted = await redis.delete(session_key)
count += deleted
# Также удаляем ключ в формате TokenStorage
token_payload = JWTCodec.decode(token)
if token_payload:
token_key = f"{user_id}-{token_payload.username}-{token}"
await redis.delete(token_key)
# Очищаем список токенов
await redis.delete(user_sessions_key)
return count
@classmethod @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]]:
""" """
@ -122,7 +271,7 @@ class SessionManager:
""" """
try: try:
session_key = cls._make_session_key(user_id, token) session_key = cls._make_session_key(user_id, token)
session_data = await redis.hgetall(session_key) session_data = await redis.execute("HGETALL", session_key)
return session_data if session_data else None return session_data if session_data else None
except Exception as e: except Exception as e:
logger.error(f"[SessionManager.get_session_data] Ошибка: {str(e)}") logger.error(f"[SessionManager.get_session_data] Ошибка: {str(e)}")

22
auth/state.py Normal file
View File

@ -0,0 +1,22 @@
"""
Классы состояния авторизации
"""
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 __bool__(self):
"""Возвращает True если пользователь авторизован"""
return self.logged_in

View File

@ -1,6 +1,7 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import json import json
from typing import Dict, Any, Optional import time
from typing import Dict, Any, Optional, Tuple, List
from auth.jwtcodec import JWTCodec from auth.jwtcodec import JWTCodec
from auth.validations import AuthInput from auth.validations import AuthInput
@ -11,10 +12,289 @@ from utils.logger import root_logger as logger
class TokenStorage: class TokenStorage:
""" """
Хранилище токенов в Redis. Класс для работы с хранилищем токенов в Redis
Обеспечивает создание, проверку и отзыв токенов.
""" """
@staticmethod
def _make_token_key(user_id: str, username: str, token: str) -> str:
"""
Создает ключ для хранения токена
Args:
user_id: ID пользователя
username: Имя пользователя
token: Токен
Returns:
str: Ключ токена
"""
# Сохраняем в старом формате для обратной совместимости
return f"{user_id}-{username}-{token}"
@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}"
@classmethod
async def create_session(cls, user_id: str, username: str, device_info: Optional[Dict[str, str]] = None) -> str:
"""
Создает новую сессию для пользователя
Args:
user_id: ID пользователя
username: Имя пользователя
device_info: Информация об устройстве (опционально)
Returns:
str: Токен сессии
"""
logger.debug(f"[TokenStorage.create_session] Начало создания сессии для пользователя {user_id}")
# Генерируем 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)}")
# Формируем ключи для Redis
token_key = cls._make_token_key(user_id, username, token)
logger.debug(f"[TokenStorage.create_session] Сформированы ключи: token_key={token_key}")
# Формируем ключи в новом формате SessionManager для совместимости
session_key = cls._make_session_key(user_id, token)
user_sessions_key = cls._make_user_sessions_key(user_id)
# Готовим данные для сохранения
token_data = {
"user_id": user_id,
"username": username,
"created_at": time.time(),
"expires_at": time.time() + 30 * 24 * 60 * 60 # 30 дней
}
if device_info:
token_data.update(device_info)
logger.debug(f"[TokenStorage.create_session] Сформированы данные сессии: {token_data}")
# Сохраняем в Redis старый формат
pipeline = redis.pipeline()
pipeline.hset(token_key, mapping=token_data)
pipeline.expire(token_key, 30 * 24 * 60 * 60) # 30 дней
# Также сохраняем в новом формате 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 дней
results = await pipeline.execute()
logger.info(f"[TokenStorage.create_session] Сессия успешно создана для пользователя {user_id}")
return token
@classmethod
async def exists(cls, token_key: str) -> bool:
"""
Проверяет существование токена по ключу
Args:
token_key: Ключ токена
Returns:
bool: True, если токен существует
"""
exists = await redis.exists(token_key)
return bool(exists)
@classmethod
async def validate_token(cls, token: str) -> Tuple[bool, Optional[Dict[str, Any]]]:
"""
Проверяет валидность токена
Args:
token: JWT токен
Returns:
Tuple[bool, Dict[str, Any]]: (Валиден ли токен, данные токена)
"""
try:
# Декодируем JWT токен
payload = JWTCodec.decode(token)
if not payload:
logger.warning(f"[TokenStorage.validate_token] Токен не валиден (не удалось декодировать)")
return False, None
user_id = payload.user_id
username = payload.username
# Формируем ключи для 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(token_key)
new_exists = await redis.exists(session_key)
if old_exists or new_exists:
logger.info(f"[TokenStorage.validate_token] Токен валиден для пользователя {user_id}")
# Получаем данные токена из актуального хранилища
if new_exists:
token_data = await redis.hgetall(session_key)
else:
token_data = await redis.hgetall(token_key)
# Если найден только в старом формате, создаем запись в новом формате
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
except Exception as e:
logger.error(f"[TokenStorage.validate_token] Ошибка при проверке токена: {e}")
return False, None
@classmethod
async def invalidate_token(cls, token: str) -> bool:
"""
Инвалидирует токен
Args:
token: JWT токен
Returns:
bool: True, если токен успешно инвалидирован
"""
try:
# Декодируем JWT токен
payload = JWTCodec.decode(token)
if not payload:
logger.warning(f"[TokenStorage.invalidate_token] Токен не валиден (не удалось декодировать)")
return False
user_id = payload.user_id
username = payload.username
# Формируем ключи для 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)
# Удаляем токен из Redis в обоих форматах
pipeline = redis.pipeline()
pipeline.delete(token_key)
pipeline.delete(session_key)
pipeline.srem(user_sessions_key, token)
results = await pipeline.execute()
success = any(results)
if success:
logger.info(f"[TokenStorage.invalidate_token] Токен успешно инвалидирован для пользователя {user_id}")
else:
logger.warning(f"[TokenStorage.invalidate_token] Токен не найден: {token_key}")
return success
except Exception as e:
logger.error(f"[TokenStorage.invalidate_token] Ошибка при инвалидации токена: {e}")
return False
@classmethod
async def invalidate_all_tokens(cls, user_id: str) -> int:
"""
Инвалидирует все токены пользователя
Args:
user_id: ID пользователя
Returns:
int: Количество инвалидированных токенов
"""
try:
# Получаем список сессий пользователя
user_sessions_key = cls._make_user_sessions_key(user_id)
tokens = await redis.smembers(user_sessions_key)
if not tokens:
logger.warning(f"[TokenStorage.invalidate_all_tokens] Нет активных сессий пользователя {user_id}")
return 0
count = 0
for token in tokens:
# Декодируем JWT токен
try:
payload = JWTCodec.decode(token)
if payload:
username = payload.username
# Формируем ключи для Redis
token_key = cls._make_token_key(user_id, username, token)
session_key = cls._make_session_key(user_id, token)
# Удаляем токен из Redis
pipeline = redis.pipeline()
pipeline.delete(token_key)
pipeline.delete(session_key)
results = await pipeline.execute()
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}")
return count
except Exception as e:
logger.error(f"[TokenStorage.invalidate_all_tokens] Ошибка при инвалидации всех токенов: {e}")
return 0
@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)
return data if valid else None
@staticmethod @staticmethod
async def get(token_key: str) -> Optional[str]: async def get(token_key: str) -> Optional[str]:
""" """
@ -88,43 +368,6 @@ class TokenStorage:
return one_time_token return one_time_token
@staticmethod
async def create_session(user: AuthInput) -> str:
"""
Создает сессионный токен для пользователя.
Args:
user: Объект пользователя
Returns:
str: Сгенерированный токен
"""
life_span = SESSION_TOKEN_LIFE_SPAN
exp = datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)
session_token = JWTCodec.encode(user, exp)
# Сохраняем токен в Redis
token_key = f"{user.id}-{user.username}-{session_token}"
user_sessions_key = f"user_sessions:{user.id}"
# Создаем данные сессии
session_data = {
"user_id": str(user.id),
"username": user.username,
"created_at": datetime.now(tz=timezone.utc).timestamp(),
"expires_at": exp.timestamp(),
}
# Сохраняем токен и добавляем его в список сессий пользователя
pipe = redis.pipeline()
await pipe.hmset(token_key, session_data)
await pipe.expire(token_key, life_span)
await pipe.sadd(user_sessions_key, session_token)
await pipe.expire(user_sessions_key, life_span)
await pipe.execute()
return session_token
@staticmethod @staticmethod
async def revoke(token: str) -> bool: async def revoke(token: str) -> bool:
""" """

View File

@ -86,7 +86,7 @@
- `sendLink` - отправка ссылки для входа - `sendLink` - отправка ссылки для входа
### Запросы ### Запросы
- `signOut` - выход из системы - `logout` - выход из системы
- `isEmailUsed` - проверка использования email - `isEmailUsed` - проверка использования email
## Безопасность ## Безопасность

101
main.py
View File

@ -26,6 +26,14 @@ from services.search import search_service
from utils.logger import root_logger as logger from utils.logger import root_logger as logger
from auth.internal import InternalAuthentication from auth.internal import InternalAuthentication
from auth.middleware import AuthMiddleware from auth.middleware import AuthMiddleware
from settings import (
SESSION_COOKIE_NAME,
SESSION_COOKIE_HTTPONLY,
SESSION_COOKIE_SECURE,
SESSION_COOKIE_SAMESITE,
SESSION_COOKIE_MAX_AGE,
SESSION_TOKEN_HEADER,
)
# Импортируем резолверы # Импортируем резолверы
import_module("resolvers") import_module("resolvers")
@ -61,19 +69,49 @@ class EnhancedGraphQLHTTPHandler(GraphQLHTTPHandler):
# Получаем стандартный контекст от базового класса # Получаем стандартный контекст от базового класса
context = await super().get_context_for_request(request, data) context = await super().get_context_for_request(request, data)
# Добавляем объект ответа для установки cookie # Создаем объект ответа для установки cookie
response = JSONResponse({}) response = JSONResponse({})
context["response"] = response context["response"] = response
# Интегрируем с AuthMiddleware # Интегрируем с AuthMiddleware
auth_middleware.set_context(context)
context["extensions"] = auth_middleware context["extensions"] = auth_middleware
logger.debug(f"[graphql] Подготовлен расширенный контекст для запроса")
return context return context
async def process_result(self, request: Request, result: dict) -> Response:
"""
Обрабатывает результат GraphQL запроса, поддерживая установку cookie
"""
# Получаем контекст запроса
context = getattr(request, "context", {})
# Получаем заранее созданный response из контекста
response = context.get("response")
if not response or not isinstance(response, Response):
# Если response не найден или не является объектом Response, создаем новый
response = await super().process_result(request, result)
else:
# Обновляем тело ответа данными из результата GraphQL
response.body = self.encode_json(result)
response.headers["content-type"] = "application/json"
response.headers["content-length"] = str(len(response.body))
logger.debug(f"[graphql] Подготовлен ответ с типом {type(response).__name__}")
return response
# Функция запуска сервера # Функция запуска сервера
async def start(): async def start():
"""Запуск сервера и инициализация данных""" """Запуск сервера и инициализация данных"""
# Инициализируем соединение с Redis
await redis.connect()
logger.info("Установлено соединение с Redis")
# Создаем все таблицы в БД # Создаем все таблицы в БД
create_all_tables() create_all_tables()
@ -86,7 +124,7 @@ async def start():
# Выводим сообщение о запуске сервера и доступности API # Выводим сообщение о запуске сервера и доступности API
logger.info("Сервер запущен и готов принимать запросы") logger.info("Сервер запущен и готов принимать запросы")
logger.info("GraphQL API доступно по адресу: /graphql") logger.info("GraphQL API доступно по адресу: /graphql")
logger.info("Админ-панель доступна по адресу: /admin") logger.info("Админ-панель доступна по адресу: http://127.0.0.1:8000/")
# Функция остановки сервера # Функция остановки сервера
@ -125,8 +163,12 @@ middleware = [
] ]
# Создаем экземпляр GraphQL # Создаем экземпляр GraphQL с улучшенным обработчиком
graphql_app = GraphQL(schema, debug=True) graphql_app = GraphQL(
schema,
debug=True,
http_handler=EnhancedGraphQLHTTPHandler()
)
# Оборачиваем GraphQL-обработчик для лучшей обработки ошибок # Оборачиваем GraphQL-обработчик для лучшей обработки ошибок
@ -135,14 +177,57 @@ async def graphql_handler(request: Request):
return JSONResponse({"error": "Method Not Allowed by main.py"}, status_code=405) return JSONResponse({"error": "Method Not Allowed by main.py"}, status_code=405)
try: try:
# Обрабатываем CORS для OPTIONS запросов
if request.method == "OPTIONS":
response = JSONResponse({})
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Methods"] = "POST, GET, OPTIONS"
response.headers["Access-Control-Allow-Headers"] = "*"
response.headers["Access-Control-Max-Age"] = "86400" # 24 hours
return response
result = await graphql_app.handle_request(request) result = await graphql_app.handle_request(request)
if isinstance(result, Response):
return result # Если результат не является Response, преобразуем его в JSONResponse
return JSONResponse(result) if not isinstance(result, Response):
response = JSONResponse(result)
# Проверяем, был ли токен в запросе или ответе
if request.method == "POST" and isinstance(result, dict):
data = await request.json()
op_name = data.get("operationName", "").lower()
# Если это операция логина или обновления токена, и в ответе есть токен
if (op_name in ["login", "refreshtoken"]) and result.get("data", {}).get(op_name, {}).get("token"):
token = result["data"][op_name]["token"]
# Устанавливаем cookie с токеном
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=token,
httponly=SESSION_COOKIE_HTTPONLY,
secure=SESSION_COOKIE_SECURE,
samesite=SESSION_COOKIE_SAMESITE,
max_age=SESSION_COOKIE_MAX_AGE,
)
logger.debug(f"[graphql_handler] Установлена cookie {SESSION_COOKIE_NAME} для операции {op_name}")
# Если это операция logout, удаляем cookie
elif op_name == "logout":
response.delete_cookie(
key=SESSION_COOKIE_NAME,
secure=SESSION_COOKIE_SECURE,
httponly=SESSION_COOKIE_HTTPONLY,
samesite=SESSION_COOKIE_SAMESITE
)
logger.debug(f"[graphql_handler] Удалена cookie {SESSION_COOKIE_NAME} для операции {op_name}")
return response
return result
except asyncio.CancelledError: except asyncio.CancelledError:
return JSONResponse({"error": "Request cancelled"}, status_code=499) return JSONResponse({"error": "Request cancelled"}, status_code=499)
except Exception as e: except Exception as e:
print(f"GraphQL error: {str(e)}") logger.error(f"GraphQL error: {str(e)}")
return JSONResponse({"error": str(e)}, status_code=500) return JSONResponse({"error": str(e)}, status_code=500)
# Добавляем маршруты, порядок имеет значение # Добавляем маршруты, порядок имеет значение

View File

@ -63,7 +63,7 @@ async def admin_get_users(_, info, limit=10, offset=0, search=None):
"email": user.email, "email": user.email,
"name": user.name, "name": user.name,
"slug": user.slug, "slug": user.slug,
"roles": [role.role for role in user.roles] "roles": [role.id for role in user.roles]
if hasattr(user, "roles") and user.roles if hasattr(user, "roles") and user.roles
else [], else [],
"created_at": user.created_at, "created_at": user.created_at,

View File

@ -6,7 +6,7 @@ from utils.logger import root_logger as logger
from graphql.type import GraphQLResolveInfo from graphql.type import GraphQLResolveInfo
# import asyncio # Убираем, так как резолвер будет синхронным # import asyncio # Убираем, так как резолвер будет синхронным
from auth.authenticate import login_required from services.auth import login_required
from auth.credentials import AuthCredentials from auth.credentials import AuthCredentials
from auth.email import send_auth_email from auth.email import send_auth_email
from auth.exceptions import InvalidToken, ObjectNotExist from auth.exceptions import InvalidToken, ObjectNotExist
@ -31,6 +31,7 @@ from auth.internal import verify_internal_auth
@mutation.field("getSession") @mutation.field("getSession")
@login_required @login_required
async def get_current_user(_, info): async def get_current_user(_, info):
"""get current user"""
auth: AuthCredentials = info.context["request"].auth auth: AuthCredentials = info.context["request"].auth
token = info.context["request"].headers.get(SESSION_TOKEN_HEADER) token = info.context["request"].headers.get(SESSION_TOKEN_HEADER)
@ -42,23 +43,34 @@ async def get_current_user(_, info):
return {"token": token, "author": author} return {"token": token, "author": author}
@mutation.field("confirmEmail") @mutation.field("confirmEmail")
async def confirm_email(_, info, token): async def confirm_email(_, info, token):
"""confirm owning email address""" """confirm owning email address"""
try: try:
logger.info("[auth] confirmEmail: Начало подтверждения email по токену.") logger.info("[auth] confirmEmail: Начало подтверждения email по токену.")
payload = JWTCodec.decode(token) payload = JWTCodec.decode(token)
user_id = payload.user_id user_id = payload.user_id
username = payload.username
# Если TokenStorage.get асинхронный, это нужно будет переделать или вызывать синхронно # Если TokenStorage.get асинхронный, это нужно будет переделать или вызывать синхронно
# Для теста пока оставим, но это потенциальная точка отказа в синхронном резолвере # Для теста пока оставим, но это потенциальная точка отказа в синхронном резолвере
await TokenStorage.get(f"{user_id}-{payload.username}-{token}") token_key = f"{user_id}-{username}-{token}"
await TokenStorage.get(token_key)
with local_session() as session: with local_session() as session:
user = session.query(Author).where(Author.id == user_id).first() user = session.query(Author).where(Author.id == user_id).first()
if not user: if not user:
logger.warning(f"[auth] confirmEmail: Пользователь с ID {user_id} не найден.") logger.warning(f"[auth] confirmEmail: Пользователь с ID {user_id} не найден.")
return {"success": False, "error": "Пользователь не найден"} return {"success": False, "error": "Пользователь не найден"}
# Если TokenStorage.create_session асинхронный...
session_token = await TokenStorage.create_session(user) # Создаем сессионный токен с новым форматом вызова и явным временем истечения
device_info = {"email": user.email} if hasattr(user, "email") else None
session_token = await TokenStorage.create_session(
user_id=str(user_id),
username=user.username or user.email or user.slug or username,
device_info=device_info
)
user.email_verified = True user.email_verified = True
user.last_seen = int(time.time()) user.last_seen = int(time.time())
session.add(user) session.add(user)
@ -75,10 +87,11 @@ async def confirm_email(_, info, token):
"token": None, "token": None,
"author": None, "author": None,
"error": f"Ошибка подтверждения email: {str(e)}", "error": f"Ошибка подтверждения email: {str(e)}",
} }
def create_user(user_dict): def create_user(user_dict):
"""create new user account"""
user = Author(**user_dict) user = Author(**user_dict)
with local_session() as session: with local_session() as session:
# Добавляем пользователя в БД # Добавляем пользователя в БД
@ -118,8 +131,8 @@ def create_user(user_dict):
@mutation.field("registerUser") @mutation.field("registerUser")
async def register_by_email(_, _info, email: str, password: str = "", name: str = ""): async def register_by_email(_, _info, email: str, password: str = "", name: str = ""):
"""register new user account by email"""
email = email.lower() email = email.lower()
"""creates new user account"""
logger.info(f"[auth] registerUser: Попытка регистрации для {email}") logger.info(f"[auth] registerUser: Попытка регистрации для {email}")
with local_session() as session: with local_session() as session:
user = session.query(Author).filter(Author.email == email).first() user = session.query(Author).filter(Author.email == email).first()
@ -171,8 +184,8 @@ async def register_by_email(_, _info, email: str, password: str = "", name: str
@mutation.field("sendLink") @mutation.field("sendLink")
async def send_link(_, _info, email, lang="ru", template="email_confirmation"): async def send_link(_, _info, email, lang="ru", template="email_confirmation"):
email = email.lower()
"""send link with confirm code to email""" """send link with confirm code to email"""
email = email.lower()
with local_session() as session: with local_session() as session:
user = session.query(Author).filter(Author.email == email).first() user = session.query(Author).filter(Author.email == email).first()
if not user: if not user:
@ -264,20 +277,23 @@ async def login(_, info, email: str, password: str):
# Создаем сессионный токен # Создаем сессионный токен
logger.info(f"[auth] login: СОЗДАНИЕ ТОКЕНА для {email}, id={valid_author.id}") logger.info(f"[auth] login: СОЗДАНИЕ ТОКЕНА для {email}, id={valid_author.id}")
token = await TokenStorage.create_session(valid_author) token = await TokenStorage.create_session(
user_id=str(valid_author.id),
username=valid_author.username or valid_author.email or valid_author.slug or "",
device_info={"email": valid_author.email} if hasattr(valid_author, "email") else None
)
logger.info(f"[auth] login: токен успешно создан, длина: {len(token) if token else 0}") logger.info(f"[auth] login: токен успешно создан, длина: {len(token) if token else 0}")
# Обновляем время последнего входа # Обновляем время последнего входа
valid_author.last_seen = int(time.time()) valid_author.last_seen = int(time.time())
session.commit() session.commit()
# Устанавливаем httponly cookie с помощью GraphQLExtensionsMiddleware # Устанавливаем httponly cookie различными способами для надежности
cookie_set = False
# Метод 1: GraphQL контекст через extensions
try: try:
# Используем extensions для установки cookie if hasattr(info.context, "extensions") and hasattr(info.context.extensions, "set_cookie"):
if hasattr(info.context, "extensions") and hasattr(
info.context.extensions, "set_cookie"
):
logger.info("[auth] login: Устанавливаем httponly cookie через extensions")
info.context.extensions.set_cookie( info.context.extensions.set_cookie(
SESSION_COOKIE_NAME, SESSION_COOKIE_NAME,
token, token,
@ -286,9 +302,34 @@ async def login(_, info, email: str, password: str):
samesite=SESSION_COOKIE_SAMESITE, samesite=SESSION_COOKIE_SAMESITE,
max_age=SESSION_COOKIE_MAX_AGE, max_age=SESSION_COOKIE_MAX_AGE,
) )
elif hasattr(info.context, "response") and hasattr(info.context.response, "set_cookie"): logger.info(f"[auth] login: Установлена cookie через extensions")
logger.info("[auth] login: Устанавливаем httponly cookie через response") cookie_set = True
info.context.response.set_cookie( except Exception as e:
logger.error(f"[auth] login: Ошибка при установке cookie через extensions: {str(e)}")
# Метод 2: GraphQL контекст через response
if not cookie_set:
try:
if hasattr(info.context, "response") and hasattr(info.context.response, "set_cookie"):
info.context.response.set_cookie(
key=SESSION_COOKIE_NAME,
value=token,
httponly=SESSION_COOKIE_HTTPONLY,
secure=SESSION_COOKIE_SECURE,
samesite=SESSION_COOKIE_SAMESITE,
max_age=SESSION_COOKIE_MAX_AGE,
)
logger.info(f"[auth] login: Установлена cookie через response")
cookie_set = True
except Exception as e:
logger.error(f"[auth] login: Ошибка при установке cookie через response: {str(e)}")
# Если ни один способ не сработал, создаем response в контексте
if not cookie_set and hasattr(info.context, "request") and not hasattr(info.context, "response"):
try:
from starlette.responses import JSONResponse
response = JSONResponse({})
response.set_cookie(
key=SESSION_COOKIE_NAME, key=SESSION_COOKIE_NAME,
value=token, value=token,
httponly=SESSION_COOKIE_HTTPONLY, httponly=SESSION_COOKIE_HTTPONLY,
@ -296,15 +337,15 @@ async def login(_, info, email: str, password: str):
samesite=SESSION_COOKIE_SAMESITE, samesite=SESSION_COOKIE_SAMESITE,
max_age=SESSION_COOKIE_MAX_AGE, max_age=SESSION_COOKIE_MAX_AGE,
) )
else: info.context["response"] = response
logger.warning( logger.info(f"[auth] login: Создан новый response и установлена cookie")
"[auth] login: Невозможно установить cookie - объекты extensions/response недоступны" cookie_set = True
) except Exception as e:
except Exception as e: logger.error(f"[auth] login: Ошибка при создании response и установке cookie: {str(e)}")
# В случае ошибки при установке cookie просто логируем, но продолжаем авторизацию
logger.error(f"[auth] login: Ошибка при установке cookie: {str(e)}") if not cookie_set:
logger.debug(traceback.format_exc()) logger.warning(f"[auth] login: Не удалось установить cookie никаким способом")
# Возвращаем успешный результат # Возвращаем успешный результат
logger.info(f"[auth] login: Успешный вход для {email}") logger.info(f"[auth] login: Успешный вход для {email}")
result = {"success": True, "token": token, "author": valid_author, "error": None} result = {"success": True, "token": token, "author": valid_author, "error": None}
@ -327,21 +368,10 @@ async def login(_, info, email: str, password: str):
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "token": None, "author": None, "error": str(e)} return {"success": False, "token": None, "author": None, "error": str(e)}
# Если по какой-то причине мы дошли до этой точки, вернем безопасный результат
return default_response
@query.field("signOut")
@login_required
async def sign_out(_, info: GraphQLResolveInfo):
token = info.context["request"].headers.get(SESSION_TOKEN_HEADER, "")
# Если TokenStorage.revoke асинхронный...
status = await TokenStorage.revoke(token)
return status
@query.field("isEmailUsed") @query.field("isEmailUsed")
async def is_email_used(_, _info, email): async def is_email_used(_, _info, email):
"""check if email is used"""
email = email.lower() email = email.lower()
with local_session() as session: with local_session() as session:
user = session.query(Author).filter(Author.email == email).first() user = session.query(Author).filter(Author.email == email).first()

View File

@ -7,7 +7,7 @@ type Query {
# search_authors(what: String!): [Author] # search_authors(what: String!): [Author]
# Auth queries # Auth queries
signOut: AuthSuccess! logout: AuthResult!
me: AuthResult! me: AuthResult!
isEmailUsed(email: String!): Boolean! isEmailUsed(email: String!): Boolean!
isAdmin: Boolean! isAdmin: Boolean!

View File

@ -16,7 +16,7 @@ class RedisService:
self._client = None self._client = None
async def connect(self): async def connect(self):
if self._uri: if self._uri and self._client is None:
self._client = await Redis.from_url(self._uri, decode_responses=True) self._client = await Redis.from_url(self._uri, decode_responses=True)
logger.info("Redis connection was established.") logger.info("Redis connection was established.")
@ -26,6 +26,11 @@ class RedisService:
logger.info("Redis connection was closed.") logger.info("Redis connection was closed.")
async def execute(self, command, *args, **kwargs): async def execute(self, command, *args, **kwargs):
# Автоматически подключаемся к Redis, если соединение не установлено
if self._client is None:
await self.connect()
logger.info(f"[redis] Автоматически установлено соединение при выполнении команды {command}")
if self._client: if self._client:
try: try:
logger.debug(f"{command}") # {args[0]}") # {args} {kwargs}") logger.debug(f"{command}") # {args[0]}") # {args} {kwargs}")
@ -47,31 +52,43 @@ class RedisService:
Returns: Returns:
Pipeline: объект pipeline Redis Pipeline: объект pipeline Redis
""" """
if self._client: if self._client is None:
return self._client.pipeline() # Выбрасываем исключение, так как pipeline нельзя создать до подключения
raise Exception("Redis client is not initialized") raise Exception("Redis client is not initialized. Call redis.connect() first.")
return self._client.pipeline()
async def subscribe(self, *channels): async def subscribe(self, *channels):
if self._client: # Автоматически подключаемся к Redis, если соединение не установлено
async with self._client.pubsub() as pubsub: if self._client is None:
for channel in channels: await self.connect()
await pubsub.subscribe(channel)
self.pubsub_channels.append(channel) async with self._client.pubsub() as pubsub:
for channel in channels:
await pubsub.subscribe(channel)
self.pubsub_channels.append(channel)
async def unsubscribe(self, *channels): async def unsubscribe(self, *channels):
if not self._client: if self._client is None:
return return
async with self._client.pubsub() as pubsub: async with self._client.pubsub() as pubsub:
for channel in channels: for channel in channels:
await pubsub.unsubscribe(channel) await pubsub.unsubscribe(channel)
self.pubsub_channels.remove(channel) self.pubsub_channels.remove(channel)
async def publish(self, channel, data): async def publish(self, channel, data):
if not self._client: # Автоматически подключаемся к Redis, если соединение не установлено
return if self._client is None:
await self.connect()
await self._client.publish(channel, data) await self._client.publish(channel, data)
async def set(self, key, data, ex=None): async def set(self, key, data, ex=None):
# Автоматически подключаемся к Redis, если соединение не установлено
if self._client is None:
await self.connect()
# Prepare the command arguments # Prepare the command arguments
args = [key, data] args = [key, data]
@ -84,6 +101,10 @@ class RedisService:
await self.execute("set", *args) await self.execute("set", *args)
async def get(self, key): async def get(self, key):
# Автоматически подключаемся к Redis, если соединение не установлено
if self._client is None:
await self.connect()
return await self.execute("get", key) return await self.execute("get", key)
async def delete(self, *keys): async def delete(self, *keys):
@ -96,8 +117,13 @@ class RedisService:
Returns: Returns:
int: Количество удаленных ключей int: Количество удаленных ключей
""" """
if not self._client or not keys: if not keys:
return 0 return 0
# Автоматически подключаемся к Redis, если соединение не установлено
if self._client is None:
await self.connect()
return await self._client.delete(*keys) return await self._client.delete(*keys)
async def hmset(self, key, mapping): async def hmset(self, key, mapping):
@ -108,8 +134,10 @@ class RedisService:
key: Ключ хеша key: Ключ хеша
mapping: Словарь с полями и значениями mapping: Словарь с полями и значениями
""" """
if not self._client: # Автоматически подключаемся к Redis, если соединение не установлено
return if self._client is None:
await self.connect()
await self._client.hset(key, mapping=mapping) await self._client.hset(key, mapping=mapping)
async def expire(self, key, seconds): async def expire(self, key, seconds):
@ -120,8 +148,10 @@ class RedisService:
key: Ключ key: Ключ
seconds: Время жизни в секундах seconds: Время жизни в секундах
""" """
if not self._client: # Автоматически подключаемся к Redis, если соединение не установлено
return if self._client is None:
await self.connect()
await self._client.expire(key, seconds) await self._client.expire(key, seconds)
async def sadd(self, key, *values): async def sadd(self, key, *values):
@ -132,8 +162,10 @@ class RedisService:
key: Ключ множества key: Ключ множества
*values: Значения для добавления *values: Значения для добавления
""" """
if not self._client: # Автоматически подключаемся к Redis, если соединение не установлено
return if self._client is None:
await self.connect()
await self._client.sadd(key, *values) await self._client.sadd(key, *values)
async def srem(self, key, *values): async def srem(self, key, *values):
@ -144,8 +176,10 @@ class RedisService:
key: Ключ множества key: Ключ множества
*values: Значения для удаления *values: Значения для удаления
""" """
if not self._client: # Автоматически подключаемся к Redis, если соединение не установлено
return if self._client is None:
await self.connect()
await self._client.srem(key, *values) await self._client.srem(key, *values)
async def smembers(self, key): async def smembers(self, key):
@ -158,9 +192,56 @@ class RedisService:
Returns: Returns:
set: Множество элементов set: Множество элементов
""" """
if not self._client: # Автоматически подключаемся к Redis, если соединение не установлено
return set() if self._client is None:
await self.connect()
return await self._client.smembers(key) return await self._client.smembers(key)
async def exists(self, key):
"""
Проверяет, существует ли ключ в Redis.
Args:
key: Ключ для проверки
Returns:
bool: True, если ключ существует, False в противном случае
"""
# Автоматически подключаемся к Redis, если соединение не установлено
if self._client is None:
await self.connect()
return await self._client.exists(key)
async def expire(self, key, seconds):
"""
Устанавливает время жизни ключа.
Args:
key: Ключ
seconds: Время жизни в секундах
"""
# Автоматически подключаемся к Redis, если соединение не установлено
if self._client is None:
await self.connect()
return await self._client.expire(key, seconds)
async def keys(self, pattern):
"""
Возвращает все ключи, соответствующие шаблону.
Args:
pattern: Шаблон для поиска ключей
"""
# Автоматически подключаемся к Redis, если соединение не установлено
if self._client is None:
await self.connect()
return await self._client.keys(pattern)
redis = RedisService() redis = RedisService()