core/auth/tokenstorage.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

437 lines
17 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
import json
import time
from typing import Dict, Any, Optional, Tuple, List
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
class TokenStorage:
"""
Класс для работы с хранилищем токенов в Redis
"""
@staticmethod
def _make_token_key(user_id: str, username: str, token: str) -> str:
"""
Создает ключ для хранения токена
Args:
user_id: ID пользователя
username: Имя пользователя
token: Токен
Returns:
str: Ключ токена
"""
# Сохраняем в старом формате для обратной совместимости
return f"{user_id}-{username}-{token}"
@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}"
@classmethod
async def create_session(cls, user_id: str, username: str, device_info: Optional[Dict[str, str]] = None) -> str:
"""
Создает новую сессию для пользователя
Args:
user_id: ID пользователя
username: Имя пользователя
device_info: Информация об устройстве (опционально)
Returns:
str: Токен сессии
"""
logger.debug(f"[TokenStorage.create_session] Начало создания сессии для пользователя {user_id}")
# Генерируем 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)}")
# Формируем ключи для Redis
token_key = cls._make_token_key(user_id, username, token)
logger.debug(f"[TokenStorage.create_session] Сформированы ключи: token_key={token_key}")
# Формируем ключи в новом формате SessionManager для совместимости
session_key = cls._make_session_key(user_id, token)
user_sessions_key = cls._make_user_sessions_key(user_id)
# Готовим данные для сохранения
token_data = {
"user_id": user_id,
"username": username,
"created_at": time.time(),
"expires_at": time.time() + 30 * 24 * 60 * 60 # 30 дней
}
if device_info:
token_data.update(device_info)
logger.debug(f"[TokenStorage.create_session] Сформированы данные сессии: {token_data}")
# Сохраняем в Redis старый формат
pipeline = redis.pipeline()
pipeline.hset(token_key, mapping=token_data)
pipeline.expire(token_key, 30 * 24 * 60 * 60) # 30 дней
# Также сохраняем в новом формате 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 дней
results = await pipeline.execute()
logger.info(f"[TokenStorage.create_session] Сессия успешно создана для пользователя {user_id}")
return token
@classmethod
async def exists(cls, token_key: str) -> bool:
"""
Проверяет существование токена по ключу
Args:
token_key: Ключ токена
Returns:
bool: True, если токен существует
"""
exists = await redis.exists(token_key)
return bool(exists)
@classmethod
async def validate_token(cls, token: str) -> Tuple[bool, Optional[Dict[str, Any]]]:
"""
Проверяет валидность токена
Args:
token: JWT токен
Returns:
Tuple[bool, Dict[str, Any]]: (Валиден ли токен, данные токена)
"""
try:
# Декодируем JWT токен
payload = JWTCodec.decode(token)
if not payload:
logger.warning(f"[TokenStorage.validate_token] Токен не валиден (не удалось декодировать)")
return False, None
user_id = payload.user_id
username = payload.username
# Формируем ключи для 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(token_key)
new_exists = await redis.exists(session_key)
if old_exists or new_exists:
logger.info(f"[TokenStorage.validate_token] Токен валиден для пользователя {user_id}")
# Получаем данные токена из актуального хранилища
if new_exists:
token_data = await redis.hgetall(session_key)
else:
token_data = await redis.hgetall(token_key)
# Если найден только в старом формате, создаем запись в новом формате
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
except Exception as e:
logger.error(f"[TokenStorage.validate_token] Ошибка при проверке токена: {e}")
return False, None
@classmethod
async def invalidate_token(cls, token: str) -> bool:
"""
Инвалидирует токен
Args:
token: JWT токен
Returns:
bool: True, если токен успешно инвалидирован
"""
try:
# Декодируем JWT токен
payload = JWTCodec.decode(token)
if not payload:
logger.warning(f"[TokenStorage.invalidate_token] Токен не валиден (не удалось декодировать)")
return False
user_id = payload.user_id
username = payload.username
# Формируем ключи для 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)
# Удаляем токен из Redis в обоих форматах
pipeline = redis.pipeline()
pipeline.delete(token_key)
pipeline.delete(session_key)
pipeline.srem(user_sessions_key, token)
results = await pipeline.execute()
success = any(results)
if success:
logger.info(f"[TokenStorage.invalidate_token] Токен успешно инвалидирован для пользователя {user_id}")
else:
logger.warning(f"[TokenStorage.invalidate_token] Токен не найден: {token_key}")
return success
except Exception as e:
logger.error(f"[TokenStorage.invalidate_token] Ошибка при инвалидации токена: {e}")
return False
@classmethod
async def invalidate_all_tokens(cls, user_id: str) -> int:
"""
Инвалидирует все токены пользователя
Args:
user_id: ID пользователя
Returns:
int: Количество инвалидированных токенов
"""
try:
# Получаем список сессий пользователя
user_sessions_key = cls._make_user_sessions_key(user_id)
tokens = await redis.smembers(user_sessions_key)
if not tokens:
logger.warning(f"[TokenStorage.invalidate_all_tokens] Нет активных сессий пользователя {user_id}")
return 0
count = 0
for token in tokens:
# Декодируем JWT токен
try:
payload = JWTCodec.decode(token)
if payload:
username = payload.username
# Формируем ключи для Redis
token_key = cls._make_token_key(user_id, username, token)
session_key = cls._make_session_key(user_id, token)
# Удаляем токен из Redis
pipeline = redis.pipeline()
pipeline.delete(token_key)
pipeline.delete(session_key)
results = await pipeline.execute()
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}")
return count
except Exception as e:
logger.error(f"[TokenStorage.invalidate_all_tokens] Ошибка при инвалидации всех токенов: {e}")
return 0
@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)
return data if valid else None
@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)
@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, если токен успешно сохранен
"""
try:
# Если данные не строка, преобразуем их в JSON
value = json.dumps(data) if isinstance(data, dict) else data
# Сохраняем токен и устанавливаем время жизни
await redis.set(token_key, value, ex=life_span)
return True
except Exception as e:
logger.error(f"[tokenstorage.save_token] Ошибка сохранения токена: {str(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
@staticmethod
async def revoke(token: str) -> bool:
"""
Отзывает токен.
Args:
token: Токен для отзыва
Returns:
bool: True, если токен успешно отозван
"""
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
except Exception as e:
logger.error(f"[tokenstorage.revoke] Ошибка отзыва токена: {str(e)}")
return False
@staticmethod
async def revoke_all(user: AuthInput) -> bool:
"""
Отзывает все токены пользователя.
Args:
user: Объект пользователя
Returns:
bool: True, если все токены успешно отозваны
"""
try:
# Формируем ключи
user_sessions_key = f"user_sessions:{user.id}"
# Получаем все токены пользователя
tokens = await redis.smembers(user_sessions_key)
if not tokens:
return True
# Формируем список ключей для удаления
keys_to_delete = [f"{user.id}-{user.username}-{token}" for token in tokens]
keys_to_delete.append(user_sessions_key)
# Удаляем все токены и список сессий
await redis.delete(*keys_to_delete)
return True
except Exception as e:
logger.error(f"[tokenstorage.revoke_all] Ошибка отзыва всех токенов: {str(e)}")
return False