core/auth/sessions.py

229 lines
8.5 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
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"""
return f"session:{user_id}:{token}"
@staticmethod
def _make_user_sessions_key(user_id: str) -> str:
"""Формирует ключ для списка сессий пользователя в Redis"""
return f"user_sessions:{user_id}"
@classmethod
async def create_session(cls, user_id: str, username: str, device_info: dict = None) -> str:
"""
Создает новую сессию для пользователя.
Args:
user_id: ID пользователя
username: Имя пользователя/логин
device_info: Информация об устройстве (опционально)
Returns:
str: Токен сессии
"""
try:
# Создаем JWT токен
exp = datetime.now(tz=timezone.utc) + timedelta(seconds=SESSION_TOKEN_LIFE_SPAN)
session_token = JWTCodec.encode({"id": user_id, "email": username}, exp)
# Создаем данные сессии
session_data = SessionData(
user_id=user_id,
username=username,
created_at=datetime.now(tz=timezone.utc),
expires_at=exp,
device_info=device_info,
)
# Ключи в Redis
session_key = cls._make_session_key(user_id, session_token)
user_sessions_key = cls._make_user_sessions_key(user_id)
# Сохраняем в Redis
pipe = redis.pipeline()
await pipe.hset(session_key, mapping=session_data.dict())
await pipe.expire(session_key, SESSION_TOKEN_LIFE_SPAN)
await pipe.sadd(user_sessions_key, session_token)
await pipe.expire(user_sessions_key, SESSION_TOKEN_LIFE_SPAN)
await pipe.execute()
return session_token
except Exception as e:
logger.error(f"[SessionManager.create_session] Ошибка: {str(e)}")
raise
@classmethod
async def verify_session(cls, token: str) -> Optional[TokenPayload]:
"""
Проверяет валидность сессии.
Args:
token: Токен сессии
Returns:
TokenPayload: Данные токена или None, если токен недействителен
"""
try:
# Декодируем JWT
payload = JWTCodec.decode(token)
# Формируем ключ сессии
session_key = cls._make_session_key(payload.user_id, token)
# Проверяем существование сессии в Redis
session_exists = await redis.exists(session_key)
if not session_exists:
logger.debug(f"[SessionManager.verify_session] Сессия не найдена: {payload.user_id}")
return None
return payload
except Exception as e:
logger.error(f"[SessionManager.verify_session] Ошибка: {str(e)}")
return None
@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.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