Improve topic sorting: add popular sorting by publications and authors count
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, RedirectResponse
|
||||
from starlette.responses import JSONResponse, RedirectResponse, Response
|
||||
from starlette.routing import Route
|
||||
|
||||
from auth.internal import verify_internal_auth
|
||||
@@ -17,7 +17,7 @@ from settings import (
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
|
||||
async def logout(request: Request):
|
||||
async def logout(request: Request) -> Response:
|
||||
"""
|
||||
Выход из системы с удалением сессии и cookie.
|
||||
|
||||
@@ -54,10 +54,10 @@ async def logout(request: Request):
|
||||
if token:
|
||||
try:
|
||||
# Декодируем токен для получения user_id
|
||||
user_id, _ = await verify_internal_auth(token)
|
||||
user_id, _, _ = await verify_internal_auth(token)
|
||||
if user_id:
|
||||
# Отзываем сессию
|
||||
await SessionManager.revoke_session(user_id, token)
|
||||
await SessionManager.revoke_session(str(user_id), token)
|
||||
logger.info(f"[auth] logout: Токен успешно отозван для пользователя {user_id}")
|
||||
else:
|
||||
logger.warning("[auth] logout: Не удалось получить user_id из токена")
|
||||
@@ -81,7 +81,7 @@ async def logout(request: Request):
|
||||
return response
|
||||
|
||||
|
||||
async def refresh_token(request: Request):
|
||||
async def refresh_token(request: Request) -> JSONResponse:
|
||||
"""
|
||||
Обновление токена аутентификации.
|
||||
|
||||
@@ -128,7 +128,7 @@ async def refresh_token(request: Request):
|
||||
|
||||
try:
|
||||
# Получаем информацию о пользователе из токена
|
||||
user_id, _ = await verify_internal_auth(token)
|
||||
user_id, _, _ = await verify_internal_auth(token)
|
||||
if not user_id:
|
||||
logger.warning("[auth] refresh_token: Недействительный токен")
|
||||
return JSONResponse({"success": False, "error": "Недействительный токен"}, status_code=401)
|
||||
@@ -142,7 +142,10 @@ async def refresh_token(request: Request):
|
||||
return JSONResponse({"success": False, "error": "Пользователь не найден"}, status_code=404)
|
||||
|
||||
# Обновляем сессию (создаем новую и отзываем старую)
|
||||
device_info = {"ip": request.client.host, "user_agent": request.headers.get("user-agent")}
|
||||
device_info = {
|
||||
"ip": request.client.host if request.client else "unknown",
|
||||
"user_agent": request.headers.get("user-agent"),
|
||||
}
|
||||
new_token = await SessionManager.refresh_session(user_id, token, device_info)
|
||||
|
||||
if not new_token:
|
||||
|
@@ -1,4 +1,4 @@
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -25,13 +25,13 @@ class AuthCredentials(BaseModel):
|
||||
"""
|
||||
|
||||
author_id: Optional[int] = Field(None, description="ID автора")
|
||||
scopes: Dict[str, Set[str]] = Field(default_factory=dict, description="Разрешения пользователя")
|
||||
scopes: dict[str, set[str]] = Field(default_factory=dict, description="Разрешения пользователя")
|
||||
logged_in: bool = Field(False, description="Флаг, указывающий, авторизован ли пользователь")
|
||||
error_message: str = Field("", description="Сообщение об ошибке аутентификации")
|
||||
email: Optional[str] = Field(None, description="Email пользователя")
|
||||
token: Optional[str] = Field(None, description="JWT токен авторизации")
|
||||
|
||||
def get_permissions(self) -> List[str]:
|
||||
def get_permissions(self) -> list[str]:
|
||||
"""
|
||||
Возвращает список строковых представлений разрешений.
|
||||
Например: ["posts:read", "posts:write", "comments:create"].
|
||||
@@ -71,7 +71,7 @@ class AuthCredentials(BaseModel):
|
||||
"""
|
||||
return self.email in ADMIN_EMAILS if self.email else False
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Преобразует учетные данные в словарь
|
||||
|
||||
@@ -85,11 +85,10 @@ class AuthCredentials(BaseModel):
|
||||
"permissions": self.get_permissions(),
|
||||
}
|
||||
|
||||
async def permissions(self) -> List[Permission]:
|
||||
async def permissions(self) -> list[Permission]:
|
||||
if self.author_id is None:
|
||||
# raise Unauthorized("Please login first")
|
||||
return {"error": "Please login first"}
|
||||
else:
|
||||
# TODO: implement permissions logix
|
||||
print(self.author_id)
|
||||
return NotImplemented
|
||||
return [] # Возвращаем пустой список вместо dict
|
||||
# TODO: implement permissions logix
|
||||
print(self.author_id)
|
||||
return [] # Возвращаем пустой список вместо NotImplemented
|
||||
|
@@ -1,5 +1,6 @@
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from graphql import GraphQLError, GraphQLResolveInfo
|
||||
from sqlalchemy import exc
|
||||
@@ -7,12 +8,8 @@ from sqlalchemy import exc
|
||||
from auth.credentials import AuthCredentials
|
||||
from auth.exceptions import OperationNotAllowed
|
||||
from auth.internal import authenticate
|
||||
from auth.jwtcodec import ExpiredToken, InvalidToken, JWTCodec
|
||||
from auth.orm import Author
|
||||
from auth.sessions import SessionManager
|
||||
from auth.tokenstorage import TokenStorage
|
||||
from services.db import local_session
|
||||
from services.redis import redis
|
||||
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
|
||||
from settings import SESSION_COOKIE_NAME, SESSION_TOKEN_HEADER
|
||||
from utils.logger import root_logger as logger
|
||||
@@ -20,7 +17,7 @@ from utils.logger import root_logger as logger
|
||||
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
|
||||
|
||||
|
||||
def get_safe_headers(request: Any) -> Dict[str, str]:
|
||||
def get_safe_headers(request: Any) -> dict[str, str]:
|
||||
"""
|
||||
Безопасно получает заголовки запроса.
|
||||
|
||||
@@ -107,10 +104,9 @@ def get_auth_token(request: Any) -> Optional[str]:
|
||||
token = auth_header[7:].strip()
|
||||
logger.debug(f"[decorators] Токен получен из заголовка {SESSION_TOKEN_HEADER}: {len(token)}")
|
||||
return token
|
||||
else:
|
||||
token = auth_header.strip()
|
||||
logger.debug(f"[decorators] Прямой токен получен из заголовка {SESSION_TOKEN_HEADER}: {len(token)}")
|
||||
return token
|
||||
token = auth_header.strip()
|
||||
logger.debug(f"[decorators] Прямой токен получен из заголовка {SESSION_TOKEN_HEADER}: {len(token)}")
|
||||
return token
|
||||
|
||||
# Затем проверяем стандартный заголовок Authorization, если основной не определен
|
||||
if SESSION_TOKEN_HEADER.lower() != "authorization":
|
||||
@@ -135,7 +131,7 @@ def get_auth_token(request: Any) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
async def validate_graphql_context(info: Any) -> None:
|
||||
async def validate_graphql_context(info: GraphQLResolveInfo) -> None:
|
||||
"""
|
||||
Проверяет валидность GraphQL контекста и проверяет авторизацию.
|
||||
|
||||
@@ -148,12 +144,14 @@ async def validate_graphql_context(info: Any) -> None:
|
||||
# Проверка базовой структуры контекста
|
||||
if info is None or not hasattr(info, "context"):
|
||||
logger.error("[decorators] Missing GraphQL context information")
|
||||
raise GraphQLError("Internal server error: missing context")
|
||||
msg = "Internal server error: missing context"
|
||||
raise GraphQLError(msg)
|
||||
|
||||
request = info.context.get("request")
|
||||
if not request:
|
||||
logger.error("[decorators] Missing request in context")
|
||||
raise GraphQLError("Internal server error: missing request")
|
||||
msg = "Internal server error: missing request"
|
||||
raise GraphQLError(msg)
|
||||
|
||||
# Проверяем auth из контекста - если уже авторизован, просто возвращаем
|
||||
auth = getattr(request, "auth", None)
|
||||
@@ -179,7 +177,8 @@ async def validate_graphql_context(info: Any) -> None:
|
||||
"headers": get_safe_headers(request),
|
||||
}
|
||||
logger.warning(f"[decorators] Токен авторизации не найден: {client_info}")
|
||||
raise GraphQLError("Unauthorized - please login")
|
||||
msg = "Unauthorized - please login"
|
||||
raise GraphQLError(msg)
|
||||
|
||||
# Используем единый механизм проверки токена из auth.internal
|
||||
auth_state = await authenticate(request)
|
||||
@@ -187,7 +186,8 @@ async def validate_graphql_context(info: Any) -> None:
|
||||
if not auth_state.logged_in:
|
||||
error_msg = auth_state.error or "Invalid or expired token"
|
||||
logger.warning(f"[decorators] Недействительный токен: {error_msg}")
|
||||
raise GraphQLError(f"Unauthorized - {error_msg}")
|
||||
msg = f"Unauthorized - {error_msg}"
|
||||
raise GraphQLError(msg)
|
||||
|
||||
# Если все проверки пройдены, создаем AuthCredentials и устанавливаем в request.auth
|
||||
with local_session() as session:
|
||||
@@ -198,7 +198,12 @@ async def validate_graphql_context(info: Any) -> None:
|
||||
|
||||
# Создаем объект авторизации
|
||||
auth_cred = AuthCredentials(
|
||||
author_id=author.id, scopes=scopes, logged_in=True, email=author.email, token=auth_state.token
|
||||
author_id=author.id,
|
||||
scopes=scopes,
|
||||
logged_in=True,
|
||||
error_message="",
|
||||
email=author.email,
|
||||
token=auth_state.token,
|
||||
)
|
||||
|
||||
# Устанавливаем auth в request
|
||||
@@ -206,7 +211,8 @@ async def validate_graphql_context(info: Any) -> None:
|
||||
logger.debug(f"[decorators] Токен успешно проверен и установлен для пользователя {auth_state.author_id}")
|
||||
except exc.NoResultFound:
|
||||
logger.error(f"[decorators] Пользователь с ID {auth_state.author_id} не найден в базе данных")
|
||||
raise GraphQLError("Unauthorized - user not found")
|
||||
msg = "Unauthorized - user not found"
|
||||
raise GraphQLError(msg)
|
||||
|
||||
return
|
||||
|
||||
@@ -232,48 +238,59 @@ def admin_auth_required(resolver: Callable) -> Callable:
|
||||
"""
|
||||
|
||||
@wraps(resolver)
|
||||
async def wrapper(root: Any = None, info: Any = None, **kwargs):
|
||||
async def wrapper(root: Any = None, info: Optional[GraphQLResolveInfo] = None, **kwargs):
|
||||
try:
|
||||
# Проверяем авторизацию пользователя
|
||||
if info is None:
|
||||
logger.error("[admin_auth_required] GraphQL info is None")
|
||||
msg = "Invalid GraphQL context"
|
||||
raise GraphQLError(msg)
|
||||
|
||||
await validate_graphql_context(info)
|
||||
if info:
|
||||
# Получаем объект авторизации
|
||||
auth = info.context["request"].auth
|
||||
if not auth or not auth.logged_in:
|
||||
logger.error("[admin_auth_required] Пользователь не авторизован после validate_graphql_context")
|
||||
msg = "Unauthorized - please login"
|
||||
raise GraphQLError(msg)
|
||||
|
||||
# Получаем объект авторизации
|
||||
auth = info.context["request"].auth
|
||||
if not auth or not auth.logged_in:
|
||||
logger.error(f"[admin_auth_required] Пользователь не авторизован после validate_graphql_context")
|
||||
raise GraphQLError("Unauthorized - please login")
|
||||
# Проверяем, является ли пользователь администратором
|
||||
with local_session() as session:
|
||||
try:
|
||||
# Преобразуем author_id в int для совместимости с базой данных
|
||||
author_id = int(auth.author_id) if auth and auth.author_id else None
|
||||
if not author_id:
|
||||
logger.error(f"[admin_auth_required] ID автора не определен: {auth}")
|
||||
msg = "Unauthorized - invalid user ID"
|
||||
raise GraphQLError(msg)
|
||||
|
||||
# Проверяем, является ли пользователь администратором
|
||||
with local_session() as session:
|
||||
try:
|
||||
# Преобразуем author_id в int для совместимости с базой данных
|
||||
author_id = int(auth.author_id) if auth and auth.author_id else None
|
||||
if not author_id:
|
||||
logger.error(f"[admin_auth_required] ID автора не определен: {auth}")
|
||||
raise GraphQLError("Unauthorized - invalid user ID")
|
||||
author = session.query(Author).filter(Author.id == author_id).one()
|
||||
|
||||
author = session.query(Author).filter(Author.id == author_id).one()
|
||||
# Проверяем, является ли пользователь администратором
|
||||
if author.email in ADMIN_EMAILS:
|
||||
logger.info(f"Admin access granted for {author.email} (ID: {author.id})")
|
||||
return await resolver(root, info, **kwargs)
|
||||
|
||||
# Проверяем, является ли пользователь администратором
|
||||
if author.email in ADMIN_EMAILS:
|
||||
logger.info(f"Admin access granted for {author.email} (ID: {author.id})")
|
||||
return await resolver(root, info, **kwargs)
|
||||
# Проверяем роли пользователя
|
||||
admin_roles = ["admin", "super"]
|
||||
user_roles = [role.id for role in author.roles] if author.roles else []
|
||||
|
||||
# Проверяем роли пользователя
|
||||
admin_roles = ["admin", "super"]
|
||||
user_roles = [role.id for role in author.roles] if author.roles else []
|
||||
if any(role in admin_roles for role in user_roles):
|
||||
logger.info(
|
||||
f"Admin access granted for {author.email} (ID: {author.id}) with role: {user_roles}"
|
||||
)
|
||||
return await resolver(root, info, **kwargs)
|
||||
|
||||
if any(role in admin_roles for role in user_roles):
|
||||
logger.info(
|
||||
f"Admin access granted for {author.email} (ID: {author.id}) with role: {user_roles}"
|
||||
logger.warning(f"Admin access denied for {author.email} (ID: {author.id}). Roles: {user_roles}")
|
||||
msg = "Unauthorized - not an admin"
|
||||
raise GraphQLError(msg)
|
||||
except exc.NoResultFound:
|
||||
logger.error(
|
||||
f"[admin_auth_required] Пользователь с ID {auth.author_id} не найден в базе данных"
|
||||
)
|
||||
return await resolver(root, info, **kwargs)
|
||||
|
||||
logger.warning(f"Admin access denied for {author.email} (ID: {author.id}). Roles: {user_roles}")
|
||||
raise GraphQLError("Unauthorized - not an admin")
|
||||
except exc.NoResultFound:
|
||||
logger.error(f"[admin_auth_required] Пользователь с ID {auth.author_id} не найден в базе данных")
|
||||
raise GraphQLError("Unauthorized - user not found")
|
||||
msg = "Unauthorized - user not found"
|
||||
raise GraphQLError(msg)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
@@ -285,18 +302,18 @@ def admin_auth_required(resolver: Callable) -> Callable:
|
||||
return wrapper
|
||||
|
||||
|
||||
def permission_required(resource: str, operation: str, func):
|
||||
def permission_required(resource: str, operation: str, func: Callable) -> Callable:
|
||||
"""
|
||||
Декоратор для проверки разрешений.
|
||||
|
||||
Args:
|
||||
resource (str): Ресурс для проверки
|
||||
operation (str): Операция для проверки
|
||||
resource: Ресурс для проверки
|
||||
operation: Операция для проверки
|
||||
func: Декорируемая функция
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
|
||||
async def wrap(parent: Any, info: GraphQLResolveInfo, *args: Any, **kwargs: Any) -> Any:
|
||||
# Сначала проверяем авторизацию
|
||||
await validate_graphql_context(info)
|
||||
|
||||
@@ -304,8 +321,9 @@ def permission_required(resource: str, operation: str, func):
|
||||
logger.debug(f"[permission_required] Контекст: {info.context}")
|
||||
auth = info.context["request"].auth
|
||||
if not auth or not auth.logged_in:
|
||||
logger.error(f"[permission_required] Пользователь не авторизован после validate_graphql_context")
|
||||
raise OperationNotAllowed("Требуются права доступа")
|
||||
logger.error("[permission_required] Пользователь не авторизован после validate_graphql_context")
|
||||
msg = "Требуются права доступа"
|
||||
raise OperationNotAllowed(msg)
|
||||
|
||||
# Проверяем разрешения
|
||||
with local_session() as session:
|
||||
@@ -313,10 +331,9 @@ def permission_required(resource: str, operation: str, func):
|
||||
author = session.query(Author).filter(Author.id == auth.author_id).one()
|
||||
|
||||
# Проверяем базовые условия
|
||||
if not author.is_active:
|
||||
raise OperationNotAllowed("Account is not active")
|
||||
if author.is_locked():
|
||||
raise OperationNotAllowed("Account is locked")
|
||||
msg = "Account is locked"
|
||||
raise OperationNotAllowed(msg)
|
||||
|
||||
# Проверяем, является ли пользователь администратором (у них есть все разрешения)
|
||||
if author.email in ADMIN_EMAILS:
|
||||
@@ -338,7 +355,8 @@ def permission_required(resource: str, operation: str, func):
|
||||
logger.warning(
|
||||
f"[permission_required] У пользователя {author.email} нет разрешения {operation} на {resource}"
|
||||
)
|
||||
raise OperationNotAllowed(f"No permission for {operation} on {resource}")
|
||||
msg = f"No permission for {operation} on {resource}"
|
||||
raise OperationNotAllowed(msg)
|
||||
|
||||
logger.debug(
|
||||
f"[permission_required] Пользователь {author.email} имеет разрешение {operation} на {resource}"
|
||||
@@ -346,12 +364,13 @@ def permission_required(resource: str, operation: str, func):
|
||||
return await func(parent, info, *args, **kwargs)
|
||||
except exc.NoResultFound:
|
||||
logger.error(f"[permission_required] Пользователь с ID {auth.author_id} не найден в базе данных")
|
||||
raise OperationNotAllowed("User not found")
|
||||
msg = "User not found"
|
||||
raise OperationNotAllowed(msg)
|
||||
|
||||
return wrap
|
||||
|
||||
|
||||
def login_accepted(func):
|
||||
def login_accepted(func: Callable) -> Callable:
|
||||
"""
|
||||
Декоратор для резолверов, которые могут работать как с авторизованными,
|
||||
так и с неавторизованными пользователями.
|
||||
@@ -363,7 +382,7 @@ def login_accepted(func):
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
|
||||
async def wrap(parent: Any, info: GraphQLResolveInfo, *args: Any, **kwargs: Any) -> Any:
|
||||
try:
|
||||
# Пробуем проверить авторизацию, но не выбрасываем исключение, если пользователь не авторизован
|
||||
try:
|
||||
|
@@ -1,3 +1,5 @@
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from settings import MAILGUN_API_KEY, MAILGUN_DOMAIN
|
||||
@@ -7,9 +9,9 @@ noreply = "discours.io <noreply@%s>" % (MAILGUN_DOMAIN or "discours.io")
|
||||
lang_subject = {"ru": "Подтверждение почты", "en": "Confirm email"}
|
||||
|
||||
|
||||
async def send_auth_email(user, token, lang="ru", template="email_confirmation"):
|
||||
async def send_auth_email(user: Any, token: str, lang: str = "ru", template: str = "email_confirmation") -> None:
|
||||
try:
|
||||
to = "%s <%s>" % (user.name, user.email)
|
||||
to = f"{user.name} <{user.email}>"
|
||||
if lang not in ["ru", "en"]:
|
||||
lang = "ru"
|
||||
subject = lang_subject.get(lang, lang_subject["en"])
|
||||
@@ -19,12 +21,12 @@ async def send_auth_email(user, token, lang="ru", template="email_confirmation")
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"template": template,
|
||||
"h:X-Mailgun-Variables": '{ "token": "%s" }' % token,
|
||||
"h:X-Mailgun-Variables": f'{{ "token": "{token}" }}',
|
||||
}
|
||||
print("[auth.email] payload: %r" % payload)
|
||||
print(f"[auth.email] payload: {payload!r}")
|
||||
# debug
|
||||
# print('http://localhost:3000/?modal=auth&mode=confirm-email&token=%s' % token)
|
||||
response = requests.post(api_url, auth=("api", MAILGUN_API_KEY), data=payload)
|
||||
response = requests.post(api_url, auth=("api", MAILGUN_API_KEY), data=payload, timeout=30)
|
||||
response.raise_for_status()
|
||||
except Exception as e:
|
||||
print(e)
|
||||
|
@@ -1,6 +1,6 @@
|
||||
from ariadne.asgi.handlers import GraphQLHTTPHandler
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from auth.middleware import auth_middleware
|
||||
from utils.logger import root_logger as logger
|
||||
@@ -51,6 +51,6 @@ class EnhancedGraphQLHTTPHandler(GraphQLHTTPHandler):
|
||||
# Безопасно логируем информацию о типе объекта auth
|
||||
logger.debug(f"[graphql] Добавлены данные авторизации в контекст: {type(request.auth).__name__}")
|
||||
|
||||
logger.debug(f"[graphql] Подготовлен расширенный контекст для запроса")
|
||||
logger.debug("[graphql] Подготовлен расширенный контекст для запроса")
|
||||
|
||||
return context
|
||||
|
@@ -1,6 +1,6 @@
|
||||
from binascii import hexlify
|
||||
from hashlib import sha256
|
||||
from typing import TYPE_CHECKING, Any, Dict, TypeVar
|
||||
from typing import TYPE_CHECKING, Any, TypeVar
|
||||
|
||||
from passlib.hash import bcrypt
|
||||
|
||||
@@ -8,6 +8,7 @@ from auth.exceptions import ExpiredToken, InvalidPassword, InvalidToken
|
||||
from auth.jwtcodec import JWTCodec
|
||||
from auth.tokenstorage import TokenStorage
|
||||
from services.db import local_session
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
# Для типизации
|
||||
if TYPE_CHECKING:
|
||||
@@ -42,11 +43,11 @@ class Password:
|
||||
|
||||
@staticmethod
|
||||
def verify(password: str, hashed: str) -> bool:
|
||||
"""
|
||||
r"""
|
||||
Verify that password hash is equal to specified hash. Hash format:
|
||||
|
||||
$2a$10$Ro0CUfOqk6cXEKf3dyaM7OhSCvnwM9s4wIX9JeLapehKK5YdLxKcm
|
||||
\__/\/ \____________________/\_____________________________/ # noqa: W605
|
||||
\__/\/ \____________________/\_____________________________/
|
||||
| | Salt Hash
|
||||
| Cost
|
||||
Version
|
||||
@@ -65,7 +66,7 @@ class Password:
|
||||
|
||||
class Identity:
|
||||
@staticmethod
|
||||
def password(orm_author: Any, password: str) -> Any:
|
||||
def password(orm_author: AuthorType, password: str) -> AuthorType:
|
||||
"""
|
||||
Проверяет пароль пользователя
|
||||
|
||||
@@ -80,24 +81,26 @@ class Identity:
|
||||
InvalidPassword: Если пароль не соответствует хешу или отсутствует
|
||||
"""
|
||||
# Импортируем внутри функции для избежания циклических импортов
|
||||
from auth.orm import Author
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
# Проверим исходный пароль в orm_author
|
||||
if not orm_author.password:
|
||||
logger.warning(f"[auth.identity] Пароль в исходном объекте автора пуст: email={orm_author.email}")
|
||||
raise InvalidPassword("Пароль не установлен для данного пользователя")
|
||||
msg = "Пароль не установлен для данного пользователя"
|
||||
raise InvalidPassword(msg)
|
||||
|
||||
# Проверяем пароль напрямую, не используя dict()
|
||||
if not Password.verify(password, orm_author.password):
|
||||
password_hash = str(orm_author.password) if orm_author.password else ""
|
||||
if not password_hash or not Password.verify(password, password_hash):
|
||||
logger.warning(f"[auth.identity] Неверный пароль для {orm_author.email}")
|
||||
raise InvalidPassword("Неверный пароль пользователя")
|
||||
msg = "Неверный пароль пользователя"
|
||||
raise InvalidPassword(msg)
|
||||
|
||||
# Возвращаем исходный объект, чтобы сохранить все связи
|
||||
return orm_author
|
||||
|
||||
@staticmethod
|
||||
def oauth(inp: Dict[str, Any]) -> Any:
|
||||
def oauth(inp: dict[str, Any]) -> Any:
|
||||
"""
|
||||
Создает нового пользователя OAuth, если он не существует
|
||||
|
||||
@@ -114,7 +117,7 @@ class Identity:
|
||||
author = session.query(Author).filter(Author.email == inp["email"]).first()
|
||||
if not author:
|
||||
author = Author(**inp)
|
||||
author.email_verified = True
|
||||
author.email_verified = True # type: ignore[assignment]
|
||||
session.add(author)
|
||||
session.commit()
|
||||
|
||||
@@ -137,21 +140,29 @@ class Identity:
|
||||
try:
|
||||
print("[auth.identity] using one time token")
|
||||
payload = JWTCodec.decode(token)
|
||||
if not await TokenStorage.exist(f"{payload.user_id}-{payload.username}-{token}"):
|
||||
# raise InvalidToken("Login token has expired, please login again")
|
||||
return {"error": "Token has expired"}
|
||||
if payload is None:
|
||||
logger.warning("[Identity.token] Токен не валиден (payload is None)")
|
||||
return {"error": "Invalid token"}
|
||||
|
||||
# Проверяем существование токена в хранилище
|
||||
token_key = f"{payload.user_id}-{payload.username}-{token}"
|
||||
token_storage = TokenStorage()
|
||||
if not await token_storage.exists(token_key):
|
||||
logger.warning(f"[Identity.token] Токен не найден в хранилище: {token_key}")
|
||||
return {"error": "Token not found"}
|
||||
|
||||
# Если все проверки пройдены, ищем автора в базе данных
|
||||
with local_session() as session:
|
||||
author = session.query(Author).filter_by(id=payload.user_id).first()
|
||||
if not author:
|
||||
logger.warning(f"[Identity.token] Автор с ID {payload.user_id} не найден")
|
||||
return {"error": "User not found"}
|
||||
|
||||
logger.info(f"[Identity.token] Токен валиден для автора {author.id}")
|
||||
return author
|
||||
except ExpiredToken:
|
||||
# raise InvalidToken("Login token has expired, please try again")
|
||||
return {"error": "Token has expired"}
|
||||
except InvalidToken:
|
||||
# raise InvalidToken("token format error") from e
|
||||
return {"error": "Token format error"}
|
||||
with local_session() as session:
|
||||
author = session.query(Author).filter_by(id=payload.user_id).first()
|
||||
if not author:
|
||||
# raise Exception("user not exist")
|
||||
return {"error": "Author does not exist"}
|
||||
if not author.email_verified:
|
||||
author.email_verified = True
|
||||
session.commit()
|
||||
return author
|
||||
|
@@ -4,7 +4,7 @@
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any, Optional, Tuple
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy.orm import exc
|
||||
|
||||
@@ -20,7 +20,7 @@ from utils.logger import root_logger as logger
|
||||
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
|
||||
|
||||
|
||||
async def verify_internal_auth(token: str) -> Tuple[str, list, bool]:
|
||||
async def verify_internal_auth(token: str) -> tuple[int, list, bool]:
|
||||
"""
|
||||
Проверяет локальную авторизацию.
|
||||
Возвращает user_id, список ролей и флаг администратора.
|
||||
@@ -41,18 +41,13 @@ async def verify_internal_auth(token: str) -> Tuple[str, list, bool]:
|
||||
payload = await SessionManager.verify_session(token)
|
||||
if not payload:
|
||||
logger.warning("[verify_internal_auth] Недействительный токен: payload не получен")
|
||||
return "", [], False
|
||||
return 0, [], False
|
||||
|
||||
logger.debug(f"[verify_internal_auth] Токен действителен, user_id={payload.user_id}")
|
||||
|
||||
with local_session() as session:
|
||||
try:
|
||||
author = (
|
||||
session.query(Author)
|
||||
.filter(Author.id == payload.user_id)
|
||||
.filter(Author.is_active == True) # noqa
|
||||
.one()
|
||||
)
|
||||
author = session.query(Author).filter(Author.id == payload.user_id).one()
|
||||
|
||||
# Получаем роли
|
||||
roles = [role.id for role in author.roles]
|
||||
@@ -64,10 +59,10 @@ async def verify_internal_auth(token: str) -> Tuple[str, list, bool]:
|
||||
f"[verify_internal_auth] Пользователь {author.id} {'является' if is_admin else 'не является'} администратором"
|
||||
)
|
||||
|
||||
return str(author.id), roles, is_admin
|
||||
return int(author.id), roles, is_admin
|
||||
except exc.NoResultFound:
|
||||
logger.warning(f"[verify_internal_auth] Пользователь с ID {payload.user_id} не найден в БД или не активен")
|
||||
return "", [], False
|
||||
return 0, [], False
|
||||
|
||||
|
||||
async def create_internal_session(author: Author, device_info: Optional[dict] = None) -> str:
|
||||
@@ -85,12 +80,12 @@ async def create_internal_session(author: Author, device_info: Optional[dict] =
|
||||
author.reset_failed_login()
|
||||
|
||||
# Обновляем last_seen
|
||||
author.last_seen = int(time.time())
|
||||
author.last_seen = int(time.time()) # type: ignore[assignment]
|
||||
|
||||
# Создаем сессию, используя token для идентификации
|
||||
return await SessionManager.create_session(
|
||||
user_id=str(author.id),
|
||||
username=author.slug or author.email or author.phone or "",
|
||||
username=str(author.slug or author.email or author.phone or ""),
|
||||
device_info=device_info,
|
||||
)
|
||||
|
||||
@@ -124,10 +119,7 @@ async def authenticate(request: Any) -> AuthState:
|
||||
try:
|
||||
headers = {}
|
||||
if hasattr(request, "headers"):
|
||||
if callable(request.headers):
|
||||
headers = dict(request.headers())
|
||||
else:
|
||||
headers = dict(request.headers)
|
||||
headers = dict(request.headers()) if callable(request.headers) else dict(request.headers)
|
||||
|
||||
auth_header = headers.get(SESSION_TOKEN_HEADER, "")
|
||||
if auth_header and auth_header.startswith("Bearer "):
|
||||
@@ -153,7 +145,7 @@ async def authenticate(request: Any) -> AuthState:
|
||||
# Проверяем токен через SessionManager, который теперь совместим с TokenStorage
|
||||
payload = await SessionManager.verify_session(token)
|
||||
if not payload:
|
||||
logger.warning(f"[auth.authenticate] Токен не валиден: не найдена сессия")
|
||||
logger.warning("[auth.authenticate] Токен не валиден: не найдена сессия")
|
||||
state.error = "Invalid or expired token"
|
||||
return state
|
||||
|
||||
@@ -175,11 +167,16 @@ async def authenticate(request: Any) -> AuthState:
|
||||
|
||||
# Создаем объект авторизации
|
||||
auth_cred = AuthCredentials(
|
||||
author_id=author.id, scopes=scopes, logged_in=True, email=author.email, token=token
|
||||
author_id=author.id,
|
||||
scopes=scopes,
|
||||
logged_in=True,
|
||||
email=author.email,
|
||||
token=token,
|
||||
error_message="",
|
||||
)
|
||||
|
||||
# Устанавливаем auth в request
|
||||
setattr(request, "auth", auth_cred)
|
||||
request.auth = auth_cred
|
||||
logger.debug(
|
||||
f"[auth.authenticate] Авторизационные данные установлены в request.auth для {payload.user_id}"
|
||||
)
|
||||
|
@@ -1,10 +1,9 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import jwt
|
||||
from pydantic import BaseModel
|
||||
|
||||
from auth.exceptions import ExpiredToken, InvalidToken
|
||||
from settings import JWT_ALGORITHM, JWT_SECRET_KEY
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
@@ -19,7 +18,7 @@ class TokenPayload(BaseModel):
|
||||
|
||||
class JWTCodec:
|
||||
@staticmethod
|
||||
def encode(user, exp: Optional[datetime] = None) -> str:
|
||||
def encode(user: Union[dict[str, Any], Any], exp: Optional[datetime] = None) -> str:
|
||||
# Поддержка как объектов, так и словарей
|
||||
if isinstance(user, dict):
|
||||
# В SessionManager.create_session передается словарь {"id": user_id, "email": username}
|
||||
@@ -59,13 +58,16 @@ class JWTCodec:
|
||||
try:
|
||||
token = jwt.encode(payload, JWT_SECRET_KEY, JWT_ALGORITHM)
|
||||
logger.debug(f"[JWTCodec.encode] Токен успешно создан, длина: {len(token) if token else 0}")
|
||||
return token
|
||||
# Ensure we always return str, not bytes
|
||||
if isinstance(token, bytes):
|
||||
return token.decode("utf-8")
|
||||
return str(token)
|
||||
except Exception as e:
|
||||
logger.error(f"[JWTCodec.encode] Ошибка при кодировании JWT: {e}")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def decode(token: str, verify_exp: bool = True):
|
||||
def decode(token: str, verify_exp: bool = True) -> Optional[TokenPayload]:
|
||||
logger.debug(f"[JWTCodec.decode] Начало декодирования токена длиной {len(token) if token else 0}")
|
||||
|
||||
if not token:
|
||||
@@ -87,7 +89,7 @@ class JWTCodec:
|
||||
|
||||
# Убедимся, что exp существует (добавим обработку если exp отсутствует)
|
||||
if "exp" not in payload:
|
||||
logger.warning(f"[JWTCodec.decode] В токене отсутствует поле exp")
|
||||
logger.warning("[JWTCodec.decode] В токене отсутствует поле exp")
|
||||
# Добавим exp по умолчанию, чтобы избежать ошибки при создании TokenPayload
|
||||
payload["exp"] = int((datetime.now(tz=timezone.utc) + timedelta(days=30)).timestamp())
|
||||
|
||||
|
@@ -3,14 +3,16 @@
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
from collections.abc import Awaitable, MutableMapping
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from graphql import GraphQLResolveInfo
|
||||
from sqlalchemy.orm import exc
|
||||
from starlette.authentication import UnauthenticatedUser
|
||||
from starlette.datastructures import Headers
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from auth.credentials import AuthCredentials
|
||||
from auth.orm import Author
|
||||
@@ -36,8 +38,13 @@ class AuthenticatedUser:
|
||||
"""Аутентифицированный пользователь"""
|
||||
|
||||
def __init__(
|
||||
self, user_id: str, username: str = "", roles: list = None, permissions: dict = None, token: str = None
|
||||
):
|
||||
self,
|
||||
user_id: str,
|
||||
username: str = "",
|
||||
roles: Optional[list] = None,
|
||||
permissions: Optional[dict] = None,
|
||||
token: Optional[str] = None,
|
||||
) -> None:
|
||||
self.user_id = user_id
|
||||
self.username = username
|
||||
self.roles = roles or []
|
||||
@@ -68,33 +75,39 @@ class AuthMiddleware:
|
||||
4. Предоставление методов для установки/удаления cookies
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp):
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
self._context = None
|
||||
|
||||
async def authenticate_user(self, token: str):
|
||||
async def authenticate_user(self, token: str) -> tuple[AuthCredentials, AuthenticatedUser | UnauthenticatedUser]:
|
||||
"""Аутентифицирует пользователя по токену"""
|
||||
if not token:
|
||||
return AuthCredentials(scopes={}, error_message="no token"), UnauthenticatedUser()
|
||||
return AuthCredentials(
|
||||
author_id=None, scopes={}, logged_in=False, error_message="no token", email=None, token=None
|
||||
), UnauthenticatedUser()
|
||||
|
||||
# Проверяем сессию в Redis
|
||||
payload = await SessionManager.verify_session(token)
|
||||
if not payload:
|
||||
logger.debug("[auth.authenticate] Недействительный токен")
|
||||
return AuthCredentials(scopes={}, error_message="Invalid token"), UnauthenticatedUser()
|
||||
return AuthCredentials(
|
||||
author_id=None, scopes={}, logged_in=False, error_message="Invalid token", email=None, token=None
|
||||
), UnauthenticatedUser()
|
||||
|
||||
with local_session() as session:
|
||||
try:
|
||||
author = (
|
||||
session.query(Author)
|
||||
.filter(Author.id == payload.user_id)
|
||||
.filter(Author.is_active == True) # noqa
|
||||
.one()
|
||||
)
|
||||
author = session.query(Author).filter(Author.id == payload.user_id).one()
|
||||
|
||||
if author.is_locked():
|
||||
logger.debug(f"[auth.authenticate] Аккаунт заблокирован: {author.id}")
|
||||
return AuthCredentials(scopes={}, error_message="Account is locked"), UnauthenticatedUser()
|
||||
return AuthCredentials(
|
||||
author_id=None,
|
||||
scopes={},
|
||||
logged_in=False,
|
||||
error_message="Account is locked",
|
||||
email=None,
|
||||
token=None,
|
||||
), UnauthenticatedUser()
|
||||
|
||||
# Получаем разрешения из ролей
|
||||
scopes = author.get_permissions()
|
||||
@@ -108,7 +121,12 @@ class AuthMiddleware:
|
||||
|
||||
# Создаем объекты авторизации с сохранением токена
|
||||
credentials = AuthCredentials(
|
||||
author_id=author.id, scopes=scopes, logged_in=True, email=author.email, token=token
|
||||
author_id=author.id,
|
||||
scopes=scopes,
|
||||
logged_in=True,
|
||||
error_message="",
|
||||
email=author.email,
|
||||
token=token,
|
||||
)
|
||||
|
||||
user = AuthenticatedUser(
|
||||
@@ -124,9 +142,16 @@ class AuthMiddleware:
|
||||
|
||||
except exc.NoResultFound:
|
||||
logger.debug("[auth.authenticate] Пользователь не найден")
|
||||
return AuthCredentials(scopes={}, error_message="User not found"), UnauthenticatedUser()
|
||||
return AuthCredentials(
|
||||
author_id=None, scopes={}, logged_in=False, error_message="User not found", email=None, token=None
|
||||
), UnauthenticatedUser()
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
||||
async def __call__(
|
||||
self,
|
||||
scope: MutableMapping[str, Any],
|
||||
receive: Callable[[], Awaitable[MutableMapping[str, Any]]],
|
||||
send: Callable[[MutableMapping[str, Any]], Awaitable[None]],
|
||||
) -> None:
|
||||
"""Обработка ASGI запроса"""
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
@@ -135,21 +160,18 @@ class AuthMiddleware:
|
||||
# Извлекаем заголовки
|
||||
headers = Headers(scope=scope)
|
||||
token = None
|
||||
token_source = None
|
||||
|
||||
# Сначала пробуем получить токен из заголовка авторизации
|
||||
auth_header = headers.get(SESSION_TOKEN_HEADER)
|
||||
if auth_header:
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header.replace("Bearer ", "", 1).strip()
|
||||
token_source = "header"
|
||||
logger.debug(
|
||||
f"[middleware] Извлечен Bearer токен из заголовка {SESSION_TOKEN_HEADER}, длина: {len(token) if token else 0}"
|
||||
)
|
||||
else:
|
||||
# Если заголовок не начинается с Bearer, предполагаем, что это чистый токен
|
||||
token = auth_header.strip()
|
||||
token_source = "header"
|
||||
logger.debug(
|
||||
f"[middleware] Извлечен прямой токен из заголовка {SESSION_TOKEN_HEADER}, длина: {len(token) if token else 0}"
|
||||
)
|
||||
@@ -159,7 +181,6 @@ class AuthMiddleware:
|
||||
auth_header = headers.get("Authorization")
|
||||
if auth_header and auth_header.startswith("Bearer "):
|
||||
token = auth_header.replace("Bearer ", "", 1).strip()
|
||||
token_source = "auth_header"
|
||||
logger.debug(
|
||||
f"[middleware] Извлечен Bearer токен из заголовка Authorization, длина: {len(token) if token else 0}"
|
||||
)
|
||||
@@ -173,14 +194,13 @@ class AuthMiddleware:
|
||||
name, value = item.split("=", 1)
|
||||
if name.strip() == SESSION_COOKIE_NAME:
|
||||
token = value.strip()
|
||||
token_source = "cookie"
|
||||
logger.debug(
|
||||
f"[middleware] Извлечен токен из cookie {SESSION_COOKIE_NAME}, длина: {len(token) if token else 0}"
|
||||
)
|
||||
break
|
||||
|
||||
# Аутентифицируем пользователя
|
||||
auth, user = await self.authenticate_user(token)
|
||||
auth, user = await self.authenticate_user(token or "")
|
||||
|
||||
# Добавляем в scope данные авторизации и пользователя
|
||||
scope["auth"] = auth
|
||||
@@ -188,25 +208,29 @@ class AuthMiddleware:
|
||||
|
||||
if token:
|
||||
# Обновляем заголовки в scope для совместимости
|
||||
new_headers = []
|
||||
new_headers: list[tuple[bytes, bytes]] = []
|
||||
for name, value in scope["headers"]:
|
||||
if name.decode("latin1").lower() != SESSION_TOKEN_HEADER.lower():
|
||||
new_headers.append((name, value))
|
||||
header_name = name.decode("latin1") if isinstance(name, bytes) else str(name)
|
||||
if header_name.lower() != SESSION_TOKEN_HEADER.lower():
|
||||
# Ensure both name and value are bytes
|
||||
name_bytes = name if isinstance(name, bytes) else str(name).encode("latin1")
|
||||
value_bytes = value if isinstance(value, bytes) else str(value).encode("latin1")
|
||||
new_headers.append((name_bytes, value_bytes))
|
||||
new_headers.append((SESSION_TOKEN_HEADER.encode("latin1"), token.encode("latin1")))
|
||||
scope["headers"] = new_headers
|
||||
|
||||
logger.debug(f"[middleware] Пользователь аутентифицирован: {user.is_authenticated}")
|
||||
else:
|
||||
logger.debug(f"[middleware] Токен не найден, пользователь неаутентифицирован")
|
||||
logger.debug("[middleware] Токен не найден, пользователь неаутентифицирован")
|
||||
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
def set_context(self, context):
|
||||
def set_context(self, context) -> None:
|
||||
"""Сохраняет ссылку на контекст GraphQL запроса"""
|
||||
self._context = context
|
||||
logger.debug(f"[middleware] Установлен контекст GraphQL: {bool(context)}")
|
||||
|
||||
def set_cookie(self, key, value, **options):
|
||||
def set_cookie(self, key, value, **options) -> None:
|
||||
"""
|
||||
Устанавливает cookie в ответе
|
||||
|
||||
@@ -224,7 +248,7 @@ class AuthMiddleware:
|
||||
logger.debug(f"[middleware] Установлена cookie {key} через response")
|
||||
success = True
|
||||
except Exception as e:
|
||||
logger.error(f"[middleware] Ошибка при установке cookie {key} через response: {str(e)}")
|
||||
logger.error(f"[middleware] Ошибка при установке cookie {key} через response: {e!s}")
|
||||
|
||||
# Способ 2: Через собственный response в контексте
|
||||
if not success and hasattr(self, "_response") and self._response and hasattr(self._response, "set_cookie"):
|
||||
@@ -233,12 +257,12 @@ class AuthMiddleware:
|
||||
logger.debug(f"[middleware] Установлена cookie {key} через _response")
|
||||
success = True
|
||||
except Exception as e:
|
||||
logger.error(f"[middleware] Ошибка при установке cookie {key} через _response: {str(e)}")
|
||||
logger.error(f"[middleware] Ошибка при установке cookie {key} через _response: {e!s}")
|
||||
|
||||
if not success:
|
||||
logger.error(f"[middleware] Не удалось установить cookie {key}: объекты response недоступны")
|
||||
|
||||
def delete_cookie(self, key, **options):
|
||||
def delete_cookie(self, key, **options) -> None:
|
||||
"""
|
||||
Удаляет cookie из ответа
|
||||
|
||||
@@ -255,7 +279,7 @@ class AuthMiddleware:
|
||||
logger.debug(f"[middleware] Удалена cookie {key} через response")
|
||||
success = True
|
||||
except Exception as e:
|
||||
logger.error(f"[middleware] Ошибка при удалении cookie {key} через response: {str(e)}")
|
||||
logger.error(f"[middleware] Ошибка при удалении cookie {key} через response: {e!s}")
|
||||
|
||||
# Способ 2: Через собственный response в контексте
|
||||
if not success and hasattr(self, "_response") and self._response and hasattr(self._response, "delete_cookie"):
|
||||
@@ -264,12 +288,14 @@ class AuthMiddleware:
|
||||
logger.debug(f"[middleware] Удалена cookie {key} через _response")
|
||||
success = True
|
||||
except Exception as e:
|
||||
logger.error(f"[middleware] Ошибка при удалении cookie {key} через _response: {str(e)}")
|
||||
logger.error(f"[middleware] Ошибка при удалении cookie {key} через _response: {e!s}")
|
||||
|
||||
if not success:
|
||||
logger.error(f"[middleware] Не удалось удалить cookie {key}: объекты response недоступны")
|
||||
|
||||
async def resolve(self, next, root, info, *args, **kwargs):
|
||||
async def resolve(
|
||||
self, next: Callable[..., Any], root: Any, info: GraphQLResolveInfo, *args: Any, **kwargs: Any
|
||||
) -> Any:
|
||||
"""
|
||||
Middleware для обработки запросов GraphQL.
|
||||
Добавляет методы для установки cookie в контекст.
|
||||
@@ -291,13 +317,11 @@ class AuthMiddleware:
|
||||
context["response"] = JSONResponse({})
|
||||
logger.debug("[middleware] Создан новый response объект в контексте GraphQL")
|
||||
|
||||
logger.debug(
|
||||
f"[middleware] GraphQL resolve: контекст подготовлен, добавлены расширения для работы с cookie"
|
||||
)
|
||||
logger.debug("[middleware] GraphQL resolve: контекст подготовлен, добавлены расширения для работы с cookie")
|
||||
|
||||
return await next(root, info, *args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"[AuthMiddleware] Ошибка в GraphQL resolve: {str(e)}")
|
||||
logger.error(f"[AuthMiddleware] Ошибка в GraphQL resolve: {e!s}")
|
||||
raise
|
||||
|
||||
async def process_result(self, request: Request, result: Any) -> Response:
|
||||
@@ -321,9 +345,14 @@ class AuthMiddleware:
|
||||
try:
|
||||
import json
|
||||
|
||||
result_data = json.loads(result.body.decode("utf-8"))
|
||||
body_content = result.body
|
||||
if isinstance(body_content, (bytes, memoryview)):
|
||||
body_text = bytes(body_content).decode("utf-8")
|
||||
result_data = json.loads(body_text)
|
||||
else:
|
||||
result_data = json.loads(str(body_content))
|
||||
except Exception as e:
|
||||
logger.error(f"[process_result] Не удалось извлечь данные из JSONResponse: {str(e)}")
|
||||
logger.error(f"[process_result] Не удалось извлечь данные из JSONResponse: {e!s}")
|
||||
else:
|
||||
response = JSONResponse(result)
|
||||
result_data = result
|
||||
@@ -369,10 +398,18 @@ class AuthMiddleware:
|
||||
)
|
||||
logger.debug(f"[graphql_handler] Удалена cookie {SESSION_COOKIE_NAME} для операции {op_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"[process_result] Ошибка при обработке POST запроса: {str(e)}")
|
||||
logger.error(f"[process_result] Ошибка при обработке POST запроса: {e!s}")
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# Создаем единый экземпляр AuthMiddleware для использования с GraphQL
|
||||
auth_middleware = AuthMiddleware(lambda scope, receive, send: None)
|
||||
async def _dummy_app(
|
||||
scope: MutableMapping[str, Any],
|
||||
receive: Callable[[], Awaitable[MutableMapping[str, Any]]],
|
||||
send: Callable[[MutableMapping[str, Any]], Awaitable[None]],
|
||||
) -> None:
|
||||
"""Dummy ASGI app for middleware initialization"""
|
||||
|
||||
|
||||
auth_middleware = AuthMiddleware(_dummy_app)
|
||||
|
430
auth/oauth.py
430
auth/oauth.py
@@ -1,9 +1,12 @@
|
||||
import time
|
||||
from secrets import token_urlsafe
|
||||
from typing import Any, Optional
|
||||
|
||||
import orjson
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
from authlib.oauth2.rfc7636 import create_s256_code_challenge
|
||||
from graphql import GraphQLResolveInfo
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, RedirectResponse
|
||||
|
||||
from auth.orm import Author
|
||||
@@ -40,17 +43,106 @@ PROVIDERS = {
|
||||
"api_base_url": "https://graph.facebook.com/",
|
||||
"client_kwargs": {"scope": "public_profile email"},
|
||||
},
|
||||
"x": {
|
||||
"name": "x",
|
||||
"access_token_url": "https://api.twitter.com/2/oauth2/token",
|
||||
"authorize_url": "https://twitter.com/i/oauth2/authorize",
|
||||
"api_base_url": "https://api.twitter.com/2/",
|
||||
"client_kwargs": {"scope": "tweet.read users.read offline.access"},
|
||||
},
|
||||
"telegram": {
|
||||
"name": "telegram",
|
||||
"authorize_url": "https://oauth.telegram.org/auth",
|
||||
"api_base_url": "https://api.telegram.org/",
|
||||
"client_kwargs": {"scope": "user:read"},
|
||||
},
|
||||
"vk": {
|
||||
"name": "vk",
|
||||
"access_token_url": "https://oauth.vk.com/access_token",
|
||||
"authorize_url": "https://oauth.vk.com/authorize",
|
||||
"api_base_url": "https://api.vk.com/method/",
|
||||
"client_kwargs": {"scope": "email", "v": "5.131"},
|
||||
},
|
||||
"yandex": {
|
||||
"name": "yandex",
|
||||
"access_token_url": "https://oauth.yandex.ru/token",
|
||||
"authorize_url": "https://oauth.yandex.ru/authorize",
|
||||
"api_base_url": "https://login.yandex.ru/info",
|
||||
"client_kwargs": {"scope": "login:email login:info"},
|
||||
},
|
||||
}
|
||||
|
||||
# Регистрация провайдеров
|
||||
for provider, config in PROVIDERS.items():
|
||||
if provider in OAUTH_CLIENTS:
|
||||
oauth.register(
|
||||
name=config["name"],
|
||||
client_id=OAUTH_CLIENTS[provider.upper()]["id"],
|
||||
client_secret=OAUTH_CLIENTS[provider.upper()]["key"],
|
||||
**config,
|
||||
)
|
||||
if provider in OAUTH_CLIENTS and OAUTH_CLIENTS[provider.upper()]:
|
||||
client_config = OAUTH_CLIENTS[provider.upper()]
|
||||
if "id" in client_config and "key" in client_config:
|
||||
try:
|
||||
# Регистрируем провайдеров вручную для избежания проблем типизации
|
||||
if provider == "google":
|
||||
oauth.register(
|
||||
name="google",
|
||||
client_id=client_config["id"],
|
||||
client_secret=client_config["key"],
|
||||
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
||||
)
|
||||
elif provider == "github":
|
||||
oauth.register(
|
||||
name="github",
|
||||
client_id=client_config["id"],
|
||||
client_secret=client_config["key"],
|
||||
access_token_url="https://github.com/login/oauth/access_token",
|
||||
authorize_url="https://github.com/login/oauth/authorize",
|
||||
api_base_url="https://api.github.com/",
|
||||
)
|
||||
elif provider == "facebook":
|
||||
oauth.register(
|
||||
name="facebook",
|
||||
client_id=client_config["id"],
|
||||
client_secret=client_config["key"],
|
||||
access_token_url="https://graph.facebook.com/v13.0/oauth/access_token",
|
||||
authorize_url="https://www.facebook.com/v13.0/dialog/oauth",
|
||||
api_base_url="https://graph.facebook.com/",
|
||||
)
|
||||
elif provider == "x":
|
||||
oauth.register(
|
||||
name="x",
|
||||
client_id=client_config["id"],
|
||||
client_secret=client_config["key"],
|
||||
access_token_url="https://api.twitter.com/2/oauth2/token",
|
||||
authorize_url="https://twitter.com/i/oauth2/authorize",
|
||||
api_base_url="https://api.twitter.com/2/",
|
||||
)
|
||||
elif provider == "telegram":
|
||||
oauth.register(
|
||||
name="telegram",
|
||||
client_id=client_config["id"],
|
||||
client_secret=client_config["key"],
|
||||
authorize_url="https://oauth.telegram.org/auth",
|
||||
api_base_url="https://api.telegram.org/",
|
||||
)
|
||||
elif provider == "vk":
|
||||
oauth.register(
|
||||
name="vk",
|
||||
client_id=client_config["id"],
|
||||
client_secret=client_config["key"],
|
||||
access_token_url="https://oauth.vk.com/access_token",
|
||||
authorize_url="https://oauth.vk.com/authorize",
|
||||
api_base_url="https://api.vk.com/method/",
|
||||
)
|
||||
elif provider == "yandex":
|
||||
oauth.register(
|
||||
name="yandex",
|
||||
client_id=client_config["id"],
|
||||
client_secret=client_config["key"],
|
||||
access_token_url="https://oauth.yandex.ru/token",
|
||||
authorize_url="https://oauth.yandex.ru/authorize",
|
||||
api_base_url="https://login.yandex.ru/info",
|
||||
)
|
||||
logger.info(f"OAuth provider {provider} registered successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to register OAuth provider {provider}: {e}")
|
||||
continue
|
||||
|
||||
|
||||
async def get_user_profile(provider: str, client, token) -> dict:
|
||||
@@ -63,7 +155,7 @@ async def get_user_profile(provider: str, client, token) -> dict:
|
||||
"name": userinfo.get("name"),
|
||||
"picture": userinfo.get("picture", "").replace("=s96", "=s600"),
|
||||
}
|
||||
elif provider == "github":
|
||||
if provider == "github":
|
||||
profile = await client.get("user", token=token)
|
||||
profile_data = profile.json()
|
||||
emails = await client.get("user/emails", token=token)
|
||||
@@ -75,7 +167,7 @@ async def get_user_profile(provider: str, client, token) -> dict:
|
||||
"name": profile_data.get("name") or profile_data.get("login"),
|
||||
"picture": profile_data.get("avatar_url"),
|
||||
}
|
||||
elif provider == "facebook":
|
||||
if provider == "facebook":
|
||||
profile = await client.get("me?fields=id,name,email,picture.width(600)", token=token)
|
||||
profile_data = profile.json()
|
||||
return {
|
||||
@@ -84,12 +176,65 @@ async def get_user_profile(provider: str, client, token) -> dict:
|
||||
"name": profile_data.get("name"),
|
||||
"picture": profile_data.get("picture", {}).get("data", {}).get("url"),
|
||||
}
|
||||
if provider == "x":
|
||||
# Twitter/X API v2
|
||||
profile = await client.get("users/me?user.fields=id,name,username,profile_image_url", token=token)
|
||||
profile_data = profile.json()
|
||||
user_data = profile_data.get("data", {})
|
||||
return {
|
||||
"id": user_data.get("id"),
|
||||
"email": None, # X не предоставляет email через API
|
||||
"name": user_data.get("name") or user_data.get("username"),
|
||||
"picture": user_data.get("profile_image_url", "").replace("_normal", "_400x400"),
|
||||
}
|
||||
if provider == "telegram":
|
||||
# Telegram OAuth (через Telegram Login Widget)
|
||||
# Данные обычно приходят в token параметрах
|
||||
return {
|
||||
"id": str(token.get("id", "")),
|
||||
"email": None, # Telegram не предоставляет email
|
||||
"phone": str(token.get("phone_number", "")),
|
||||
"name": token.get("first_name", "") + " " + token.get("last_name", ""),
|
||||
"picture": token.get("photo_url"),
|
||||
}
|
||||
if provider == "vk":
|
||||
# VK API
|
||||
profile = await client.get("users.get?fields=photo_400_orig,contacts&v=5.131", token=token)
|
||||
profile_data = profile.json()
|
||||
if profile_data.get("response"):
|
||||
user_data = profile_data["response"][0]
|
||||
return {
|
||||
"id": str(user_data["id"]),
|
||||
"email": user_data.get("contacts", {}).get("email"),
|
||||
"name": f"{user_data.get('first_name', '')} {user_data.get('last_name', '')}".strip(),
|
||||
"picture": user_data.get("photo_400_orig"),
|
||||
}
|
||||
if provider == "yandex":
|
||||
# Yandex API
|
||||
profile = await client.get("?format=json", token=token)
|
||||
profile_data = profile.json()
|
||||
return {
|
||||
"id": profile_data.get("id"),
|
||||
"email": profile_data.get("default_email"),
|
||||
"name": profile_data.get("display_name") or profile_data.get("real_name"),
|
||||
"picture": f"https://avatars.yandex.net/get-yapic/{profile_data.get('default_avatar_id')}/islands-200"
|
||||
if profile_data.get("default_avatar_id")
|
||||
else None,
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
async def oauth_login(request):
|
||||
"""Начинает процесс OAuth авторизации"""
|
||||
provider = request.path_params["provider"]
|
||||
async def oauth_login(_: None, _info: GraphQLResolveInfo, provider: str, callback_data: dict[str, Any]) -> JSONResponse:
|
||||
"""
|
||||
Обработка OAuth авторизации
|
||||
|
||||
Args:
|
||||
provider: Провайдер OAuth (google, github, etc.)
|
||||
callback_data: Данные из callback-а
|
||||
|
||||
Returns:
|
||||
dict: Результат авторизации с токеном или ошибкой
|
||||
"""
|
||||
if provider not in PROVIDERS:
|
||||
return JSONResponse({"error": "Invalid provider"}, status_code=400)
|
||||
|
||||
@@ -98,8 +243,8 @@ async def oauth_login(request):
|
||||
return JSONResponse({"error": "Provider not configured"}, status_code=400)
|
||||
|
||||
# Получаем параметры из query string
|
||||
state = request.query_params.get("state")
|
||||
redirect_uri = request.query_params.get("redirect_uri", FRONTEND_URL)
|
||||
state = callback_data.get("state")
|
||||
redirect_uri = callback_data.get("redirect_uri", FRONTEND_URL)
|
||||
|
||||
if not state:
|
||||
return JSONResponse({"error": "State parameter is required"}, status_code=400)
|
||||
@@ -118,18 +263,18 @@ async def oauth_login(request):
|
||||
await store_oauth_state(state, oauth_data)
|
||||
|
||||
# Используем URL из фронтенда для callback
|
||||
oauth_callback_uri = f"{request.base_url}oauth/{provider}/callback"
|
||||
oauth_callback_uri = f"{callback_data['base_url']}oauth/{provider}/callback"
|
||||
|
||||
try:
|
||||
return await client.authorize_redirect(
|
||||
request,
|
||||
callback_data["request"],
|
||||
oauth_callback_uri,
|
||||
code_challenge=code_challenge,
|
||||
code_challenge_method="S256",
|
||||
state=state,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"OAuth redirect error for {provider}: {str(e)}")
|
||||
logger.error(f"OAuth redirect error for {provider}: {e!s}")
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
@@ -162,41 +307,73 @@ async def oauth_callback(request):
|
||||
|
||||
# Получаем профиль пользователя
|
||||
profile = await get_user_profile(provider, client, token)
|
||||
if not profile.get("email"):
|
||||
return JSONResponse({"error": "Email not provided"}, status_code=400)
|
||||
|
||||
# Для некоторых провайдеров (X, Telegram) email может отсутствовать
|
||||
email = profile.get("email")
|
||||
if not email:
|
||||
# Генерируем временный email на основе провайдера и ID
|
||||
email = f"{provider}_{profile.get('id', 'unknown')}@oauth.local"
|
||||
logger.info(f"Generated temporary email for {provider} user: {email}")
|
||||
|
||||
# Создаем или обновляем пользователя
|
||||
with local_session() as session:
|
||||
author = session.query(Author).filter(Author.email == profile["email"]).first()
|
||||
# Сначала ищем пользователя по OAuth
|
||||
author = Author.find_by_oauth(provider, profile["id"], session)
|
||||
|
||||
if not author:
|
||||
# Генерируем slug из имени или email
|
||||
slug = generate_unique_slug(profile["name"] or profile["email"].split("@")[0])
|
||||
if author:
|
||||
# Пользователь найден по OAuth - обновляем данные
|
||||
author.set_oauth_account(provider, profile["id"], email=profile.get("email"))
|
||||
|
||||
# Обновляем основные данные автора если они пустые
|
||||
if profile.get("name") and not author.name:
|
||||
author.name = profile["name"] # type: ignore[assignment]
|
||||
if profile.get("picture") and not author.pic:
|
||||
author.pic = profile["picture"] # type: ignore[assignment]
|
||||
author.updated_at = int(time.time()) # type: ignore[assignment]
|
||||
author.last_seen = int(time.time()) # type: ignore[assignment]
|
||||
|
||||
author = Author(
|
||||
email=profile["email"],
|
||||
name=profile["name"],
|
||||
slug=slug,
|
||||
pic=profile.get("picture"),
|
||||
oauth=f"{provider}:{profile['id']}",
|
||||
email_verified=True,
|
||||
created_at=int(time.time()),
|
||||
updated_at=int(time.time()),
|
||||
last_seen=int(time.time()),
|
||||
)
|
||||
session.add(author)
|
||||
else:
|
||||
author.name = profile["name"]
|
||||
author.pic = profile.get("picture") or author.pic
|
||||
author.oauth = f"{provider}:{profile['id']}"
|
||||
author.email_verified = True
|
||||
author.updated_at = int(time.time())
|
||||
author.last_seen = int(time.time())
|
||||
# Ищем пользователя по email если есть настоящий email
|
||||
author = None
|
||||
if email and email != f"{provider}_{profile.get('id', 'unknown')}@oauth.local":
|
||||
author = session.query(Author).filter(Author.email == email).first()
|
||||
|
||||
if author:
|
||||
# Пользователь найден по email - добавляем OAuth данные
|
||||
author.set_oauth_account(provider, profile["id"], email=profile.get("email"))
|
||||
|
||||
# Обновляем данные автора если нужно
|
||||
if profile.get("name") and not author.name:
|
||||
author.name = profile["name"] # type: ignore[assignment]
|
||||
if profile.get("picture") and not author.pic:
|
||||
author.pic = profile["picture"] # type: ignore[assignment]
|
||||
author.updated_at = int(time.time()) # type: ignore[assignment]
|
||||
author.last_seen = int(time.time()) # type: ignore[assignment]
|
||||
|
||||
else:
|
||||
# Создаем нового пользователя
|
||||
slug = generate_unique_slug(profile["name"] or f"{provider}_{profile.get('id', 'user')}")
|
||||
|
||||
author = Author(
|
||||
email=email,
|
||||
name=profile["name"] or f"{provider.title()} User",
|
||||
slug=slug,
|
||||
pic=profile.get("picture"),
|
||||
email_verified=True if profile.get("email") else False,
|
||||
created_at=int(time.time()),
|
||||
updated_at=int(time.time()),
|
||||
last_seen=int(time.time()),
|
||||
)
|
||||
session.add(author)
|
||||
session.flush() # Получаем ID автора
|
||||
|
||||
# Добавляем OAuth данные для нового пользователя
|
||||
author.set_oauth_account(provider, profile["id"], email=profile.get("email"))
|
||||
|
||||
session.commit()
|
||||
|
||||
# Создаем сессию
|
||||
session_token = await TokenStorage.create_session(author)
|
||||
# Создаем токен сессии
|
||||
session_token = await TokenStorage.create_session(str(author.id))
|
||||
|
||||
# Формируем URL для редиректа с токеном
|
||||
redirect_url = f"{stored_redirect_uri}?state={state}&access_token={session_token}"
|
||||
@@ -212,10 +389,10 @@ async def oauth_callback(request):
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"OAuth callback error: {str(e)}")
|
||||
logger.error(f"OAuth callback error: {e!s}")
|
||||
# В случае ошибки редиректим на фронтенд с ошибкой
|
||||
fallback_redirect = request.query_params.get("redirect_uri", FRONTEND_URL)
|
||||
return RedirectResponse(url=f"{fallback_redirect}?error=oauth_failed&message={str(e)}")
|
||||
return RedirectResponse(url=f"{fallback_redirect}?error=oauth_failed&message={e!s}")
|
||||
|
||||
|
||||
async def store_oauth_state(state: str, data: dict) -> None:
|
||||
@@ -224,7 +401,7 @@ async def store_oauth_state(state: str, data: dict) -> None:
|
||||
await redis.execute("SETEX", key, OAUTH_STATE_TTL, orjson.dumps(data))
|
||||
|
||||
|
||||
async def get_oauth_state(state: str) -> dict:
|
||||
async def get_oauth_state(state: str) -> Optional[dict]:
|
||||
"""Получает и удаляет OAuth состояние из Redis (one-time use)"""
|
||||
key = f"oauth_state:{state}"
|
||||
data = await redis.execute("GET", key)
|
||||
@@ -232,3 +409,164 @@ async def get_oauth_state(state: str) -> dict:
|
||||
await redis.execute("DEL", key) # Одноразовое использование
|
||||
return orjson.loads(data)
|
||||
return None
|
||||
|
||||
|
||||
# HTTP handlers для тестирования
|
||||
async def oauth_login_http(request: Request) -> JSONResponse | RedirectResponse:
|
||||
"""HTTP handler для OAuth login"""
|
||||
try:
|
||||
provider = request.path_params.get("provider")
|
||||
if not provider or provider not in PROVIDERS:
|
||||
return JSONResponse({"error": "Invalid provider"}, status_code=400)
|
||||
|
||||
client = oauth.create_client(provider)
|
||||
if not client:
|
||||
return JSONResponse({"error": "Provider not configured"}, status_code=400)
|
||||
|
||||
# Генерируем PKCE challenge
|
||||
code_verifier = token_urlsafe(32)
|
||||
code_challenge = create_s256_code_challenge(code_verifier)
|
||||
state = token_urlsafe(32)
|
||||
|
||||
# Сохраняем состояние в сессии
|
||||
request.session["code_verifier"] = code_verifier
|
||||
request.session["provider"] = provider
|
||||
request.session["state"] = state
|
||||
|
||||
# Сохраняем состояние OAuth в Redis
|
||||
oauth_data = {
|
||||
"code_verifier": code_verifier,
|
||||
"provider": provider,
|
||||
"redirect_uri": FRONTEND_URL,
|
||||
"created_at": int(time.time()),
|
||||
}
|
||||
await store_oauth_state(state, oauth_data)
|
||||
|
||||
# URL для callback
|
||||
callback_uri = f"{FRONTEND_URL}oauth/{provider}/callback"
|
||||
|
||||
return await client.authorize_redirect(
|
||||
request,
|
||||
callback_uri,
|
||||
code_challenge=code_challenge,
|
||||
code_challenge_method="S256",
|
||||
state=state,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"OAuth login error: {e}")
|
||||
return JSONResponse({"error": "OAuth login failed"}, status_code=500)
|
||||
|
||||
|
||||
async def oauth_callback_http(request: Request) -> JSONResponse | RedirectResponse:
|
||||
"""HTTP handler для OAuth callback"""
|
||||
try:
|
||||
# Используем GraphQL resolver логику
|
||||
provider = request.session.get("provider")
|
||||
if not provider:
|
||||
return JSONResponse({"error": "No OAuth session found"}, status_code=400)
|
||||
|
||||
state = request.query_params.get("state")
|
||||
session_state = request.session.get("state")
|
||||
|
||||
if not state or state != session_state:
|
||||
return JSONResponse({"error": "Invalid or expired OAuth state"}, status_code=400)
|
||||
|
||||
oauth_data = await get_oauth_state(state)
|
||||
if not oauth_data:
|
||||
return JSONResponse({"error": "Invalid or expired OAuth state"}, status_code=400)
|
||||
|
||||
# Используем существующую логику
|
||||
client = oauth.create_client(provider)
|
||||
token = await client.authorize_access_token(request)
|
||||
|
||||
profile = await get_user_profile(provider, client, token)
|
||||
if not profile:
|
||||
return JSONResponse({"error": "Failed to get user profile"}, status_code=400)
|
||||
|
||||
# Для некоторых провайдеров (X, Telegram) email может отсутствовать
|
||||
email = profile.get("email")
|
||||
if not email:
|
||||
# Генерируем временный email на основе провайдера и ID
|
||||
email = f"{provider}_{profile.get('id', 'unknown')}@oauth.local"
|
||||
|
||||
# Регистрируем/обновляем пользователя
|
||||
with local_session() as session:
|
||||
# Сначала ищем пользователя по OAuth
|
||||
author = Author.find_by_oauth(provider, profile["id"], session)
|
||||
|
||||
if author:
|
||||
# Пользователь найден по OAuth - обновляем данные
|
||||
author.set_oauth_account(provider, profile["id"], email=profile.get("email"))
|
||||
|
||||
# Обновляем основные данные автора если они пустые
|
||||
if profile.get("name") and not author.name:
|
||||
author.name = profile["name"] # type: ignore[assignment]
|
||||
if profile.get("picture") and not author.pic:
|
||||
author.pic = profile["picture"] # type: ignore[assignment]
|
||||
author.updated_at = int(time.time()) # type: ignore[assignment]
|
||||
author.last_seen = int(time.time()) # type: ignore[assignment]
|
||||
|
||||
else:
|
||||
# Ищем пользователя по email если есть настоящий email
|
||||
author = None
|
||||
if email and email != f"{provider}_{profile.get('id', 'unknown')}@oauth.local":
|
||||
author = session.query(Author).filter(Author.email == email).first()
|
||||
|
||||
if author:
|
||||
# Пользователь найден по email - добавляем OAuth данные
|
||||
author.set_oauth_account(provider, profile["id"], email=profile.get("email"))
|
||||
|
||||
# Обновляем данные автора если нужно
|
||||
if profile.get("name") and not author.name:
|
||||
author.name = profile["name"] # type: ignore[assignment]
|
||||
if profile.get("picture") and not author.pic:
|
||||
author.pic = profile["picture"] # type: ignore[assignment]
|
||||
author.updated_at = int(time.time()) # type: ignore[assignment]
|
||||
author.last_seen = int(time.time()) # type: ignore[assignment]
|
||||
|
||||
else:
|
||||
# Создаем нового пользователя
|
||||
slug = generate_unique_slug(profile["name"] or f"{provider}_{profile.get('id', 'user')}")
|
||||
|
||||
author = Author(
|
||||
email=email,
|
||||
name=profile["name"] or f"{provider.title()} User",
|
||||
slug=slug,
|
||||
pic=profile.get("picture"),
|
||||
email_verified=True if profile.get("email") else False,
|
||||
created_at=int(time.time()),
|
||||
updated_at=int(time.time()),
|
||||
last_seen=int(time.time()),
|
||||
)
|
||||
session.add(author)
|
||||
session.flush() # Получаем ID автора
|
||||
|
||||
# Добавляем OAuth данные для нового пользователя
|
||||
author.set_oauth_account(provider, profile["id"], email=profile.get("email"))
|
||||
|
||||
session.commit()
|
||||
|
||||
# Создаем токен сессии
|
||||
session_token = await TokenStorage.create_session(str(author.id))
|
||||
|
||||
# Очищаем OAuth сессию
|
||||
request.session.pop("code_verifier", None)
|
||||
request.session.pop("provider", None)
|
||||
request.session.pop("state", None)
|
||||
|
||||
# Возвращаем redirect с cookie
|
||||
response = RedirectResponse(url="/auth/success", status_code=307)
|
||||
response.set_cookie(
|
||||
"session_token",
|
||||
session_token,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="lax",
|
||||
max_age=30 * 24 * 60 * 60, # 30 дней
|
||||
)
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"OAuth callback error: {e}")
|
||||
return JSONResponse({"error": "OAuth callback failed"}, status_code=500)
|
||||
|
95
auth/orm.py
95
auth/orm.py
@@ -5,7 +5,7 @@ from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from auth.identity import Password
|
||||
from services.db import Base
|
||||
from services.db import BaseModel as Base
|
||||
|
||||
# Общие table_args для всех моделей
|
||||
DEFAULT_TABLE_ARGS = {"extend_existing": True}
|
||||
@@ -91,7 +91,7 @@ class RolePermission(Base):
|
||||
__tablename__ = "role_permission"
|
||||
__table_args__ = {"extend_existing": True}
|
||||
|
||||
id = None
|
||||
id = None # type: ignore
|
||||
role = Column(ForeignKey("role.id"), primary_key=True, index=True)
|
||||
permission = Column(ForeignKey("permission.id"), primary_key=True, index=True)
|
||||
|
||||
@@ -124,7 +124,7 @@ class AuthorRole(Base):
|
||||
__tablename__ = "author_role"
|
||||
__table_args__ = {"extend_existing": True}
|
||||
|
||||
id = None
|
||||
id = None # type: ignore
|
||||
community = Column(ForeignKey("community.id"), primary_key=True, index=True, default=1)
|
||||
author = Column(ForeignKey("author.id"), primary_key=True, index=True)
|
||||
role = Column(ForeignKey("role.id"), primary_key=True, index=True)
|
||||
@@ -152,16 +152,14 @@ class Author(Base):
|
||||
pic = Column(String, nullable=True, comment="Picture")
|
||||
links = Column(JSON, nullable=True, comment="Links")
|
||||
|
||||
# Дополнительные поля из User
|
||||
oauth = Column(String, nullable=True, comment="OAuth provider")
|
||||
oid = Column(String, nullable=True, comment="OAuth ID")
|
||||
muted = Column(Boolean, default=False, comment="Is author muted")
|
||||
# OAuth аккаунты - JSON с данными всех провайдеров
|
||||
# Формат: {"google": {"id": "123", "email": "user@gmail.com"}, "github": {"id": "456"}}
|
||||
oauth = Column(JSON, nullable=True, default=dict, comment="OAuth accounts data")
|
||||
|
||||
# Поля аутентификации
|
||||
email = Column(String, unique=True, nullable=True, comment="Email")
|
||||
phone = Column(String, unique=True, nullable=True, comment="Phone")
|
||||
password = Column(String, nullable=True, comment="Password hash")
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
email_verified = Column(Boolean, default=False)
|
||||
phone_verified = Column(Boolean, default=False)
|
||||
failed_login_attempts = Column(Integer, default=0)
|
||||
@@ -205,28 +203,28 @@ class Author(Base):
|
||||
|
||||
def verify_password(self, password: str) -> bool:
|
||||
"""Проверяет пароль пользователя"""
|
||||
return Password.verify(password, self.password) if self.password else False
|
||||
return Password.verify(password, str(self.password)) if self.password else False
|
||||
|
||||
def set_password(self, password: str):
|
||||
"""Устанавливает пароль пользователя"""
|
||||
self.password = Password.encode(password)
|
||||
self.password = Password.encode(password) # type: ignore[assignment]
|
||||
|
||||
def increment_failed_login(self):
|
||||
"""Увеличивает счетчик неудачных попыток входа"""
|
||||
self.failed_login_attempts += 1
|
||||
self.failed_login_attempts += 1 # type: ignore[assignment]
|
||||
if self.failed_login_attempts >= 5:
|
||||
self.account_locked_until = int(time.time()) + 300 # 5 минут
|
||||
self.account_locked_until = int(time.time()) + 300 # type: ignore[assignment] # 5 минут
|
||||
|
||||
def reset_failed_login(self):
|
||||
"""Сбрасывает счетчик неудачных попыток входа"""
|
||||
self.failed_login_attempts = 0
|
||||
self.account_locked_until = None
|
||||
self.failed_login_attempts = 0 # type: ignore[assignment]
|
||||
self.account_locked_until = None # type: ignore[assignment]
|
||||
|
||||
def is_locked(self) -> bool:
|
||||
"""Проверяет, заблокирован ли аккаунт"""
|
||||
if not self.account_locked_until:
|
||||
return False
|
||||
return self.account_locked_until > int(time.time())
|
||||
return bool(self.account_locked_until > int(time.time()))
|
||||
|
||||
@property
|
||||
def username(self) -> str:
|
||||
@@ -237,9 +235,9 @@ class Author(Base):
|
||||
Returns:
|
||||
str: slug, email или phone пользователя
|
||||
"""
|
||||
return self.slug or self.email or self.phone or ""
|
||||
return str(self.slug or self.email or self.phone or "")
|
||||
|
||||
def dict(self, access=False) -> Dict:
|
||||
def dict(self, access: bool = False) -> Dict:
|
||||
"""
|
||||
Сериализует объект Author в словарь с учетом прав доступа.
|
||||
|
||||
@@ -266,3 +264,66 @@ class Author(Base):
|
||||
result[field] = None
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def find_by_oauth(cls, provider: str, provider_id: str, session):
|
||||
"""
|
||||
Находит автора по OAuth провайдеру и ID
|
||||
|
||||
Args:
|
||||
provider (str): Имя OAuth провайдера (google, github и т.д.)
|
||||
provider_id (str): ID пользователя у провайдера
|
||||
session: Сессия базы данных
|
||||
|
||||
Returns:
|
||||
Author или None: Найденный автор или None если не найден
|
||||
"""
|
||||
# Ищем авторов, у которых есть данный провайдер с данным ID
|
||||
authors = session.query(cls).filter(cls.oauth.isnot(None)).all()
|
||||
for author in authors:
|
||||
if author.oauth and provider in author.oauth:
|
||||
if author.oauth[provider].get("id") == provider_id:
|
||||
return author
|
||||
return None
|
||||
|
||||
def set_oauth_account(self, provider: str, provider_id: str, email: str = None):
|
||||
"""
|
||||
Устанавливает OAuth аккаунт для автора
|
||||
|
||||
Args:
|
||||
provider (str): Имя OAuth провайдера (google, github и т.д.)
|
||||
provider_id (str): ID пользователя у провайдера
|
||||
email (str, optional): Email от провайдера
|
||||
"""
|
||||
if not self.oauth:
|
||||
self.oauth = {} # type: ignore[assignment]
|
||||
|
||||
oauth_data = {"id": provider_id}
|
||||
if email:
|
||||
oauth_data["email"] = email
|
||||
|
||||
self.oauth[provider] = oauth_data # type: ignore[index]
|
||||
|
||||
def get_oauth_account(self, provider: str):
|
||||
"""
|
||||
Получает OAuth аккаунт провайдера
|
||||
|
||||
Args:
|
||||
provider (str): Имя OAuth провайдера
|
||||
|
||||
Returns:
|
||||
dict или None: Данные OAuth аккаунта или None если не найден
|
||||
"""
|
||||
if not self.oauth:
|
||||
return None
|
||||
return self.oauth.get(provider)
|
||||
|
||||
def remove_oauth_account(self, provider: str):
|
||||
"""
|
||||
Удаляет OAuth аккаунт провайдера
|
||||
|
||||
Args:
|
||||
provider (str): Имя OAuth провайдера
|
||||
"""
|
||||
if self.oauth and provider in self.oauth:
|
||||
del self.oauth[provider]
|
||||
|
@@ -5,7 +5,7 @@
|
||||
на основе его роли в этом сообществе.
|
||||
"""
|
||||
|
||||
from typing import List, Union
|
||||
from typing import Union
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -98,7 +98,7 @@ class ContextualPermissionCheck:
|
||||
permission_id = f"{resource}:{operation}"
|
||||
|
||||
# Запрос на проверку разрешений для указанных ролей
|
||||
has_permission = (
|
||||
return (
|
||||
session.query(RolePermission)
|
||||
.join(Role, Role.id == RolePermission.role)
|
||||
.join(Permission, Permission.id == RolePermission.permission)
|
||||
@@ -107,10 +107,8 @@ class ContextualPermissionCheck:
|
||||
is not None
|
||||
)
|
||||
|
||||
return has_permission
|
||||
|
||||
@staticmethod
|
||||
def get_user_community_roles(session: Session, author_id: int, community_slug: str) -> List[CommunityRole]:
|
||||
def get_user_community_roles(session: Session, author_id: int, community_slug: str) -> list[CommunityRole]:
|
||||
"""
|
||||
Получает список ролей пользователя в сообществе.
|
||||
|
||||
@@ -180,7 +178,7 @@ class ContextualPermissionCheck:
|
||||
|
||||
if not community_follower:
|
||||
# Создаем новую запись CommunityFollower
|
||||
community_follower = CommunityFollower(author=author_id, community=community.id)
|
||||
community_follower = CommunityFollower(follower=author_id, community=community.id)
|
||||
session.add(community_follower)
|
||||
|
||||
# Назначаем роль
|
||||
|
@@ -1,11 +1,10 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from auth.jwtcodec import JWTCodec, TokenPayload
|
||||
from services.redis import redis
|
||||
from settings import SESSION_TOKEN_LIFE_SPAN
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
|
||||
@@ -103,7 +102,7 @@ class SessionManager:
|
||||
pipeline.hset(token_key, mapping={"user_id": user_id, "username": username})
|
||||
pipeline.expire(token_key, 30 * 24 * 60 * 60)
|
||||
|
||||
result = await pipeline.execute()
|
||||
await pipeline.execute()
|
||||
logger.info(f"[SessionManager.create_session] Сессия успешно создана для пользователя {user_id}")
|
||||
|
||||
return token
|
||||
@@ -130,7 +129,7 @@ class SessionManager:
|
||||
|
||||
logger.debug(f"[SessionManager.verify_session] Успешно декодирован токен, user_id={payload.user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"[SessionManager.verify_session] Ошибка при декодировании токена: {str(e)}")
|
||||
logger.error(f"[SessionManager.verify_session] Ошибка при декодировании токена: {e!s}")
|
||||
return None
|
||||
|
||||
# Получаем данные из payload
|
||||
@@ -205,9 +204,9 @@ class SessionManager:
|
||||
return payload
|
||||
|
||||
@classmethod
|
||||
async def get_user_sessions(cls, user_id: str) -> List[Dict[str, Any]]:
|
||||
async def get_user_sessions(cls, user_id: str) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Получает список активных сессий пользователя.
|
||||
Получает все активные сессии пользователя.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
@@ -219,13 +218,15 @@ class SessionManager:
|
||||
tokens = await redis.smembers(user_sessions_key)
|
||||
|
||||
sessions = []
|
||||
for token in tokens:
|
||||
session_key = cls._make_session_key(user_id, token)
|
||||
# Convert set to list for iteration
|
||||
for token in list(tokens):
|
||||
token_str: str = str(token)
|
||||
session_key = cls._make_session_key(user_id, token_str)
|
||||
session_data = await redis.hgetall(session_key)
|
||||
|
||||
if session_data:
|
||||
if session_data and token:
|
||||
session = dict(session_data)
|
||||
session["token"] = token
|
||||
session["token"] = token_str
|
||||
sessions.append(session)
|
||||
|
||||
return sessions
|
||||
@@ -275,17 +276,19 @@ class SessionManager:
|
||||
tokens = await redis.smembers(user_sessions_key)
|
||||
|
||||
count = 0
|
||||
for token in tokens:
|
||||
session_key = cls._make_session_key(user_id, token)
|
||||
# Convert set to list for iteration
|
||||
for token in list(tokens):
|
||||
token_str: str = str(token)
|
||||
session_key = cls._make_session_key(user_id, token_str)
|
||||
|
||||
# Удаляем данные сессии
|
||||
deleted = await redis.delete(session_key)
|
||||
count += deleted
|
||||
|
||||
# Также удаляем ключ в формате TokenStorage
|
||||
token_payload = JWTCodec.decode(token)
|
||||
token_payload = JWTCodec.decode(token_str)
|
||||
if token_payload:
|
||||
token_key = f"{user_id}-{token_payload.username}-{token}"
|
||||
token_key = f"{user_id}-{token_payload.username}-{token_str}"
|
||||
await redis.delete(token_key)
|
||||
|
||||
# Очищаем список токенов
|
||||
@@ -294,7 +297,7 @@ class SessionManager:
|
||||
return count
|
||||
|
||||
@classmethod
|
||||
async def get_session_data(cls, user_id: str, token: str) -> Optional[Dict[str, Any]]:
|
||||
async def get_session_data(cls, user_id: str, token: str) -> Optional[dict[str, Any]]:
|
||||
"""
|
||||
Получает данные сессии.
|
||||
|
||||
@@ -310,7 +313,7 @@ class SessionManager:
|
||||
session_data = await redis.execute("HGETALL", session_key)
|
||||
return session_data if session_data else None
|
||||
except Exception as e:
|
||||
logger.error(f"[SessionManager.get_session_data] Ошибка: {str(e)}")
|
||||
logger.error(f"[SessionManager.get_session_data] Ошибка: {e!s}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
@@ -336,7 +339,7 @@ class SessionManager:
|
||||
await pipe.execute()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[SessionManager.revoke_session] Ошибка: {str(e)}")
|
||||
logger.error(f"[SessionManager.revoke_session] Ошибка: {e!s}")
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
@@ -362,8 +365,10 @@ class SessionManager:
|
||||
pipe = redis.pipeline()
|
||||
|
||||
# Формируем список ключей для удаления
|
||||
for token in tokens:
|
||||
session_key = cls._make_session_key(user_id, token)
|
||||
# Convert set to list for iteration
|
||||
for token in list(tokens):
|
||||
token_str: str = str(token)
|
||||
session_key = cls._make_session_key(user_id, token_str)
|
||||
await pipe.delete(session_key)
|
||||
|
||||
# Удаляем список сессий
|
||||
@@ -372,11 +377,11 @@ class SessionManager:
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[SessionManager.revoke_all_sessions] Ошибка: {str(e)}")
|
||||
logger.error(f"[SessionManager.revoke_all_sessions] Ошибка: {e!s}")
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
async def refresh_session(cls, user_id: str, old_token: str, device_info: dict = None) -> Optional[str]:
|
||||
async def refresh_session(cls, user_id: int, old_token: str, device_info: Optional[dict] = None) -> Optional[str]:
|
||||
"""
|
||||
Обновляет сессию пользователя, заменяя старый токен новым.
|
||||
|
||||
@@ -389,8 +394,9 @@ class SessionManager:
|
||||
str: Новый токен сессии или None в случае ошибки
|
||||
"""
|
||||
try:
|
||||
user_id_str = str(user_id)
|
||||
# Получаем данные старой сессии
|
||||
old_session_key = cls._make_session_key(user_id, old_token)
|
||||
old_session_key = cls._make_session_key(user_id_str, old_token)
|
||||
old_session_data = await redis.hgetall(old_session_key)
|
||||
|
||||
if not old_session_data:
|
||||
@@ -402,12 +408,12 @@ class SessionManager:
|
||||
device_info = old_session_data.get("device_info")
|
||||
|
||||
# Создаем новую сессию
|
||||
new_token = await cls.create_session(user_id, old_session_data.get("username", ""), device_info)
|
||||
new_token = await cls.create_session(user_id_str, old_session_data.get("username", ""), device_info)
|
||||
|
||||
# Отзываем старую сессию
|
||||
await cls.revoke_session(user_id, old_token)
|
||||
await cls.revoke_session(user_id_str, old_token)
|
||||
|
||||
return new_token
|
||||
except Exception as e:
|
||||
logger.error(f"[SessionManager.refresh_session] Ошибка: {str(e)}")
|
||||
logger.error(f"[SessionManager.refresh_session] Ошибка: {e!s}")
|
||||
return None
|
||||
|
@@ -2,6 +2,8 @@
|
||||
Классы состояния авторизации
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class AuthState:
|
||||
"""
|
||||
@@ -9,15 +11,15 @@ class AuthState:
|
||||
Используется в аутентификационных middleware и функциях.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.logged_in = False
|
||||
self.author_id = None
|
||||
self.token = None
|
||||
self.username = None
|
||||
self.is_admin = False
|
||||
self.is_editor = False
|
||||
self.error = None
|
||||
def __init__(self) -> None:
|
||||
self.logged_in: bool = False
|
||||
self.author_id: Optional[str] = None
|
||||
self.token: Optional[str] = None
|
||||
self.username: Optional[str] = None
|
||||
self.is_admin: bool = False
|
||||
self.is_editor: bool = False
|
||||
self.error: Optional[str] = None
|
||||
|
||||
def __bool__(self):
|
||||
def __bool__(self) -> bool:
|
||||
"""Возвращает True если пользователь авторизован"""
|
||||
return self.logged_in
|
||||
|
@@ -1,436 +1,671 @@
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, Literal, Optional, Union
|
||||
|
||||
from auth.jwtcodec import JWTCodec
|
||||
from auth.validations import AuthInput
|
||||
from services.redis import redis
|
||||
from settings import ONETIME_TOKEN_LIFE_SPAN, SESSION_TOKEN_LIFE_SPAN
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
# Типы токенов
|
||||
TokenType = Literal["session", "verification", "oauth_access", "oauth_refresh"]
|
||||
|
||||
# TTL по умолчанию для разных типов токенов
|
||||
DEFAULT_TTL = {
|
||||
"session": 30 * 24 * 60 * 60, # 30 дней
|
||||
"verification": 3600, # 1 час
|
||||
"oauth_access": 3600, # 1 час
|
||||
"oauth_refresh": 86400 * 30, # 30 дней
|
||||
}
|
||||
|
||||
|
||||
class TokenStorage:
|
||||
"""
|
||||
Класс для работы с хранилищем токенов в Redis
|
||||
Единый менеджер всех типов токенов в системе:
|
||||
- Токены сессий (session)
|
||||
- Токены подтверждения (verification)
|
||||
- OAuth токены (oauth_access, oauth_refresh)
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _make_token_key(user_id: str, username: str, token: str) -> str:
|
||||
def _make_token_key(token_type: TokenType, identifier: str, token: Optional[str] = None) -> str:
|
||||
"""
|
||||
Создает ключ для хранения токена
|
||||
Создает унифицированный ключ для токена
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
username: Имя пользователя
|
||||
token: Токен
|
||||
token_type: Тип токена
|
||||
identifier: Идентификатор (user_id, user_id:provider, etc)
|
||||
token: Сам токен (для session и verification)
|
||||
|
||||
Returns:
|
||||
str: Ключ токена
|
||||
"""
|
||||
# Сохраняем в старом формате для обратной совместимости
|
||||
return f"{user_id}-{username}-{token}"
|
||||
if token_type == "session":
|
||||
return f"session:{token}"
|
||||
if token_type == "verification":
|
||||
return f"verification_token:{token}"
|
||||
if token_type == "oauth_access":
|
||||
return f"oauth_access:{identifier}"
|
||||
if token_type == "oauth_refresh":
|
||||
return f"oauth_refresh:{identifier}"
|
||||
raise ValueError(f"Неизвестный тип токена: {token_type}")
|
||||
|
||||
@staticmethod
|
||||
def _make_session_key(user_id: str, token: str) -> str:
|
||||
"""
|
||||
Создает ключ в новом формате SessionManager
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
token: Токен
|
||||
|
||||
Returns:
|
||||
str: Ключ сессии
|
||||
"""
|
||||
return f"session:{user_id}:{token}"
|
||||
|
||||
@staticmethod
|
||||
def _make_user_sessions_key(user_id: str) -> str:
|
||||
"""
|
||||
Создает ключ для списка сессий пользователя
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
str: Ключ списка сессий
|
||||
"""
|
||||
return f"user_sessions:{user_id}"
|
||||
def _make_user_tokens_key(user_id: str, token_type: TokenType) -> str:
|
||||
"""Создает ключ для списка токенов пользователя"""
|
||||
return f"user_tokens:{user_id}:{token_type}"
|
||||
|
||||
@classmethod
|
||||
async def create_session(cls, user_id: str, username: str, device_info: Optional[Dict[str, str]] = None) -> str:
|
||||
async def create_token(
|
||||
cls,
|
||||
token_type: TokenType,
|
||||
user_id: str,
|
||||
data: Dict[str, Any],
|
||||
ttl: Optional[int] = None,
|
||||
token: Optional[str] = None,
|
||||
provider: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Создает новую сессию для пользователя
|
||||
Универсальный метод создания токена любого типа
|
||||
|
||||
Args:
|
||||
token_type: Тип токена
|
||||
user_id: ID пользователя
|
||||
username: Имя пользователя
|
||||
device_info: Информация об устройстве (опционально)
|
||||
data: Данные токена
|
||||
ttl: Время жизни (по умолчанию из DEFAULT_TTL)
|
||||
token: Существующий токен (для verification)
|
||||
provider: OAuth провайдер (для oauth токенов)
|
||||
|
||||
Returns:
|
||||
str: Токен сессии
|
||||
str: Токен или ключ токена
|
||||
"""
|
||||
logger.debug(f"[TokenStorage.create_session] Начало создания сессии для пользователя {user_id}")
|
||||
if ttl is None:
|
||||
ttl = DEFAULT_TTL[token_type]
|
||||
|
||||
# Генерируем JWT токен с явным указанием времени истечения
|
||||
expiration_date = datetime.now(tz=timezone.utc) + timedelta(days=30)
|
||||
token = JWTCodec.encode({"id": user_id, "email": username}, exp=expiration_date)
|
||||
logger.debug(f"[TokenStorage.create_session] Создан JWT токен длиной {len(token)}")
|
||||
# Подготавливаем данные токена
|
||||
token_data = {"user_id": user_id, "token_type": token_type, "created_at": int(time.time()), **data}
|
||||
|
||||
# Формируем ключи для Redis
|
||||
token_key = cls._make_token_key(user_id, username, token)
|
||||
logger.debug(f"[TokenStorage.create_session] Сформированы ключи: token_key={token_key}")
|
||||
if token_type == "session":
|
||||
# Генерируем новый токен сессии
|
||||
session_token = cls.generate_token()
|
||||
token_key = cls._make_token_key(token_type, user_id, session_token)
|
||||
|
||||
# Формируем ключи в новом формате SessionManager для совместимости
|
||||
session_key = cls._make_session_key(user_id, token)
|
||||
user_sessions_key = cls._make_user_sessions_key(user_id)
|
||||
# Сохраняем данные сессии
|
||||
for field, value in token_data.items():
|
||||
await redis.hset(token_key, field, str(value))
|
||||
await redis.expire(token_key, ttl)
|
||||
|
||||
# Готовим данные для сохранения
|
||||
token_data = {
|
||||
"user_id": user_id,
|
||||
"username": username,
|
||||
"created_at": time.time(),
|
||||
"expires_at": time.time() + 30 * 24 * 60 * 60, # 30 дней
|
||||
}
|
||||
# Добавляем в список сессий пользователя
|
||||
user_tokens_key = cls._make_user_tokens_key(user_id, token_type)
|
||||
await redis.sadd(user_tokens_key, session_token)
|
||||
await redis.expire(user_tokens_key, ttl)
|
||||
|
||||
if device_info:
|
||||
token_data.update(device_info)
|
||||
logger.info(f"Создан токен сессии для пользователя {user_id}")
|
||||
return session_token
|
||||
|
||||
logger.debug(f"[TokenStorage.create_session] Сформированы данные сессии: {token_data}")
|
||||
if token_type == "verification":
|
||||
# Используем переданный токен или генерируем новый
|
||||
verification_token = token or secrets.token_urlsafe(32)
|
||||
token_key = cls._make_token_key(token_type, user_id, verification_token)
|
||||
|
||||
# Сохраняем в Redis старый формат
|
||||
pipeline = redis.pipeline()
|
||||
pipeline.hset(token_key, mapping=token_data)
|
||||
pipeline.expire(token_key, 30 * 24 * 60 * 60) # 30 дней
|
||||
# Отменяем предыдущие токены того же типа
|
||||
verification_type = data.get("verification_type", "unknown")
|
||||
await cls._cancel_verification_tokens(user_id, verification_type)
|
||||
|
||||
# Также сохраняем в новом формате SessionManager для обеспечения совместимости
|
||||
pipeline.hset(session_key, mapping=token_data)
|
||||
pipeline.expire(session_key, 30 * 24 * 60 * 60) # 30 дней
|
||||
pipeline.sadd(user_sessions_key, token)
|
||||
pipeline.expire(user_sessions_key, 30 * 24 * 60 * 60) # 30 дней
|
||||
# Сохраняем токен подтверждения
|
||||
await redis.serialize_and_set(token_key, token_data, ex=ttl)
|
||||
|
||||
results = await pipeline.execute()
|
||||
logger.info(f"[TokenStorage.create_session] Сессия успешно создана для пользователя {user_id}")
|
||||
logger.info(f"Создан токен подтверждения {verification_type} для пользователя {user_id}")
|
||||
return verification_token
|
||||
|
||||
return token
|
||||
if token_type in ["oauth_access", "oauth_refresh"]:
|
||||
if not provider:
|
||||
raise ValueError("OAuth токены требуют указания провайдера")
|
||||
|
||||
identifier = f"{user_id}:{provider}"
|
||||
token_key = cls._make_token_key(token_type, identifier)
|
||||
|
||||
# Добавляем провайдера в данные
|
||||
token_data["provider"] = provider
|
||||
|
||||
# Сохраняем OAuth токен
|
||||
await redis.serialize_and_set(token_key, token_data, ex=ttl)
|
||||
|
||||
logger.info(f"Создан {token_type} токен для пользователя {user_id}, провайдер {provider}")
|
||||
return token_key
|
||||
|
||||
raise ValueError(f"Неподдерживаемый тип токена: {token_type}")
|
||||
|
||||
@classmethod
|
||||
async def exists(cls, token_key: str) -> bool:
|
||||
async def get_token_data(
|
||||
cls,
|
||||
token_type: TokenType,
|
||||
token_or_identifier: str,
|
||||
user_id: Optional[str] = None,
|
||||
provider: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Проверяет существование токена по ключу
|
||||
Универсальный метод получения данных токена
|
||||
|
||||
Args:
|
||||
token_key: Ключ токена
|
||||
token_type: Тип токена
|
||||
token_or_identifier: Токен или идентификатор
|
||||
user_id: ID пользователя (для OAuth)
|
||||
provider: OAuth провайдер
|
||||
|
||||
Returns:
|
||||
bool: True, если токен существует
|
||||
Dict с данными токена или None
|
||||
"""
|
||||
exists = await redis.exists(token_key)
|
||||
return bool(exists)
|
||||
try:
|
||||
if token_type == "session":
|
||||
token_key = cls._make_token_key(token_type, "", token_or_identifier)
|
||||
token_data = await redis.hgetall(token_key)
|
||||
if token_data:
|
||||
# Обновляем время последней активности
|
||||
await redis.hset(token_key, "last_activity", str(int(time.time())))
|
||||
return {k: v for k, v in token_data.items()}
|
||||
return None
|
||||
|
||||
if token_type == "verification":
|
||||
token_key = cls._make_token_key(token_type, "", token_or_identifier)
|
||||
return await redis.get_and_deserialize(token_key)
|
||||
|
||||
if token_type in ["oauth_access", "oauth_refresh"]:
|
||||
if not user_id or not provider:
|
||||
raise ValueError("OAuth токены требуют user_id и provider")
|
||||
|
||||
identifier = f"{user_id}:{provider}"
|
||||
token_key = cls._make_token_key(token_type, identifier)
|
||||
token_data = await redis.get_and_deserialize(token_key)
|
||||
|
||||
if token_data:
|
||||
# Добавляем информацию о TTL
|
||||
ttl = await redis.execute("TTL", token_key)
|
||||
if ttl > 0:
|
||||
token_data["ttl_remaining"] = ttl
|
||||
return token_data
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения токена {token_type}: {e}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def validate_token(cls, token: str) -> Tuple[bool, Optional[Dict[str, Any]]]:
|
||||
async def validate_token(
|
||||
cls, token: str, token_type: Optional[TokenType] = None
|
||||
) -> tuple[bool, Optional[dict[str, Any]]]:
|
||||
"""
|
||||
Проверяет валидность токена
|
||||
|
||||
Args:
|
||||
token: JWT токен
|
||||
token: Токен для проверки
|
||||
token_type: Тип токена (если не указан - определяется автоматически)
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Dict[str, Any]]: (Валиден ли токен, данные токена)
|
||||
Tuple[bool, Dict]: (Валиден ли токен, данные токена)
|
||||
"""
|
||||
try:
|
||||
# Декодируем JWT токен
|
||||
payload = JWTCodec.decode(token)
|
||||
if not payload:
|
||||
logger.warning(f"[TokenStorage.validate_token] Токен не валиден (не удалось декодировать)")
|
||||
return False, None
|
||||
# Для JWT токенов (сессии) - декодируем
|
||||
if not token_type or token_type == "session":
|
||||
payload = JWTCodec.decode(token)
|
||||
if payload:
|
||||
user_id = payload.user_id
|
||||
username = payload.username
|
||||
|
||||
user_id = payload.user_id
|
||||
username = payload.username
|
||||
# Проверяем в разных форматах для совместимости
|
||||
old_token_key = f"{user_id}-{username}-{token}"
|
||||
new_token_key = cls._make_token_key("session", user_id, token)
|
||||
|
||||
# Формируем ключи для Redis в обоих форматах
|
||||
token_key = cls._make_token_key(user_id, username, token)
|
||||
session_key = cls._make_session_key(user_id, token)
|
||||
old_exists = await redis.exists(old_token_key)
|
||||
new_exists = await redis.exists(new_token_key)
|
||||
|
||||
# Проверяем в обоих форматах для совместимости
|
||||
old_exists = await redis.exists(token_key)
|
||||
new_exists = await redis.exists(session_key)
|
||||
if old_exists or new_exists:
|
||||
# Получаем данные из актуального хранилища
|
||||
if new_exists:
|
||||
token_data = await redis.hgetall(new_token_key)
|
||||
else:
|
||||
token_data = await redis.hgetall(old_token_key)
|
||||
# Миграция в новый формат
|
||||
if not new_exists:
|
||||
for field, value in token_data.items():
|
||||
await redis.hset(new_token_key, field, value)
|
||||
await redis.expire(new_token_key, DEFAULT_TTL["session"])
|
||||
|
||||
if old_exists or new_exists:
|
||||
logger.info(f"[TokenStorage.validate_token] Токен валиден для пользователя {user_id}")
|
||||
return True, {k: v for k, v in token_data.items()}
|
||||
|
||||
# Получаем данные токена из актуального хранилища
|
||||
if new_exists:
|
||||
token_data = await redis.hgetall(session_key)
|
||||
else:
|
||||
token_data = await redis.hgetall(token_key)
|
||||
# Для токенов подтверждения - прямая проверка
|
||||
if not token_type or token_type == "verification":
|
||||
token_key = cls._make_token_key("verification", "", token)
|
||||
token_data = await redis.get_and_deserialize(token_key)
|
||||
if token_data:
|
||||
return True, token_data
|
||||
|
||||
# Если найден только в старом формате, создаем запись в новом формате
|
||||
if not new_exists:
|
||||
logger.info(f"[TokenStorage.validate_token] Миграция токена в новый формат: {session_key}")
|
||||
await redis.hset(session_key, mapping=token_data)
|
||||
await redis.expire(session_key, 30 * 24 * 60 * 60)
|
||||
await redis.sadd(cls._make_user_sessions_key(user_id), token)
|
||||
|
||||
return True, token_data
|
||||
else:
|
||||
logger.warning(f"[TokenStorage.validate_token] Токен не найден в Redis: {token_key}")
|
||||
return False, None
|
||||
return False, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[TokenStorage.validate_token] Ошибка при проверке токена: {e}")
|
||||
logger.error(f"Ошибка валидации токена: {e}")
|
||||
return False, None
|
||||
|
||||
@classmethod
|
||||
async def invalidate_token(cls, token: str) -> bool:
|
||||
async def revoke_token(
|
||||
cls,
|
||||
token_type: TokenType,
|
||||
token_or_identifier: str,
|
||||
user_id: Optional[str] = None,
|
||||
provider: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Инвалидирует токен
|
||||
Универсальный метод отзыва токена
|
||||
|
||||
Args:
|
||||
token: JWT токен
|
||||
token_type: Тип токена
|
||||
token_or_identifier: Токен или идентификатор
|
||||
user_id: ID пользователя
|
||||
provider: OAuth провайдер
|
||||
|
||||
Returns:
|
||||
bool: True, если токен успешно инвалидирован
|
||||
bool: Успех операции
|
||||
"""
|
||||
try:
|
||||
# Декодируем JWT токен
|
||||
payload = JWTCodec.decode(token)
|
||||
if not payload:
|
||||
logger.warning(f"[TokenStorage.invalidate_token] Токен не валиден (не удалось декодировать)")
|
||||
return False
|
||||
if token_type == "session":
|
||||
# Декодируем JWT для получения данных
|
||||
payload = JWTCodec.decode(token_or_identifier)
|
||||
if payload:
|
||||
user_id = payload.user_id
|
||||
username = payload.username
|
||||
|
||||
user_id = payload.user_id
|
||||
username = payload.username
|
||||
# Удаляем в обоих форматах
|
||||
old_token_key = f"{user_id}-{username}-{token_or_identifier}"
|
||||
new_token_key = cls._make_token_key(token_type, user_id, token_or_identifier)
|
||||
user_tokens_key = cls._make_user_tokens_key(user_id, token_type)
|
||||
|
||||
# Формируем ключи для Redis в обоих форматах
|
||||
token_key = cls._make_token_key(user_id, username, token)
|
||||
session_key = cls._make_session_key(user_id, token)
|
||||
user_sessions_key = cls._make_user_sessions_key(user_id)
|
||||
result1 = await redis.delete(old_token_key)
|
||||
result2 = await redis.delete(new_token_key)
|
||||
result3 = await redis.srem(user_tokens_key, token_or_identifier)
|
||||
|
||||
# Удаляем токен из Redis в обоих форматах
|
||||
pipeline = redis.pipeline()
|
||||
pipeline.delete(token_key)
|
||||
pipeline.delete(session_key)
|
||||
pipeline.srem(user_sessions_key, token)
|
||||
results = await pipeline.execute()
|
||||
return result1 > 0 or result2 > 0 or result3 > 0
|
||||
|
||||
success = any(results)
|
||||
if success:
|
||||
logger.info(f"[TokenStorage.invalidate_token] Токен успешно инвалидирован для пользователя {user_id}")
|
||||
else:
|
||||
logger.warning(f"[TokenStorage.invalidate_token] Токен не найден: {token_key}")
|
||||
elif token_type == "verification":
|
||||
token_key = cls._make_token_key(token_type, "", token_or_identifier)
|
||||
result = await redis.delete(token_key)
|
||||
return result > 0
|
||||
|
||||
return success
|
||||
elif token_type in ["oauth_access", "oauth_refresh"]:
|
||||
if not user_id or not provider:
|
||||
raise ValueError("OAuth токены требуют user_id и provider")
|
||||
|
||||
identifier = f"{user_id}:{provider}"
|
||||
token_key = cls._make_token_key(token_type, identifier)
|
||||
result = await redis.delete(token_key)
|
||||
return result > 0
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[TokenStorage.invalidate_token] Ошибка при инвалидации токена: {e}")
|
||||
logger.error(f"Ошибка отзыва токена {token_type}: {e}")
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
async def invalidate_all_tokens(cls, user_id: str) -> int:
|
||||
async def revoke_user_tokens(cls, user_id: str, token_type: Optional[TokenType] = None) -> int:
|
||||
"""
|
||||
Инвалидирует все токены пользователя
|
||||
Отзывает все токены пользователя определенного типа или все
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
token_type: Тип токенов для отзыва (None = все типы)
|
||||
|
||||
Returns:
|
||||
int: Количество инвалидированных токенов
|
||||
int: Количество отозванных токенов
|
||||
"""
|
||||
count = 0
|
||||
|
||||
try:
|
||||
# Получаем список сессий пользователя
|
||||
user_sessions_key = cls._make_user_sessions_key(user_id)
|
||||
tokens = await redis.smembers(user_sessions_key)
|
||||
types_to_revoke = (
|
||||
[token_type] if token_type else ["session", "verification", "oauth_access", "oauth_refresh"]
|
||||
)
|
||||
|
||||
if not tokens:
|
||||
logger.warning(f"[TokenStorage.invalidate_all_tokens] Нет активных сессий пользователя {user_id}")
|
||||
return 0
|
||||
for t_type in types_to_revoke:
|
||||
if t_type == "session":
|
||||
user_tokens_key = cls._make_user_tokens_key(user_id, t_type)
|
||||
tokens = await redis.smembers(user_tokens_key)
|
||||
|
||||
count = 0
|
||||
for token in tokens:
|
||||
# Декодируем JWT токен
|
||||
try:
|
||||
payload = JWTCodec.decode(token)
|
||||
if payload:
|
||||
username = payload.username
|
||||
for token in tokens:
|
||||
token_str = token.decode("utf-8") if isinstance(token, bytes) else str(token)
|
||||
success = await cls.revoke_token(t_type, token_str, user_id)
|
||||
if success:
|
||||
count += 1
|
||||
|
||||
# Формируем ключи для Redis
|
||||
token_key = cls._make_token_key(user_id, username, token)
|
||||
session_key = cls._make_session_key(user_id, token)
|
||||
await redis.delete(user_tokens_key)
|
||||
|
||||
# Удаляем токен из Redis
|
||||
pipeline = redis.pipeline()
|
||||
pipeline.delete(token_key)
|
||||
pipeline.delete(session_key)
|
||||
results = await pipeline.execute()
|
||||
elif t_type == "verification":
|
||||
# Ищем все токены подтверждения пользователя
|
||||
pattern = "verification_token:*"
|
||||
keys = await redis.keys(pattern)
|
||||
|
||||
for key in keys:
|
||||
token_data = await redis.get_and_deserialize(key)
|
||||
if token_data and token_data.get("user_id") == user_id:
|
||||
await redis.delete(key)
|
||||
count += 1
|
||||
|
||||
elif t_type in ["oauth_access", "oauth_refresh"]:
|
||||
# Ищем OAuth токены по паттерну
|
||||
pattern = f"{t_type}:{user_id}:*"
|
||||
keys = await redis.keys(pattern)
|
||||
|
||||
for key in keys:
|
||||
await redis.delete(key)
|
||||
count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"[TokenStorage.invalidate_all_tokens] Ошибка при обработке токена: {e}")
|
||||
continue
|
||||
|
||||
# Удаляем список сессий пользователя
|
||||
await redis.delete(user_sessions_key)
|
||||
|
||||
logger.info(f"[TokenStorage.invalidate_all_tokens] Инвалидировано {count} токенов пользователя {user_id}")
|
||||
logger.info(f"Отозвано {count} токенов для пользователя {user_id}")
|
||||
return count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[TokenStorage.invalidate_all_tokens] Ошибка при инвалидации всех токенов: {e}")
|
||||
return 0
|
||||
logger.error(f"Ошибка отзыва токенов пользователя: {e}")
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def _cancel_verification_tokens(user_id: str, verification_type: str) -> None:
|
||||
"""Отменяет предыдущие токены подтверждения определенного типа"""
|
||||
try:
|
||||
pattern = "verification_token:*"
|
||||
keys = await redis.keys(pattern)
|
||||
|
||||
for key in keys:
|
||||
token_data = await redis.get_and_deserialize(key)
|
||||
if (
|
||||
token_data
|
||||
and token_data.get("user_id") == user_id
|
||||
and token_data.get("verification_type") == verification_type
|
||||
):
|
||||
await redis.delete(key)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отмены токенов подтверждения: {e}")
|
||||
|
||||
# === УДОБНЫЕ МЕТОДЫ ДЛЯ СЕССИЙ ===
|
||||
|
||||
@classmethod
|
||||
async def create_session(
|
||||
cls,
|
||||
user_id: str,
|
||||
auth_data: Optional[dict] = None,
|
||||
username: Optional[str] = None,
|
||||
device_info: Optional[dict] = None,
|
||||
) -> str:
|
||||
"""Создает токен сессии"""
|
||||
session_data = {}
|
||||
|
||||
if auth_data:
|
||||
session_data["auth_data"] = json.dumps(auth_data)
|
||||
if username:
|
||||
session_data["username"] = username
|
||||
if device_info:
|
||||
session_data["device_info"] = json.dumps(device_info)
|
||||
|
||||
return await cls.create_token("session", user_id, session_data)
|
||||
|
||||
@classmethod
|
||||
async def get_session_data(cls, token: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Получает данные сессии
|
||||
|
||||
Args:
|
||||
token: JWT токен
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Данные сессии или None
|
||||
"""
|
||||
valid, data = await cls.validate_token(token)
|
||||
"""Получает данные сессии"""
|
||||
valid, data = await cls.validate_token(token, "session")
|
||||
return data if valid else None
|
||||
|
||||
# === УДОБНЫЕ МЕТОДЫ ДЛЯ ТОКЕНОВ ПОДТВЕРЖДЕНИЯ ===
|
||||
|
||||
@classmethod
|
||||
async def create_verification_token(
|
||||
cls,
|
||||
user_id: str,
|
||||
verification_type: str,
|
||||
data: Dict[str, Any],
|
||||
ttl: Optional[int] = None,
|
||||
) -> str:
|
||||
"""Создает токен подтверждения"""
|
||||
token_data = {"verification_type": verification_type, **data}
|
||||
|
||||
# TTL по типу подтверждения
|
||||
if ttl is None:
|
||||
verification_ttls = {
|
||||
"email_change": 3600, # 1 час
|
||||
"phone_change": 600, # 10 минут
|
||||
"password_reset": 1800, # 30 минут
|
||||
}
|
||||
ttl = verification_ttls.get(verification_type, 3600)
|
||||
|
||||
return await cls.create_token("verification", user_id, token_data, ttl)
|
||||
|
||||
@classmethod
|
||||
async def confirm_verification_token(cls, token_str: str) -> Optional[Dict[str, Any]]:
|
||||
"""Подтверждает и использует токен подтверждения (одноразовый)"""
|
||||
token_data = await cls.get_token_data("verification", token_str)
|
||||
if token_data:
|
||||
# Удаляем токен после использования
|
||||
await cls.revoke_token("verification", token_str)
|
||||
return token_data
|
||||
return None
|
||||
|
||||
# === УДОБНЫЕ МЕТОДЫ ДЛЯ OAUTH ТОКЕНОВ ===
|
||||
|
||||
@classmethod
|
||||
async def store_oauth_tokens(
|
||||
cls,
|
||||
user_id: str,
|
||||
provider: str,
|
||||
access_token: str,
|
||||
refresh_token: Optional[str] = None,
|
||||
expires_in: Optional[int] = None,
|
||||
additional_data: Optional[Dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
"""Сохраняет OAuth токены"""
|
||||
try:
|
||||
# Сохраняем access token
|
||||
access_data = {
|
||||
"token": access_token,
|
||||
"provider": provider,
|
||||
"expires_in": expires_in,
|
||||
**(additional_data or {}),
|
||||
}
|
||||
|
||||
access_ttl = expires_in if expires_in else DEFAULT_TTL["oauth_access"]
|
||||
await cls.create_token("oauth_access", user_id, access_data, access_ttl, provider=provider)
|
||||
|
||||
# Сохраняем refresh token если есть
|
||||
if refresh_token:
|
||||
refresh_data = {
|
||||
"token": refresh_token,
|
||||
"provider": provider,
|
||||
}
|
||||
await cls.create_token("oauth_refresh", user_id, refresh_data, provider=provider)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка сохранения OAuth токенов: {e}")
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
async def get_oauth_token(cls, user_id: int, provider: str, token_type: str = "access") -> Optional[Dict[str, Any]]:
|
||||
"""Получает OAuth токен"""
|
||||
oauth_type = f"oauth_{token_type}"
|
||||
if oauth_type in ["oauth_access", "oauth_refresh"]:
|
||||
return await cls.get_token_data(oauth_type, "", user_id, provider) # type: ignore[arg-type]
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def revoke_oauth_tokens(cls, user_id: str, provider: str) -> bool:
|
||||
"""Удаляет все OAuth токены для провайдера"""
|
||||
try:
|
||||
result1 = await cls.revoke_token("oauth_access", "", user_id, provider)
|
||||
result2 = await cls.revoke_token("oauth_refresh", "", user_id, provider)
|
||||
return result1 or result2
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления OAuth токенов: {e}")
|
||||
return False
|
||||
|
||||
# === ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ ===
|
||||
|
||||
@staticmethod
|
||||
def generate_token() -> str:
|
||||
"""Генерирует криптографически стойкий токен"""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
@staticmethod
|
||||
async def cleanup_expired_tokens() -> int:
|
||||
"""Очищает истекшие токены (Redis делает это автоматически)"""
|
||||
# Redis автоматически удаляет истекшие ключи
|
||||
# Здесь можем очистить связанные структуры данных
|
||||
try:
|
||||
user_session_keys = await redis.keys("user_tokens:*:session")
|
||||
cleaned_count = 0
|
||||
|
||||
for user_tokens_key in user_session_keys:
|
||||
tokens = await redis.smembers(user_tokens_key)
|
||||
active_tokens = []
|
||||
|
||||
for token in tokens:
|
||||
token_str = token.decode("utf-8") if isinstance(token, bytes) else str(token)
|
||||
session_key = f"session:{token_str}"
|
||||
exists = await redis.exists(session_key)
|
||||
if exists:
|
||||
active_tokens.append(token_str)
|
||||
else:
|
||||
cleaned_count += 1
|
||||
|
||||
# Обновляем список активных токенов
|
||||
if active_tokens:
|
||||
await redis.delete(user_tokens_key)
|
||||
for token in active_tokens:
|
||||
await redis.sadd(user_tokens_key, token)
|
||||
else:
|
||||
await redis.delete(user_tokens_key)
|
||||
|
||||
if cleaned_count > 0:
|
||||
logger.info(f"Очищено {cleaned_count} ссылок на истекшие токены")
|
||||
|
||||
return cleaned_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка очистки токенов: {e}")
|
||||
return 0
|
||||
|
||||
# === ОБРАТНАЯ СОВМЕСТИМОСТЬ ===
|
||||
|
||||
@staticmethod
|
||||
async def get(token_key: str) -> Optional[str]:
|
||||
"""
|
||||
Получает токен из хранилища.
|
||||
|
||||
Args:
|
||||
token_key: Ключ токена
|
||||
|
||||
Returns:
|
||||
str или None, если токен не найден
|
||||
"""
|
||||
logger.debug(f"[tokenstorage.get] Запрос токена: {token_key}")
|
||||
return await redis.get(token_key)
|
||||
"""Обратная совместимость - получение токена по ключу"""
|
||||
result = await redis.get(token_key)
|
||||
if isinstance(result, bytes):
|
||||
return result.decode("utf-8")
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def exists(token_key: str) -> bool:
|
||||
"""
|
||||
Проверяет наличие токена в хранилище.
|
||||
|
||||
Args:
|
||||
token_key: Ключ токена
|
||||
|
||||
Returns:
|
||||
bool: True, если токен существует
|
||||
"""
|
||||
return bool(await redis.execute("EXISTS", token_key))
|
||||
|
||||
@staticmethod
|
||||
async def save_token(token_key: str, data: Dict[str, Any], life_span: int) -> bool:
|
||||
"""
|
||||
Сохраняет токен в хранилище с указанным временем жизни.
|
||||
|
||||
Args:
|
||||
token_key: Ключ токена
|
||||
data: Данные токена
|
||||
life_span: Время жизни токена в секундах
|
||||
|
||||
Returns:
|
||||
bool: True, если токен успешно сохранен
|
||||
"""
|
||||
async def save_token(token_key: str, token_data: Dict[str, Any], life_span: int = 3600) -> bool:
|
||||
"""Обратная совместимость - сохранение токена"""
|
||||
try:
|
||||
# Если данные не строка, преобразуем их в JSON
|
||||
value = json.dumps(data) if isinstance(data, dict) else data
|
||||
|
||||
# Сохраняем токен и устанавливаем время жизни
|
||||
await redis.set(token_key, value, ex=life_span)
|
||||
|
||||
return True
|
||||
return await redis.serialize_and_set(token_key, token_data, ex=life_span)
|
||||
except Exception as e:
|
||||
logger.error(f"[tokenstorage.save_token] Ошибка сохранения токена: {str(e)}")
|
||||
logger.error(f"Ошибка сохранения токена {token_key}: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def create_onetime(user: AuthInput) -> str:
|
||||
"""
|
||||
Создает одноразовый токен для пользователя.
|
||||
|
||||
Args:
|
||||
user: Объект пользователя
|
||||
|
||||
Returns:
|
||||
str: Сгенерированный токен
|
||||
"""
|
||||
life_span = ONETIME_TOKEN_LIFE_SPAN
|
||||
exp = datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)
|
||||
one_time_token = JWTCodec.encode(user, exp)
|
||||
|
||||
# Сохраняем токен в Redis
|
||||
token_key = f"{user.id}-{user.username}-{one_time_token}"
|
||||
await TokenStorage.save_token(token_key, "TRUE", life_span)
|
||||
|
||||
return one_time_token
|
||||
async def get_token(token_key: str) -> Optional[Dict[str, Any]]:
|
||||
"""Обратная совместимость - получение данных токена"""
|
||||
try:
|
||||
return await redis.get_and_deserialize(token_key)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения токена {token_key}: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def revoke(token: str) -> bool:
|
||||
"""
|
||||
Отзывает токен.
|
||||
|
||||
Args:
|
||||
token: Токен для отзыва
|
||||
|
||||
Returns:
|
||||
bool: True, если токен успешно отозван
|
||||
"""
|
||||
async def delete_token(token_key: str) -> bool:
|
||||
"""Обратная совместимость - удаление токена"""
|
||||
try:
|
||||
logger.debug("[tokenstorage.revoke] Отзыв токена")
|
||||
|
||||
# Декодируем токен
|
||||
payload = JWTCodec.decode(token)
|
||||
if not payload:
|
||||
logger.warning("[tokenstorage.revoke] Невозможно декодировать токен")
|
||||
return False
|
||||
|
||||
# Формируем ключи
|
||||
token_key = f"{payload.user_id}-{payload.username}-{token}"
|
||||
user_sessions_key = f"user_sessions:{payload.user_id}"
|
||||
|
||||
# Удаляем токен и запись из списка сессий пользователя
|
||||
pipe = redis.pipeline()
|
||||
await pipe.delete(token_key)
|
||||
await pipe.srem(user_sessions_key, token)
|
||||
await pipe.execute()
|
||||
|
||||
return True
|
||||
result = await redis.delete(token_key)
|
||||
return result > 0
|
||||
except Exception as e:
|
||||
logger.error(f"[tokenstorage.revoke] Ошибка отзыва токена: {str(e)}")
|
||||
logger.error(f"Ошибка удаления токена {token_key}: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def revoke_all(user: AuthInput) -> bool:
|
||||
"""
|
||||
Отзывает все токены пользователя.
|
||||
# Остальные методы для обратной совместимости...
|
||||
async def exists(self, token_key: str) -> bool:
|
||||
"""Совместимость - проверка существования"""
|
||||
return bool(await redis.exists(token_key))
|
||||
|
||||
Args:
|
||||
user: Объект пользователя
|
||||
async def invalidate_token(self, token: str) -> bool:
|
||||
"""Совместимость - инвалидация токена"""
|
||||
return await self.revoke_token("session", token)
|
||||
|
||||
Returns:
|
||||
bool: True, если все токены успешно отозваны
|
||||
"""
|
||||
async def invalidate_all_tokens(self, user_id: str) -> int:
|
||||
"""Совместимость - инвалидация всех токенов"""
|
||||
return await self.revoke_user_tokens(user_id)
|
||||
|
||||
def generate_session_token(self) -> str:
|
||||
"""Совместимость - генерация токена сессии"""
|
||||
return self.generate_token()
|
||||
|
||||
async def get_session(self, session_token: str) -> Optional[Dict[str, Any]]:
|
||||
"""Совместимость - получение сессии"""
|
||||
return await self.get_session_data(session_token)
|
||||
|
||||
async def revoke_session(self, session_token: str) -> bool:
|
||||
"""Совместимость - отзыв сессии"""
|
||||
return await self.revoke_token("session", session_token)
|
||||
|
||||
async def revoke_all_user_sessions(self, user_id: Union[int, str]) -> bool:
|
||||
"""Совместимость - отзыв всех сессий"""
|
||||
count = await self.revoke_user_tokens(str(user_id), "session")
|
||||
return count > 0
|
||||
|
||||
async def get_user_sessions(self, user_id: Union[int, str]) -> list[Dict[str, Any]]:
|
||||
"""Совместимость - получение сессий пользователя"""
|
||||
try:
|
||||
# Формируем ключи
|
||||
user_sessions_key = f"user_sessions:{user.id}"
|
||||
user_tokens_key = f"user_tokens:{user_id}:session"
|
||||
tokens = await redis.smembers(user_tokens_key)
|
||||
|
||||
# Получаем все токены пользователя
|
||||
tokens = await redis.smembers(user_sessions_key)
|
||||
if not tokens:
|
||||
return True
|
||||
sessions = []
|
||||
for token in tokens:
|
||||
token_str = token.decode("utf-8") if isinstance(token, bytes) else str(token)
|
||||
session_data = await self.get_session_data(token_str)
|
||||
if session_data:
|
||||
session_data["token"] = token_str
|
||||
sessions.append(session_data)
|
||||
|
||||
# Формируем список ключей для удаления
|
||||
keys_to_delete = [f"{user.id}-{user.username}-{token}" for token in tokens]
|
||||
keys_to_delete.append(user_sessions_key)
|
||||
return sessions
|
||||
|
||||
# Удаляем все токены и список сессий
|
||||
await redis.delete(*keys_to_delete)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[tokenstorage.revoke_all] Ошибка отзыва всех токенов: {str(e)}")
|
||||
logger.error(f"Ошибка получения сессий пользователя: {e}")
|
||||
return []
|
||||
|
||||
async def revoke_all_tokens_for_user(self, user: AuthInput) -> bool:
|
||||
"""Совместимость - отзыв всех токенов пользователя"""
|
||||
user_id = getattr(user, "id", 0) or 0
|
||||
count = await self.revoke_user_tokens(str(user_id))
|
||||
return count > 0
|
||||
|
||||
async def get_one_time_token_value(self, token_key: str) -> Optional[str]:
|
||||
"""Совместимость - одноразовые токены"""
|
||||
token_data = await self.get_token(token_key)
|
||||
if token_data and token_data.get("valid"):
|
||||
return "TRUE"
|
||||
return None
|
||||
|
||||
async def save_one_time_token(self, user: AuthInput, one_time_token: str, life_span: int = 300) -> bool:
|
||||
"""Совместимость - сохранение одноразового токена"""
|
||||
user_id = getattr(user, "id", 0) or 0
|
||||
token_key = f"{user_id}-{user.username}-{one_time_token}"
|
||||
token_data = {"valid": True, "user_id": user_id, "username": user.username}
|
||||
return await self.save_token(token_key, token_data, life_span)
|
||||
|
||||
async def extend_token_lifetime(self, token_key: str, additional_seconds: int = 3600) -> bool:
|
||||
"""Совместимость - продление времени жизни"""
|
||||
token_data = await self.get_token(token_key)
|
||||
if not token_data:
|
||||
return False
|
||||
return await self.save_token(token_key, token_data, additional_seconds)
|
||||
|
||||
async def cleanup_expired_sessions(self) -> None:
|
||||
"""Совместимость - очистка сессий"""
|
||||
await self.cleanup_expired_tokens()
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Union
|
||||
from typing import Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
@@ -19,7 +19,8 @@ class AuthInput(BaseModel):
|
||||
@classmethod
|
||||
def validate_user_id(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("user_id cannot be empty")
|
||||
msg = "user_id cannot be empty"
|
||||
raise ValueError(msg)
|
||||
return v
|
||||
|
||||
|
||||
@@ -35,7 +36,8 @@ class UserRegistrationInput(BaseModel):
|
||||
def validate_email(cls, v: str) -> str:
|
||||
"""Validate email format"""
|
||||
if not re.match(EMAIL_PATTERN, v):
|
||||
raise ValueError("Invalid email format")
|
||||
msg = "Invalid email format"
|
||||
raise ValueError(msg)
|
||||
return v.lower()
|
||||
|
||||
@field_validator("password")
|
||||
@@ -43,13 +45,17 @@ class UserRegistrationInput(BaseModel):
|
||||
def validate_password_strength(cls, v: str) -> str:
|
||||
"""Validate password meets security requirements"""
|
||||
if not any(c.isupper() for c in v):
|
||||
raise ValueError("Password must contain at least one uppercase letter")
|
||||
msg = "Password must contain at least one uppercase letter"
|
||||
raise ValueError(msg)
|
||||
if not any(c.islower() for c in v):
|
||||
raise ValueError("Password must contain at least one lowercase letter")
|
||||
msg = "Password must contain at least one lowercase letter"
|
||||
raise ValueError(msg)
|
||||
if not any(c.isdigit() for c in v):
|
||||
raise ValueError("Password must contain at least one number")
|
||||
msg = "Password must contain at least one number"
|
||||
raise ValueError(msg)
|
||||
if not any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in v):
|
||||
raise ValueError("Password must contain at least one special character")
|
||||
msg = "Password must contain at least one special character"
|
||||
raise ValueError(msg)
|
||||
return v
|
||||
|
||||
|
||||
@@ -63,7 +69,8 @@ class UserLoginInput(BaseModel):
|
||||
@classmethod
|
||||
def validate_email(cls, v: str) -> str:
|
||||
if not re.match(EMAIL_PATTERN, v):
|
||||
raise ValueError("Invalid email format")
|
||||
msg = "Invalid email format"
|
||||
raise ValueError(msg)
|
||||
return v.lower()
|
||||
|
||||
|
||||
@@ -74,7 +81,7 @@ class TokenPayload(BaseModel):
|
||||
username: str
|
||||
exp: datetime
|
||||
iat: datetime
|
||||
scopes: Optional[List[str]] = []
|
||||
scopes: Optional[list[str]] = []
|
||||
|
||||
|
||||
class OAuthInput(BaseModel):
|
||||
@@ -89,7 +96,8 @@ class OAuthInput(BaseModel):
|
||||
def validate_provider(cls, v: str) -> str:
|
||||
valid_providers = ["google", "github", "facebook"]
|
||||
if v.lower() not in valid_providers:
|
||||
raise ValueError(f"Provider must be one of: {', '.join(valid_providers)}")
|
||||
msg = f"Provider must be one of: {', '.join(valid_providers)}"
|
||||
raise ValueError(msg)
|
||||
return v.lower()
|
||||
|
||||
|
||||
@@ -99,18 +107,20 @@ class AuthResponse(BaseModel):
|
||||
success: bool
|
||||
token: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
user: Optional[Dict[str, Union[str, int, bool]]] = None
|
||||
user: Optional[dict[str, Union[str, int, bool]]] = None
|
||||
|
||||
@field_validator("error")
|
||||
@classmethod
|
||||
def validate_error_if_not_success(cls, v: Optional[str], info) -> Optional[str]:
|
||||
if not info.data.get("success") and not v:
|
||||
raise ValueError("Error message required when success is False")
|
||||
msg = "Error message required when success is False"
|
||||
raise ValueError(msg)
|
||||
return v
|
||||
|
||||
@field_validator("token")
|
||||
@classmethod
|
||||
def validate_token_if_success(cls, v: Optional[str], info) -> Optional[str]:
|
||||
if info.data.get("success") and not v:
|
||||
raise ValueError("Token required when success is True")
|
||||
msg = "Token required when success is True"
|
||||
raise ValueError(msg)
|
||||
return v
|
||||
|
Reference in New Issue
Block a user