core/auth/tokenstorage.py

672 lines
28 KiB
Python
Raw Normal View History

2025-05-16 06:23:48 +00:00
import json
import secrets
2025-05-19 21:00:24 +00:00
import time
from typing import Any, Dict, Literal, Optional, Union
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
2025-05-16 06:23:48 +00:00
from utils.logger import root_logger as logger
2024-11-01 12:06:21 +00:00
# Типы токенов
TokenType = Literal["session", "verification", "oauth_access", "oauth_refresh"]
# TTL по умолчанию для разных типов токенов
DEFAULT_TTL = {
"session": 30 * 24 * 60 * 60, # 30 дней
"verification": 3600, # 1 час
"oauth_access": 3600, # 1 час
"oauth_refresh": 86400 * 30, # 30 дней
}
2025-05-16 06:23:48 +00:00
class TokenStorage:
"""
Единый менеджер всех типов токенов в системе:
- Токены сессий (session)
- Токены подтверждения (verification)
- OAuth токены (oauth_access, oauth_refresh)
2025-05-16 06:23:48 +00:00
"""
2025-05-19 21:00:24 +00:00
@staticmethod
def _make_token_key(token_type: TokenType, identifier: str, token: Optional[str] = None) -> str:
2025-05-19 21:00:24 +00:00
"""
Создает унифицированный ключ для токена
2025-05-19 21:00:24 +00:00
Args:
token_type: Тип токена
identifier: Идентификатор (user_id, user_id:provider, etc)
token: Сам токен (для session и verification)
2025-05-19 21:00:24 +00:00
Returns:
str: Ключ токена
"""
if token_type == "session":
return f"session:{token}"
if token_type == "verification":
return f"verification_token:{token}"
if token_type == "oauth_access":
return f"oauth_access:{identifier}"
if token_type == "oauth_refresh":
return f"oauth_refresh:{identifier}"
raise ValueError(f"Неизвестный тип токена: {token_type}")
2025-05-19 21:00:24 +00:00
@staticmethod
def _make_user_tokens_key(user_id: str, token_type: TokenType) -> str:
"""Создает ключ для списка токенов пользователя"""
return f"user_tokens:{user_id}:{token_type}"
@classmethod
async def create_token(
cls,
token_type: TokenType,
user_id: str,
data: Dict[str, Any],
ttl: Optional[int] = None,
token: Optional[str] = None,
provider: Optional[str] = None,
) -> str:
2025-05-19 21:00:24 +00:00
"""
Универсальный метод создания токена любого типа
2025-05-19 21:00:24 +00:00
Args:
token_type: Тип токена
2025-05-19 21:00:24 +00:00
user_id: ID пользователя
data: Данные токена
ttl: Время жизни (по умолчанию из DEFAULT_TTL)
token: Существующий токен (для verification)
provider: OAuth провайдер (для oauth токенов)
2025-05-19 21:00:24 +00:00
Returns:
str: Токен или ключ токена
2025-05-19 21:00:24 +00:00
"""
if ttl is None:
ttl = DEFAULT_TTL[token_type]
2025-05-19 21:00:24 +00:00
# Подготавливаем данные токена
token_data = {"user_id": user_id, "token_type": token_type, "created_at": int(time.time()), **data}
2025-05-19 21:00:24 +00:00
if token_type == "session":
# Генерируем новый токен сессии
session_token = cls.generate_token()
token_key = cls._make_token_key(token_type, user_id, session_token)
2025-05-19 21:00:24 +00:00
# Сохраняем данные сессии
for field, value in token_data.items():
await redis.hset(token_key, field, str(value))
await redis.expire(token_key, ttl)
2025-05-19 21:00:24 +00:00
# Добавляем в список сессий пользователя
user_tokens_key = cls._make_user_tokens_key(user_id, token_type)
await redis.sadd(user_tokens_key, session_token)
await redis.expire(user_tokens_key, ttl)
2025-05-19 21:00:24 +00:00
logger.info(f"Создан токен сессии для пользователя {user_id}")
return session_token
2025-05-19 21:00:24 +00:00
if token_type == "verification":
# Используем переданный токен или генерируем новый
verification_token = token or secrets.token_urlsafe(32)
token_key = cls._make_token_key(token_type, user_id, verification_token)
2025-05-19 21:00:24 +00:00
# Отменяем предыдущие токены того же типа
verification_type = data.get("verification_type", "unknown")
await cls._cancel_verification_tokens(user_id, verification_type)
2025-05-19 21:00:24 +00:00
# Сохраняем токен подтверждения
await redis.serialize_and_set(token_key, token_data, ex=ttl)
2025-05-29 09:37:39 +00:00
logger.info(f"Создан токен подтверждения {verification_type} для пользователя {user_id}")
return verification_token
2025-05-19 21:00:24 +00:00
if token_type in ["oauth_access", "oauth_refresh"]:
if not provider:
raise ValueError("OAuth токены требуют указания провайдера")
2025-05-29 09:37:39 +00:00
identifier = f"{user_id}:{provider}"
token_key = cls._make_token_key(token_type, identifier)
2025-05-19 21:00:24 +00:00
# Добавляем провайдера в данные
token_data["provider"] = provider
2025-05-29 09:37:39 +00:00
# Сохраняем OAuth токен
await redis.serialize_and_set(token_key, token_data, ex=ttl)
2025-05-29 09:37:39 +00:00
logger.info(f"Создан {token_type} токен для пользователя {user_id}, провайдер {provider}")
return token_key
2025-05-19 21:00:24 +00:00
raise ValueError(f"Неподдерживаемый тип токена: {token_type}")
2025-05-19 21:00:24 +00:00
@classmethod
async def get_token_data(
cls,
token_type: TokenType,
token_or_identifier: str,
user_id: Optional[str] = None,
provider: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
2025-05-19 21:00:24 +00:00
"""
Универсальный метод получения данных токена
2025-05-19 21:00:24 +00:00
Args:
token_type: Тип токена
token_or_identifier: Токен или идентификатор
user_id: ID пользователя (для OAuth)
provider: OAuth провайдер
2025-05-19 21:00:24 +00:00
Returns:
Dict с данными токена или None
2025-05-19 21:00:24 +00:00
"""
try:
if token_type == "session":
token_key = cls._make_token_key(token_type, "", token_or_identifier)
token_data = await redis.hgetall(token_key)
if token_data:
# Обновляем время последней активности
await redis.hset(token_key, "last_activity", str(int(time.time())))
return {k: v for k, v in token_data.items()}
return None
if token_type == "verification":
token_key = cls._make_token_key(token_type, "", token_or_identifier)
return await redis.get_and_deserialize(token_key)
if token_type in ["oauth_access", "oauth_refresh"]:
if not user_id or not provider:
raise ValueError("OAuth токены требуют user_id и provider")
identifier = f"{user_id}:{provider}"
token_key = cls._make_token_key(token_type, identifier)
token_data = await redis.get_and_deserialize(token_key)
if token_data:
# Добавляем информацию о TTL
ttl = await redis.execute("TTL", token_key)
if ttl > 0:
token_data["ttl_remaining"] = ttl
return token_data
return None
except Exception as e:
logger.error(f"Ошибка получения токена {token_type}: {e}")
return None
2025-05-19 21:00:24 +00:00
@classmethod
async def validate_token(
cls, token: str, token_type: Optional[TokenType] = None
) -> tuple[bool, Optional[dict[str, Any]]]:
2025-05-19 21:00:24 +00:00
"""
Проверяет валидность токена
Args:
token: Токен для проверки
token_type: Тип токена (если не указан - определяется автоматически)
2025-05-19 21:00:24 +00:00
Returns:
Tuple[bool, Dict]: (Валиден ли токен, данные токена)
2025-05-19 21:00:24 +00:00
"""
try:
# Для JWT токенов (сессии) - декодируем
if not token_type or token_type == "session":
payload = JWTCodec.decode(token)
if payload:
user_id = payload.user_id
username = payload.username
# Проверяем в разных форматах для совместимости
old_token_key = f"{user_id}-{username}-{token}"
new_token_key = cls._make_token_key("session", user_id, token)
old_exists = await redis.exists(old_token_key)
new_exists = await redis.exists(new_token_key)
if old_exists or new_exists:
# Получаем данные из актуального хранилища
if new_exists:
token_data = await redis.hgetall(new_token_key)
else:
token_data = await redis.hgetall(old_token_key)
# Миграция в новый формат
if not new_exists:
for field, value in token_data.items():
await redis.hset(new_token_key, field, value)
await redis.expire(new_token_key, DEFAULT_TTL["session"])
return True, {k: v for k, v in token_data.items()}
# Для токенов подтверждения - прямая проверка
if not token_type or token_type == "verification":
token_key = cls._make_token_key("verification", "", token)
token_data = await redis.get_and_deserialize(token_key)
if token_data:
return True, token_data
2025-05-29 09:37:39 +00:00
return False, None
2025-05-29 09:37:39 +00:00
2025-05-19 21:00:24 +00:00
except Exception as e:
logger.error(f"Ошибка валидации токена: {e}")
2025-05-19 21:00:24 +00:00
return False, None
@classmethod
async def revoke_token(
cls,
token_type: TokenType,
token_or_identifier: str,
user_id: Optional[str] = None,
provider: Optional[str] = None,
) -> bool:
2025-05-19 21:00:24 +00:00
"""
Универсальный метод отзыва токена
2025-05-19 21:00:24 +00:00
Args:
token_type: Тип токена
token_or_identifier: Токен или идентификатор
user_id: ID пользователя
provider: OAuth провайдер
2025-05-19 21:00:24 +00:00
Returns:
bool: Успех операции
2025-05-19 21:00:24 +00:00
"""
try:
if token_type == "session":
# Декодируем JWT для получения данных
payload = JWTCodec.decode(token_or_identifier)
if payload:
user_id = payload.user_id
username = payload.username
# Удаляем в обоих форматах
old_token_key = f"{user_id}-{username}-{token_or_identifier}"
new_token_key = cls._make_token_key(token_type, user_id, token_or_identifier)
user_tokens_key = cls._make_user_tokens_key(user_id, token_type)
result1 = await redis.delete(old_token_key)
result2 = await redis.delete(new_token_key)
result3 = await redis.srem(user_tokens_key, token_or_identifier)
return result1 > 0 or result2 > 0 or result3 > 0
elif token_type == "verification":
token_key = cls._make_token_key(token_type, "", token_or_identifier)
result = await redis.delete(token_key)
return result > 0
elif token_type in ["oauth_access", "oauth_refresh"]:
if not user_id or not provider:
raise ValueError("OAuth токены требуют user_id и provider")
identifier = f"{user_id}:{provider}"
token_key = cls._make_token_key(token_type, identifier)
result = await redis.delete(token_key)
return result > 0
return False
2025-05-29 09:37:39 +00:00
2025-05-19 21:00:24 +00:00
except Exception as e:
logger.error(f"Ошибка отзыва токена {token_type}: {e}")
2025-05-19 21:00:24 +00:00
return False
@classmethod
async def revoke_user_tokens(cls, user_id: str, token_type: Optional[TokenType] = None) -> int:
2025-05-19 21:00:24 +00:00
"""
Отзывает все токены пользователя определенного типа или все
2025-05-19 21:00:24 +00:00
Args:
user_id: ID пользователя
token_type: Тип токенов для отзыва (None = все типы)
2025-05-19 21:00:24 +00:00
Returns:
int: Количество отозванных токенов
2025-05-19 21:00:24 +00:00
"""
count = 0
2025-05-29 09:37:39 +00:00
try:
types_to_revoke = (
[token_type] if token_type else ["session", "verification", "oauth_access", "oauth_refresh"]
)
for t_type in types_to_revoke:
if t_type == "session":
user_tokens_key = cls._make_user_tokens_key(user_id, t_type)
tokens = await redis.smembers(user_tokens_key)
for token in tokens:
token_str = token.decode("utf-8") if isinstance(token, bytes) else str(token)
success = await cls.revoke_token(t_type, token_str, user_id)
if success:
count += 1
await redis.delete(user_tokens_key)
elif t_type == "verification":
# Ищем все токены подтверждения пользователя
pattern = "verification_token:*"
keys = await redis.keys(pattern)
for key in keys:
token_data = await redis.get_and_deserialize(key)
if token_data and token_data.get("user_id") == user_id:
await redis.delete(key)
count += 1
elif t_type in ["oauth_access", "oauth_refresh"]:
# Ищем OAuth токены по паттерну
pattern = f"{t_type}:{user_id}:*"
keys = await redis.keys(pattern)
for key in keys:
await redis.delete(key)
2025-05-19 21:00:24 +00:00
count += 1
2025-05-29 09:37:39 +00:00
logger.info(f"Отозвано {count} токенов для пользователя {user_id}")
return count
2025-05-29 09:37:39 +00:00
except Exception as e:
logger.error(f"Ошибка отзыва токенов пользователя: {e}")
2025-05-19 21:00:24 +00:00
return count
2025-05-29 09:37:39 +00:00
@staticmethod
async def _cancel_verification_tokens(user_id: str, verification_type: str) -> None:
"""Отменяет предыдущие токены подтверждения определенного типа"""
try:
pattern = "verification_token:*"
keys = await redis.keys(pattern)
for key in keys:
token_data = await redis.get_and_deserialize(key)
if (
token_data
and token_data.get("user_id") == user_id
and token_data.get("verification_type") == verification_type
):
await redis.delete(key)
2025-05-19 21:00:24 +00:00
except Exception as e:
logger.error(f"Ошибка отмены токенов подтверждения: {e}")
# === УДОБНЫЕ МЕТОДЫ ДЛЯ СЕССИЙ ===
2025-05-19 21:00:24 +00:00
@classmethod
async def create_session(
cls,
user_id: str,
auth_data: Optional[dict] = None,
username: Optional[str] = None,
device_info: Optional[dict] = None,
) -> str:
"""Создает токен сессии"""
session_data = {}
if auth_data:
session_data["auth_data"] = json.dumps(auth_data)
if username:
session_data["username"] = username
if device_info:
session_data["device_info"] = json.dumps(device_info)
2025-05-19 21:00:24 +00:00
return await cls.create_token("session", user_id, session_data)
2025-05-19 21:00:24 +00:00
@classmethod
async def get_session_data(cls, token: str) -> Optional[Dict[str, Any]]:
"""Получает данные сессии"""
valid, data = await cls.validate_token(token, "session")
2025-05-19 21:00:24 +00:00
return data if valid else None
# === УДОБНЫЕ МЕТОДЫ ДЛЯ ТОКЕНОВ ПОДТВЕРЖДЕНИЯ ===
@classmethod
async def create_verification_token(
cls,
user_id: str,
verification_type: str,
data: Dict[str, Any],
ttl: Optional[int] = None,
) -> str:
"""Создает токен подтверждения"""
token_data = {"verification_type": verification_type, **data}
# TTL по типу подтверждения
if ttl is None:
verification_ttls = {
"email_change": 3600, # 1 час
"phone_change": 600, # 10 минут
"password_reset": 1800, # 30 минут
}
ttl = verification_ttls.get(verification_type, 3600)
return await cls.create_token("verification", user_id, token_data, ttl)
@classmethod
async def confirm_verification_token(cls, token_str: str) -> Optional[Dict[str, Any]]:
"""Подтверждает и использует токен подтверждения (одноразовый)"""
token_data = await cls.get_token_data("verification", token_str)
if token_data:
# Удаляем токен после использования
await cls.revoke_token("verification", token_str)
return token_data
return None
2025-05-16 06:23:48 +00:00
# === УДОБНЫЕ МЕТОДЫ ДЛЯ OAUTH ТОКЕНОВ ===
2022-11-24 14:31:52 +00:00
@classmethod
async def store_oauth_tokens(
cls,
user_id: str,
provider: str,
access_token: str,
refresh_token: Optional[str] = None,
expires_in: Optional[int] = None,
additional_data: Optional[Dict[str, Any]] = None,
) -> bool:
"""Сохраняет OAuth токены"""
try:
# Сохраняем access token
access_data = {
"token": access_token,
"provider": provider,
"expires_in": expires_in,
**(additional_data or {}),
}
access_ttl = expires_in if expires_in else DEFAULT_TTL["oauth_access"]
await cls.create_token("oauth_access", user_id, access_data, access_ttl, provider=provider)
# Сохраняем refresh token если есть
if refresh_token:
refresh_data = {
"token": refresh_token,
"provider": provider,
}
await cls.create_token("oauth_refresh", user_id, refresh_data, provider=provider)
2022-11-24 14:31:52 +00:00
return True
2022-11-24 14:31:52 +00:00
except Exception as e:
logger.error(f"Ошибка сохранения OAuth токенов: {e}")
return False
2025-05-16 06:23:48 +00:00
@classmethod
async def get_oauth_token(cls, user_id: int, provider: str, token_type: str = "access") -> Optional[Dict[str, Any]]:
"""Получает OAuth токен"""
oauth_type = f"oauth_{token_type}"
if oauth_type in ["oauth_access", "oauth_refresh"]:
return await cls.get_token_data(oauth_type, "", user_id, provider) # type: ignore[arg-type]
return None
2025-05-16 06:23:48 +00:00
@classmethod
async def revoke_oauth_tokens(cls, user_id: str, provider: str) -> bool:
"""Удаляет все OAuth токены для провайдера"""
2025-05-16 06:23:48 +00:00
try:
result1 = await cls.revoke_token("oauth_access", "", user_id, provider)
result2 = await cls.revoke_token("oauth_refresh", "", user_id, provider)
return result1 or result2
2025-05-16 06:23:48 +00:00
except Exception as e:
logger.error(f"Ошибка удаления OAuth токенов: {e}")
2025-05-16 06:23:48 +00:00
return False
# === ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ ===
@staticmethod
def generate_token() -> str:
"""Генерирует криптографически стойкий токен"""
return secrets.token_urlsafe(32)
2025-05-16 06:23:48 +00:00
@staticmethod
async def cleanup_expired_tokens() -> int:
"""Очищает истекшие токены (Redis делает это автоматически)"""
# Redis автоматически удаляет истекшие ключи
# Здесь можем очистить связанные структуры данных
try:
user_session_keys = await redis.keys("user_tokens:*:session")
cleaned_count = 0
for user_tokens_key in user_session_keys:
tokens = await redis.smembers(user_tokens_key)
active_tokens = []
for token in tokens:
token_str = token.decode("utf-8") if isinstance(token, bytes) else str(token)
session_key = f"session:{token_str}"
exists = await redis.exists(session_key)
if exists:
active_tokens.append(token_str)
else:
cleaned_count += 1
# Обновляем список активных токенов
if active_tokens:
await redis.delete(user_tokens_key)
for token in active_tokens:
await redis.sadd(user_tokens_key, token)
else:
await redis.delete(user_tokens_key)
2025-05-16 06:23:48 +00:00
if cleaned_count > 0:
logger.info(f"Очищено {cleaned_count} ссылок на истекшие токены")
2025-05-16 06:23:48 +00:00
return cleaned_count
2025-05-16 06:23:48 +00:00
except Exception as e:
logger.error(f"Ошибка очистки токенов: {e}")
return 0
# === ОБРАТНАЯ СОВМЕСТИМОСТЬ ===
@staticmethod
async def get(token_key: str) -> Optional[str]:
"""Обратная совместимость - получение токена по ключу"""
result = await redis.get(token_key)
if isinstance(result, bytes):
return result.decode("utf-8")
return result
2025-05-16 06:23:48 +00:00
@staticmethod
async def save_token(token_key: str, token_data: Dict[str, Any], life_span: int = 3600) -> bool:
"""Обратная совместимость - сохранение токена"""
try:
return await redis.serialize_and_set(token_key, token_data, ex=life_span)
except Exception as e:
logger.error(f"Ошибка сохранения токена {token_key}: {e}")
return False
2025-05-16 06:23:48 +00:00
@staticmethod
async def get_token(token_key: str) -> Optional[Dict[str, Any]]:
"""Обратная совместимость - получение данных токена"""
try:
return await redis.get_and_deserialize(token_key)
except Exception as e:
logger.error(f"Ошибка получения токена {token_key}: {e}")
return None
@staticmethod
async def delete_token(token_key: str) -> bool:
"""Обратная совместимость - удаление токена"""
try:
result = await redis.delete(token_key)
return result > 0
except Exception as e:
logger.error(f"Ошибка удаления токена {token_key}: {e}")
return False
2025-05-16 06:23:48 +00:00
# Остальные методы для обратной совместимости...
async def exists(self, token_key: str) -> bool:
"""Совместимость - проверка существования"""
return bool(await redis.exists(token_key))
2025-05-16 06:23:48 +00:00
async def invalidate_token(self, token: str) -> bool:
"""Совместимость - инвалидация токена"""
return await self.revoke_token("session", token)
2025-05-16 06:23:48 +00:00
async def invalidate_all_tokens(self, user_id: str) -> int:
"""Совместимость - инвалидация всех токенов"""
return await self.revoke_user_tokens(user_id)
2025-05-16 06:23:48 +00:00
def generate_session_token(self) -> str:
"""Совместимость - генерация токена сессии"""
return self.generate_token()
async def get_session(self, session_token: str) -> Optional[Dict[str, Any]]:
"""Совместимость - получение сессии"""
return await self.get_session_data(session_token)
2025-05-16 06:23:48 +00:00
async def revoke_session(self, session_token: str) -> bool:
"""Совместимость - отзыв сессии"""
return await self.revoke_token("session", session_token)
2025-05-16 06:23:48 +00:00
async def revoke_all_user_sessions(self, user_id: Union[int, str]) -> bool:
"""Совместимость - отзыв всех сессий"""
count = await self.revoke_user_tokens(str(user_id), "session")
return count > 0
2025-05-16 06:23:48 +00:00
async def get_user_sessions(self, user_id: Union[int, str]) -> list[Dict[str, Any]]:
"""Совместимость - получение сессий пользователя"""
try:
user_tokens_key = f"user_tokens:{user_id}:session"
tokens = await redis.smembers(user_tokens_key)
2025-05-16 06:23:48 +00:00
sessions = []
for token in tokens:
token_str = token.decode("utf-8") if isinstance(token, bytes) else str(token)
session_data = await self.get_session_data(token_str)
if session_data:
session_data["token"] = token_str
sessions.append(session_data)
2025-05-16 06:23:48 +00:00
return sessions
2025-05-16 06:23:48 +00:00
except Exception as e:
logger.error(f"Ошибка получения сессий пользователя: {e}")
return []
async def revoke_all_tokens_for_user(self, user: AuthInput) -> bool:
"""Совместимость - отзыв всех токенов пользователя"""
user_id = getattr(user, "id", 0) or 0
count = await self.revoke_user_tokens(str(user_id))
return count > 0
async def get_one_time_token_value(self, token_key: str) -> Optional[str]:
"""Совместимость - одноразовые токены"""
token_data = await self.get_token(token_key)
if token_data and token_data.get("valid"):
return "TRUE"
return None
async def save_one_time_token(self, user: AuthInput, one_time_token: str, life_span: int = 300) -> bool:
"""Совместимость - сохранение одноразового токена"""
user_id = getattr(user, "id", 0) or 0
token_key = f"{user_id}-{user.username}-{one_time_token}"
token_data = {"valid": True, "user_id": user_id, "username": user.username}
return await self.save_token(token_key, token_data, life_span)
async def extend_token_lifetime(self, token_key: str, additional_seconds: int = 3600) -> bool:
"""Совместимость - продление времени жизни"""
token_data = await self.get_token(token_key)
if not token_data:
2025-05-16 06:23:48 +00:00
return False
return await self.save_token(token_key, token_data, additional_seconds)
async def cleanup_expired_sessions(self) -> None:
"""Совместимость - очистка сессий"""
await self.cleanup_expired_tokens()