2023-10-26 20:38:31 +00:00
|
|
|
|
from datetime import datetime, timedelta, timezone
|
2025-05-16 06:23:48 +00:00
|
|
|
|
import json
|
2025-05-19 21:00:24 +00:00
|
|
|
|
import time
|
|
|
|
|
from typing import Dict, Any, Optional, Tuple, List
|
2023-10-26 21:07:35 +00:00
|
|
|
|
|
2024-11-01 12:06:21 +00:00
|
|
|
|
from auth.jwtcodec import JWTCodec
|
2025-02-11 09:00:35 +00:00
|
|
|
|
from auth.validations import AuthInput
|
|
|
|
|
from services.redis import redis
|
2024-11-01 12:06:21 +00:00
|
|
|
|
from settings import ONETIME_TOKEN_LIFE_SPAN, SESSION_TOKEN_LIFE_SPAN
|
2025-05-16 06:23:48 +00:00
|
|
|
|
from utils.logger import root_logger as logger
|
2024-11-01 12:06:21 +00:00
|
|
|
|
|
2022-09-17 18:12:14 +00:00
|
|
|
|
|
2025-05-16 06:23:48 +00:00
|
|
|
|
class TokenStorage:
|
|
|
|
|
"""
|
2025-05-19 21:00:24 +00:00
|
|
|
|
Класс для работы с хранилищем токенов в Redis
|
2025-05-16 06:23:48 +00:00
|
|
|
|
"""
|
|
|
|
|
|
2025-05-19 21:00:24 +00:00
|
|
|
|
@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
|
|
|
|
|
|
2025-05-16 06:23:48 +00:00
|
|
|
|
@staticmethod
|
|
|
|
|
async def get(token_key: str) -> Optional[str]:
|
|
|
|
|
"""
|
|
|
|
|
Получает токен из хранилища.
|
2022-09-17 18:12:14 +00:00
|
|
|
|
|
2025-05-16 06:23:48 +00:00
|
|
|
|
Args:
|
|
|
|
|
token_key: Ключ токена
|
2022-09-17 18:12:14 +00:00
|
|
|
|
|
2025-05-16 06:23:48 +00:00
|
|
|
|
Returns:
|
|
|
|
|
str или None, если токен не найден
|
2022-11-24 14:31:52 +00:00
|
|
|
|
"""
|
2025-05-16 06:23:48 +00:00
|
|
|
|
logger.debug(f"[tokenstorage.get] Запрос токена: {token_key}")
|
|
|
|
|
return await redis.get(token_key)
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
async def exists(token_key: str) -> bool:
|
2022-11-24 14:31:52 +00:00
|
|
|
|
"""
|
2025-05-16 06:23:48 +00:00
|
|
|
|
Проверяет наличие токена в хранилище.
|
2022-11-24 14:31:52 +00:00
|
|
|
|
|
2025-05-16 06:23:48 +00:00
|
|
|
|
Args:
|
|
|
|
|
token_key: Ключ токена
|
2022-11-24 14:31:52 +00:00
|
|
|
|
|
2025-05-16 06:23:48 +00:00
|
|
|
|
Returns:
|
|
|
|
|
bool: True, если токен существует
|
|
|
|
|
"""
|
|
|
|
|
return bool(await redis.execute("EXISTS", token_key))
|
2022-11-24 14:31:52 +00:00
|
|
|
|
|
2022-09-17 18:12:14 +00:00
|
|
|
|
@staticmethod
|
2025-05-16 06:23:48 +00:00
|
|
|
|
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
|
2022-09-17 18:12:14 +00:00
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
async def create_onetime(user: AuthInput) -> str:
|
2025-05-16 06:23:48 +00:00
|
|
|
|
"""
|
|
|
|
|
Создает одноразовый токен для пользователя.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
user: Объект пользователя
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
str: Сгенерированный токен
|
|
|
|
|
"""
|
2022-09-17 18:12:14 +00:00
|
|
|
|
life_span = ONETIME_TOKEN_LIFE_SPAN
|
2022-11-13 23:38:06 +00:00
|
|
|
|
exp = datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)
|
2022-10-31 21:25:25 +00:00
|
|
|
|
one_time_token = JWTCodec.encode(user, exp)
|
2025-05-16 06:23:48 +00:00
|
|
|
|
|
|
|
|
|
# Сохраняем токен в Redis
|
|
|
|
|
token_key = f"{user.id}-{user.username}-{one_time_token}"
|
|
|
|
|
await TokenStorage.save_token(token_key, "TRUE", life_span)
|
|
|
|
|
|
2022-09-17 18:12:14 +00:00
|
|
|
|
return one_time_token
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
async def revoke(token: str) -> bool:
|
2025-05-16 06:23:48 +00:00
|
|
|
|
"""
|
|
|
|
|
Отзывает токен.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
token: Токен для отзыва
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
bool: True, если токен успешно отозван
|
|
|
|
|
"""
|
2022-09-17 18:12:14 +00:00
|
|
|
|
try:
|
2025-05-16 06:23:48 +00:00
|
|
|
|
logger.debug("[tokenstorage.revoke] Отзыв токена")
|
|
|
|
|
|
|
|
|
|
# Декодируем токен
|
2022-09-17 18:12:14 +00:00
|
|
|
|
payload = JWTCodec.decode(token)
|
2025-05-16 06:23:48 +00:00
|
|
|
|
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
|
2022-09-17 18:12:14 +00:00
|
|
|
|
|
|
|
|
|
@staticmethod
|
2025-05-16 06:23:48 +00:00
|
|
|
|
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
|