Improve topic sorting: add popular sorting by publications and authors count

This commit is contained in:
2025-06-02 02:56:11 +03:00
parent baca19a4d5
commit 3327976586
113 changed files with 7238 additions and 3739 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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:

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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}"
)

View File

@@ -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())

View File

@@ -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)

View File

@@ -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)

View File

@@ -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]

View File

@@ -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)
# Назначаем роль

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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