core/auth/sessions.py
Untone 1d64811880
All checks were successful
Deploy on push / deploy (push) Successful in 6s
userlist-demo-ready
2025-05-20 00:00:24 +03:00

378 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any, List
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:
"""
Создаёт ключ для сессии в Redis.
Args:
user_id: ID пользователя
token: JWT токен сессии
Returns:
str: Ключ сессии
"""
session_key = f"session:{user_id}:{token}"
logger.debug(f"[SessionManager._make_session_key] Сформирован ключ сессии: {session_key}")
return session_key
@staticmethod
def _make_user_sessions_key(user_id: str) -> str:
"""
Создаёт ключ для списка активных сессий пользователя.
Args:
user_id: ID пользователя
Returns:
str: Ключ списка сессий
"""
return f"user_sessions:{user_id}"
@classmethod
async def create_session(cls, user_id: str, username: str, device_info: Optional[dict] = None) -> str:
"""
Создаёт новую сессию.
Args:
user_id: ID пользователя
username: Имя пользователя
device_info: Информация об устройстве (опционально)
Returns:
str: JWT токен сессии
"""
# Создаём токен с явным указанием срока действия (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
@classmethod
async def verify_session(cls, token: str) -> Optional[TokenPayload]:
"""
Проверяет сессию по токену.
Args:
token: JWT токен
Returns:
Optional[TokenPayload]: Данные токена или None, если сессия недействительна
"""
# Декодируем токен для получения payload
payload = JWTCodec.decode(token)
if not payload:
return None
# Получаем данные из payload
user_id = payload.user_id
# Формируем ключ сессии
session_key = cls._make_session_key(user_id, token)
logger.debug(f"[SessionManager.verify_session] Сформирован ключ сессии: {session_key}")
# Проверяем существование сессии в Redis
exists = await redis.exists(session_key)
if not exists:
logger.warning(f"[SessionManager.verify_session] Сессия не найдена: {user_id}. Ключ: {session_key}")
# Проверяем также ключ в старом формате TokenStorage для обратной совместимости
token_key = f"{user_id}-{payload.username}-{token}"
old_format_exists = await redis.exists(token_key)
if old_format_exists:
logger.info(f"[SessionManager.verify_session] Найдена сессия в старом формате: {token_key}")
# Миграция: создаем запись в новом формате
session_data = {
"user_id": user_id,
"username": payload.username,
}
# Копируем сессию в новый формат
pipeline = redis.pipeline()
pipeline.hset(session_key, mapping=session_data)
pipeline.expire(session_key, 30 * 24 * 60 * 60)
pipeline.sadd(cls._make_user_sessions_key(user_id), token)
await pipeline.execute()
logger.info(f"[SessionManager.verify_session] Сессия мигрирована в новый формат: {session_key}")
return payload
# Если сессия не найдена ни в новом, ни в старом формате, проверяем все ключи в Redis
keys = await redis.keys("session:*")
logger.debug(f"[SessionManager.verify_session] Все ключи сессий в Redis: {keys}")
# Если сессии нет, возвращаем None
return None
# Если сессия найдена, возвращаем payload
return payload
@classmethod
async def get_user_sessions(cls, user_id: str) -> List[Dict[str, Any]]:
"""
Получает список активных сессий пользователя.
Args:
user_id: ID пользователя
Returns:
List[Dict[str, Any]]: Список сессий
"""
user_sessions_key = cls._make_user_sessions_key(user_id)
tokens = await redis.smembers(user_sessions_key)
sessions = []
for token in tokens:
session_key = cls._make_session_key(user_id, token)
session_data = await redis.hgetall(session_key)
if session_data:
session = dict(session_data)
session["token"] = token
sessions.append(session)
return sessions
@classmethod
async def delete_session(cls, user_id: str, token: str) -> bool:
"""
Удаляет сессию.
Args:
user_id: ID пользователя
token: JWT токен
Returns:
bool: True, если сессия успешно удалена
"""
session_key = cls._make_session_key(user_id, token)
user_sessions_key = cls._make_user_sessions_key(user_id)
# Удаляем данные сессии и токен из списка сессий пользователя
pipeline = redis.pipeline()
pipeline.delete(session_key)
pipeline.srem(user_sessions_key, token)
# Также удаляем ключ в формате TokenStorage для полной очистки
token_payload = JWTCodec.decode(token)
if token_payload:
token_key = f"{user_id}-{token_payload.username}-{token}"
pipeline.delete(token_key)
results = await pipeline.execute()
return bool(results[0]) or bool(results[1])
@classmethod
async def delete_all_sessions(cls, user_id: str) -> int:
"""
Удаляет все сессии пользователя.
Args:
user_id: ID пользователя
Returns:
int: Количество удаленных сессий
"""
user_sessions_key = cls._make_user_sessions_key(user_id)
tokens = await redis.smembers(user_sessions_key)
count = 0
for token in tokens:
session_key = cls._make_session_key(user_id, token)
# Удаляем данные сессии
deleted = await redis.delete(session_key)
count += deleted
# Также удаляем ключ в формате TokenStorage
token_payload = JWTCodec.decode(token)
if token_payload:
token_key = f"{user_id}-{token_payload.username}-{token}"
await redis.delete(token_key)
# Очищаем список токенов
await redis.delete(user_sessions_key)
return count
@classmethod
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)
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)}")
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