gonets/SRV/storage/session_storage.py
2025-10-21 09:20:50 +05:00

266 lines
No EOL
11 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.

import json
import os
import uuid
import logging
from typing import Optional, Dict
from datetime import datetime, timedelta
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class SessionStorage:
"""Хранилище сессий пользователей в JSON-файле.
Назначение:
Лёгкое файловое хранилище для сессионных токенов, сопоставленных с
`client_id` (например, IP клиента). Предназначено для сред без БД и
persistent-кэшей. Обеспечивает базовые операции и авто-очистку протухших
сессий.
Формат данных:
{
"sessions": {
"<session_id>": {
"username": str,
"token": str,
"client_id": str,
"created_at": ISO8601 str,
"expiry": ISO8601 str
},
...
}
}
Политика TTL:
Каждая сессия действует 30 минут. При создании/чтении/удалении вызывается
`clean_expired_sessions()` для удаления протухших записей.
Ограничения:
- Нет файловых блокировок: в многопроцессных конфигурациях возможны гонки.
- Размещение по умолчанию `/etc/gonets/sessions.json` требует прав на запись.
"""
def __init__(self, file_path: str = "/etc/gonets/sessions.json"):
"""Инициализирует хранилище сессий.
Args:
file_path (str, optional): Путь к JSON-файлу для хранения сессий.
По умолчанию "/etc/gonets/sessions.json".
Создаёт файл и директории, если они отсутствуют, с правами доступа 600.
"""
self.file_path = file_path
self._ensure_file_exists()
def _ensure_file_exists(self):
"""Создаёт JSON-файл, если он не существует.
Создаёт родительские директории и файл с начальной структурой данных,
устанавливая права доступа 600.
Raises:
Exception: Если произошла ошибка при создании файла или директории.
"""
try:
logger.info(f"Проверка существования файла {self.file_path}")
if not os.path.exists(self.file_path):
logger.info(f"Создание директории {os.path.dirname(self.file_path)}")
os.makedirs(os.path.dirname(self.file_path), exist_ok=True)
logger.info(f"Создание файла {self.file_path}")
with open(self.file_path, "w") as f:
json.dump({"sessions": {}}, f)
os.chmod(self.file_path, 0o600)
logger.info(f"Создан файл {self.file_path}")
else:
logger.info(f"Файл {self.file_path} уже существует")
except Exception as e:
logger.error(
f"Ошибка при создании файла {self.file_path}: {type(e).__name__} - {str(e)}"
)
raise
def _read_data(self) -> Dict:
"""Читает данные из JSON-файла.
Returns:
Dict: Словарь с данными сессий.
Raises:
Exception: Если произошла ошибка при чтении файла.
"""
try:
with open(self.file_path, "r") as f:
return json.load(f)
except Exception as e:
logger.error(
f"Ошибка при чтении файла {self.file_path}: {type(e).__name__} - {str(e)}"
)
raise
def _write_data(self, data: Dict):
"""Записывает данные в JSON-файл.
Args:
data (Dict): Словарь с данными сессий для записи.
Raises:
Exception: Если произошла ошибка при записи в файл.
"""
try:
with open(self.file_path, "w") as f:
json.dump(data, f, indent=2)
except Exception as e:
logger.error(
f"Ошибка при записи файла {self.file_path}: {type(e).__name__} - {str(e)}"
)
raise
def create_session(self, username: str, token: str, client_id: str) -> str:
"""Создаёт новую сессию и возвращает уникальный session_id.
Args:
username (str): Имя пользователя, для которого создаётся сессия.
token (str): Токен аутентификации.
client_id (str): Идентификатор клиента.
Returns:
str: Уникальный идентификатор сессии (session_id).
Raises:
Exception: Если произошла ошибка при создании или записи сессии.
Note:
Удаляет истекшие сессии перед созданием новой и устанавливает срок
действия 30 минут.
"""
try:
self.clean_expired_sessions()
data = self._read_data()
session_id = str(uuid.uuid4())
created_at = datetime.utcnow().isoformat()
expiry = (datetime.utcnow() + timedelta(minutes=30)).isoformat()
data["sessions"][session_id] = {
"username": username,
"token": token,
"client_id": client_id,
"created_at": created_at,
"expiry": expiry,
}
self._write_data(data)
logger.info(
f"Сессия создана для пользователя {username} с session_id {session_id}"
)
return session_id
except Exception as e:
logger.error(
f"Ошибка при создании сессии для {username}: {type(e).__name__} - {str(e)}"
)
raise
def get_latest_session(self, client_id: str) -> Optional[Dict]:
"""Получает последнюю активную сессию для указанного client_id.
Args:
client_id (str): Идентификатор клиента.
Returns:
Optional[Dict]: Словарь с данными последней активной сессии, включая
"session_id", или None, если сессия не найдена.
Raises:
Exception: Если произошла ошибка при чтении или обработке данных.
Note:
Очищает истекшие сессии перед поиском и возвращает сессию с самым
поздним временем создания.
"""
try:
self.clean_expired_sessions()
data = self._read_data()
latest_session = None
latest_time = None
for session_id, session in data["sessions"].items():
if session["client_id"] == client_id:
expiry = datetime.fromisoformat(session["expiry"])
if datetime.utcnow() > expiry:
logger.info(f"Сессия {session_id} истекла")
del data["sessions"][session_id]
continue
created_at = datetime.fromisoformat(session["created_at"])
if latest_time is None or created_at > latest_time:
latest_time = created_at
latest_session = session
latest_session["session_id"] = session_id
if latest_session:
logger.info(f"Найдена сессия для client_id {client_id}")
return latest_session
logger.info(f"Сессия для client_id {client_id} не найдена")
self._write_data(data)
return None
except Exception as e:
logger.error(
f"Ошибка при получении сессии для client_id {client_id}: {type(e).__name__} - {str(e)}"
)
raise
def delete_session(self, session_id: str):
"""Удаляет сессию по указанному session_id.
Args:
session_id (str): Уникальный идентификатор сессии.
Raises:
Exception: Если произошла ошибка при чтении или записи данных.
Note:
Очищает истекшие сессии перед удалением.
"""
try:
self.clean_expired_sessions()
data = self._read_data()
if session_id in data["sessions"]:
del data["sessions"][session_id]
self._write_data(data)
logger.info(f"Сессия с session_id {session_id} удалена")
except Exception as e:
logger.error(
f"Ошибка при удалении сессии {session_id}: {type(e).__name__} - {str(e)}"
)
raise
def clean_expired_sessions(self):
"""Удаляет все истекшие сессии из хранилища.
Raises:
Exception: Если произошла ошибка при чтении или записи данных.
Note:
Проверяет срок действия каждой сессии и удаляет те, что истекли,
записывая обновлённые данные.
"""
try:
data = self._read_data()
expired_sessions = []
current_time = datetime.utcnow()
for session_id, session in list(data["sessions"].items()):
expiry = datetime.fromisoformat(session["expiry"])
if current_time > expiry:
expired_sessions.append(session_id)
for session_id in expired_sessions:
del data["sessions"][session_id]
logger.info(
f"Удалена истекшая сессия с session_id {session_id}")
if expired_sessions:
self._write_data(data)
logger.info(f"Удалено {len(expired_sessions)} истекших сессий")
else:
logger.debug("Истекшие сессии не найдены")
except Exception as e:
logger.error(
f"Ошибка при очистке истекших сессий: {type(e).__name__} - {str(e)}"
)
raise