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