From c5e4c88588c62386a53cdca733be788ae1176b45 Mon Sep 17 00:00:00 2001 From: "Nick V. Anokhin" Date: Mon, 13 Jul 2026 12:43:19 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D0=B4=D0=B3=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D0=B0=20=D0=BA=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=83=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- net/xeaf/rack/enums/__init__.py | 2 + net/xeaf/rack/enums/auth_reject_reson.py | 42 ++++ net/xeaf/rack/managers/__init__.py | 2 + net/xeaf/rack/managers/account_manager.py | 11 +- net/xeaf/rack/managers/session_manager.py | 89 ++++++-- net/xeaf/rack/tests/managers/__init__.py | 1 + .../tests/managers/locale_manager_tests.py | 2 +- .../tests/managers/sesson_manager_tests.py | 216 ++++++++++++++++++ 8 files changed, 338 insertions(+), 27 deletions(-) create mode 100644 net/xeaf/rack/enums/auth_reject_reson.py create mode 100644 net/xeaf/rack/tests/managers/sesson_manager_tests.py diff --git a/net/xeaf/rack/enums/__init__.py b/net/xeaf/rack/enums/__init__.py index d046872..d29fc0f 100644 --- a/net/xeaf/rack/enums/__init__.py +++ b/net/xeaf/rack/enums/__init__.py @@ -9,3 +9,5 @@ """ from .ui_language import UILanguage +from .auth_reject_reson import AuthRejectReason + diff --git a/net/xeaf/rack/enums/auth_reject_reson.py b/net/xeaf/rack/enums/auth_reject_reson.py new file mode 100644 index 0000000..c3d8aa6 --- /dev/null +++ b/net/xeaf/rack/enums/auth_reject_reson.py @@ -0,0 +1,42 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса AuthRejectReason +""" + +from django.utils.translation import gettext as _ + +from net.xeaf.rack.core import CoreEnum + + +class AuthRejectReason(CoreEnum): + """ + Перечисление причин отказа в авторизации + """ + + INVALID_USERNAME_OR_PASSWORD = "invalid_username_or_password" + """ Некорректный логин или пароль """ + + ACCOUNT_IS_NOT_ACTIVE = "account_is_not_active" + """ Аккаунт не активен """ + + REGISTRATION_IS_NOT_COMPLETE = "registration_is_not_complete" + """ Регистрация не завершена """ + + @classmethod + def get_message(cls, reason: str) -> str: + """ + Возвращает текст сообщения по причине отказа в авторизации + """ + + if reason == cls.INVALID_USERNAME_OR_PASSWORD: + return _("rack.errors.invalid_username_or_password") + elif reason == cls.ACCOUNT_IS_NOT_ACTIVE: + return _("rack.errors.account_is_not_active") + elif reason == cls.REGISTRATION_IS_NOT_COMPLETE: + return _("rack.errors.registration_is_not_complete") + return "" diff --git a/net/xeaf/rack/managers/__init__.py b/net/xeaf/rack/managers/__init__.py index 23187f7..2864759 100644 --- a/net/xeaf/rack/managers/__init__.py +++ b/net/xeaf/rack/managers/__init__.py @@ -8,6 +8,8 @@ Пакет классов менеджеров """ +from .account_manager import AccountManager from .configuration_manager import ConfigurationManager from .crypto_manager import CryptoManager from .locale_manager import LocaleManager +from .session_manager import SessionManager diff --git a/net/xeaf/rack/managers/account_manager.py b/net/xeaf/rack/managers/account_manager.py index 1284627..ab73926 100644 --- a/net/xeaf/rack/managers/account_manager.py +++ b/net/xeaf/rack/managers/account_manager.py @@ -8,10 +8,19 @@ Описание класса AccountManager """ +from net.xeaf.rack.models.account_model import AccountModel + class AccountManager: """ Реализует методы работы с Аккаунтами """ - pass + @classmethod + def restore_account(cls, account: AccountModel) -> None: + """ + Восстанавливает удаленный Аккаунт + """ + + if account.is_deleted(): + account.undelete(save=True) diff --git a/net/xeaf/rack/managers/session_manager.py b/net/xeaf/rack/managers/session_manager.py index 135cf56..a133b10 100644 --- a/net/xeaf/rack/managers/session_manager.py +++ b/net/xeaf/rack/managers/session_manager.py @@ -8,15 +8,20 @@ Описание класса SessionManager """ +from typing import NoReturn from typing import Type from django.apps import apps +from django.contrib.auth import authenticate from environ import ImproperlyConfigured +from net.xeaf.rack.enums import AuthRejectReason from net.xeaf.rack.managers import ConfigurationManager from net.xeaf.rack.managers import CryptoManager from net.xeaf.rack.models import AccountModel from net.xeaf.rack.models import SessionModel +from net.xeaf.rack.utils.exceptions import UnauthorizedException +from .account_manager import AccountManager class SessionManager[T:SessionModel]: @@ -27,21 +32,39 @@ class SessionManager[T:SessionModel]: DEFAULT_SESSION_TTL = 3600 """ Время жизни сессии по умолчанию """ + __model_class: Type[T] | None + """ Хранилище для класса сессии """ + @classmethod - def authenticate(cls, username: str, password: str) -> SessionModel: + def authenticate(cls, username: str, password: str, device_id: str | None = None) -> T: """ Авторизует новую сессию :param username: Имя пользователя :param password: Пароль + :param device_id: Идентификатор устройства :return: Объект данных сессии """ - pass + # noinspection PyTypeChecker + account: AccountModel | None = authenticate(username=username, password=password) + + # Обрабатываем ошибки + if account is None: + cls._raise_auth_error(AuthRejectReason.INVALID_USERNAME_OR_PASSWORD) + elif not account.is_active: + cls._raise_auth_error(AuthRejectReason.ACCOUNT_IS_NOT_ACTIVE) + elif account.joined_at is None: + cls._raise_auth_error(AuthRejectReason.REGISTRATION_IS_NOT_COMPLETE) + + # Создаем и возвращаем объект сессии + AccountManager.restore_account(account) + session = cls.open_session(account, account, device_id) + return session @classmethod - def find_by_token(cls, token: str) -> SessionModel | None: + def find_by_token(cls, token: str) -> T | None: """ Возвращает объект данных сессии по токену @@ -50,7 +73,8 @@ class SessionManager[T:SessionModel]: :return: Объект данных сессии или None, если не найдена или просрочена """ - session = SessionModel.objects.filter(token__exact=token).first() + model = cls._get_session_model_class() + session = model.objects.filter(token__exact=token).first() if session is not None and not session.is_expired(): return session @@ -74,7 +98,17 @@ class SessionManager[T:SessionModel]: return session @classmethod - def renew_session(cls, session: SessionModel) -> None: + def close_session(cls, session: T) -> None: + """ + Закрывает сессию пользователя + + :param session: Модель данных сессии + """ + + session.expire(save=True) + + @classmethod + def renew_session(cls, session: T) -> None: """ Обновляет время существование сессии @@ -84,16 +118,6 @@ class SessionManager[T:SessionModel]: ttl = ConfigurationManager.get_int("SESSION_TTL", cls.DEFAULT_SESSION_TTL) session.renew(ttl, save=True) - @classmethod - def close_session(cls, session: SessionModel) -> None: - """ - Закрывает сессию пользователя - - :param session: Модель данных сессии - """ - - session.expire(save=True) - @classmethod def _create_session_model(cls, account: AccountModel, actual_account: AccountModel) -> T: """ @@ -118,16 +142,31 @@ class SessionManager[T:SessionModel]: :return: Класс модели данных сессии """ - model_path = ConfigurationManager.get_string("AUTH_SESSION_MODEL", "net_xeaf_rack.AccountModel") - app_label, model_name = model_path.split(".") - result = apps.get_model(app_label, model_name) + if cls.__model_class is None: + model_path = ConfigurationManager.get_string("AUTH_SESSION_MODEL", "net_xeaf_rack.SessionModel") + app_label, model_name = model_path.split(".") + model = apps.get_model(app_label, model_name) - # Нет класса - if not result: - raise ImproperlyConfigured("AUTH_SESSION_MODEL must be set in settings.py") + # Нет класса + if not model: + raise ImproperlyConfigured("AUTH_SESSION_MODEL must be set in settings.py") - # Некорректный класс - if not issubclass(result, SessionModel): - raise ImproperlyConfigured(f"AUTH_SESSION_MODEL must be a subclass of {SessionModel.__name__}") + # Некорректный класс + if not issubclass(model, SessionModel): + raise ImproperlyConfigured(f"AUTH_SESSION_MODEL must be a subclass of {SessionModel.__name__}") - return result + cls.__model_class = model + + # noinspection PyTypeChecker + return cls.__model_class + + @classmethod + def _raise_auth_error(cls, reason: AuthRejectReason) -> NoReturn: + """ + Выбрасывает исключение с ошибкой аутентификации + + :param reason: Причина ошибки + """ + + detail = AuthRejectReason.get_message(reason) + raise UnauthorizedException(detail=detail, reason=reason) diff --git a/net/xeaf/rack/tests/managers/__init__.py b/net/xeaf/rack/tests/managers/__init__.py index bec8ead..d96afab 100644 --- a/net/xeaf/rack/tests/managers/__init__.py +++ b/net/xeaf/rack/tests/managers/__init__.py @@ -11,3 +11,4 @@ from .configuration_manager_tests import ConfigurationManagerTests from .crypto_manager_tests import CryptoManagerTests from .locale_manager_tests import LocaleManagerTests +# from .sesson_manager_tests import SessionManagerTests diff --git a/net/xeaf/rack/tests/managers/locale_manager_tests.py b/net/xeaf/rack/tests/managers/locale_manager_tests.py index 9c636b9..3bf889d 100644 --- a/net/xeaf/rack/tests/managers/locale_manager_tests.py +++ b/net/xeaf/rack/tests/managers/locale_manager_tests.py @@ -20,7 +20,7 @@ from net.xeaf.rack.managers import LocaleManager class LocaleManagerTests(TestCase): """ - Тесты для менеджера локализации + Тесты для класса LocaleManager """ def setUp(self): diff --git a/net/xeaf/rack/tests/managers/sesson_manager_tests.py b/net/xeaf/rack/tests/managers/sesson_manager_tests.py new file mode 100644 index 0000000..4b5db10 --- /dev/null +++ b/net/xeaf/rack/tests/managers/sesson_manager_tests.py @@ -0,0 +1,216 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса SessionManagerTest +""" + +import datetime + +from django.core.exceptions import ImproperlyConfigured +from django.test import override_settings +from django.utils import timezone + +from net.xeaf.rack.core import CoreTestCase +from net.xeaf.rack.enums import AuthRejectReason +from net.xeaf.rack.management.userstory import AccountTestData +from net.xeaf.rack.management.userstory import SessionTestData +from net.xeaf.rack.managers import SessionManager +from net.xeaf.rack.models import AccountModel +from net.xeaf.rack.models import SessionModel +from net.xeaf.rack.utils.exceptions import UnauthorizedException + + +class SessionManagerTests(CoreTestCase): + """ + Тесты для класса SessionManager + """ + + def test_should_successfully_authorize_ivanov_with_real_password(self) -> None: + """ + Проверка успешного создания сессии для эталонного аккаунта + """ + + session = SessionManager.authenticate( + username="i.ivanov@xeaf.net", + password=AccountTestData.DEFAULT_PASSWORD, + device_id="mobile_app" + ) + + # Проверяем, что вернулась живая сессия со сгенерированным токеном + self.assertIsNotNone(session) + self.assertEqual(len(session.token), 40) + self.assertEqual(session.account_id, AccountTestData.I_IVANOV_ID) + self.assertEqual(session.actual_account_id, AccountTestData.I_IVANOV_ID) + self.assertEqual(session.device_id, "mobile_app") + + # Честно проверяем запись физически внутри БД + db_session = SessionModel.objects.get(token=session.token) + self.assertEqual(db_session.account_id, AccountTestData.I_IVANOV_ID) + + def test_should_raise_error_when_password_is_incorrect(self) -> None: + """ + Менеджер должен выкинуть исключение ядра при неверном пароле + """ + + with self.assertRaises(UnauthorizedException) as context: + SessionManager.authenticate( + username="i.ivanov@xeaf.net", + password="wrong_password_here" + ) + + self.assertIsInstance(context.exception.meta, dict) + self.assertEqual(context.exception.meta['reason'], AuthRejectReason.INVALID_USERNAME_OR_PASSWORD) + + def test_should_raise_error_when_username_does_not_exist(self) -> None: + """ + Менеджер должен выкинуть исключение ядра, если email не найден + """ + + with self.assertRaises(UnauthorizedException) as context: + SessionManager.authenticate( + username="unknown.user@xeaf.net", + password=AccountTestData.DEFAULT_PASSWORD + ) + + self.assertIsInstance(context.exception.meta, dict) + self.assertEqual(context.exception.meta['reason'], AuthRejectReason.INVALID_USERNAME_OR_PASSWORD) + + def test_should_raise_error_if_account_is_inactive(self) -> None: + """ + Проверка деструктивного изменения БД: отключение аккаунта блокирует вход + """ + + petrov = AccountModel.objects.get(id=AccountTestData.P_PETROV_ID) + petrov.is_active = False + petrov.save() + + with self.assertRaises(UnauthorizedException) as context: + SessionManager.authenticate( + username="p.petrov@xeaf.net", + password=AccountTestData.DEFAULT_PASSWORD + ) + + self.assertIsInstance(context.exception.meta, dict) + self.assertEqual(context.exception.meta['reason'], AuthRejectReason.ACCOUNT_IS_NOT_ACTIVE) + + def test_should_find_real_ivanov_session_by_token(self) -> None: + """ + Поиск существующей и активной сессии по токену из фикстуры + """ + + session = SessionManager.find_by_token(SessionTestData.I_IVANOV_SESSION_TOKEN) + + self.assertIsNotNone(session) + self.assertIsInstance(session, SessionModel) + self.assertEqual(session.token, SessionTestData.I_IVANOV_SESSION_TOKEN) + self.assertEqual(session.account_id, AccountTestData.I_IVANOV_ID) + + def test_should_return_none_for_non_existent_token(self) -> None: + """ + Запрос несуществующего токена должен возвращать None + """ + + result = SessionManager.find_by_token("non_existent_token_garbage_data") + self.assertIsNone(result) + + def test_should_return_none_if_session_is_expired_in_db(self) -> None: + """ + Проверка деструктивного изменения БД: просроченная сессия не считывается + """ + + session = SessionModel.objects.get(token=SessionTestData.P_PETROV_SESSION_TOKEN) + + # Искусственно сдвигаем дату экспирации в далекое прошлое прямо в базе данных + session.expires_at = timezone.now() - datetime.timedelta(days=10) + session.save() + + result = SessionManager.find_by_token(SessionTestData.P_PETROV_SESSION_TOKEN) + self.assertIsNone(result) + + def test_should_fail_finding_if_session_was_physically_deleted_from_db(self) -> None: + """ + Полное физическое удаление строки из БД корректно обрабатывается + """ + + SessionModel.objects.filter(token=SessionTestData.I_IVANOV_SESSION_TOKEN).delete() + + result = SessionManager.find_by_token(SessionTestData.I_IVANOV_SESSION_TOKEN) + self.assertIsNone(result) + + @override_settings(SESSION_TTL=1800) + def test_should_open_session_and_apply_override_settings_ttl(self) -> None: + """ + Открытие сессии должно использовать живой ConfigurationManager без заглушек + """ + + ivanov = AccountModel.objects.get(id=AccountTestData.I_IVANOV_ID) + petrov = AccountModel.objects.get(id=AccountTestData.P_PETROV_ID) + + # Создаем сессию имперсонации (Иванов под видом Петра) + session = SessionManager.open_session(account=ivanov, actual_account=petrov, device_id="tablet_v1") + + # Проверяем, что запись успешно материализовалась в ОЗУ + db_session = SessionModel.objects.get(token=session.token) + self.assertEqual(db_session.account, ivanov) + self.assertEqual(db_session.actual_account, petrov) + self.assertEqual(db_session.device_id, "tablet_v1") + self.assertGreater(db_session.expires_at, timezone.now()) + + def test_should_physically_expire_session_on_close_session(self) -> None: + """ + Закрытие сессии переводит модель в статус expired прямо в БД + """ + + session = SessionModel.objects.get(token=SessionTestData.I_IVANOV_SESSION_TOKEN) + + SessionManager.close_session(session) + + # Перечитываем чистую запись из БД + db_session = SessionModel.objects.get(token=SessionTestData.I_IVANOV_SESSION_TOKEN) + self.assertTrue(db_session.is_expired()) + + @override_settings(SESSION_TTL=9999) + def test_should_extend_expiration_time_on_renew_session(self) -> None: + """ + Обновление сессии сдвигает expires_at вперед на величину из настроек + """ + + session = SessionModel.objects.get(token=SessionTestData.P_PETROV_SESSION_TOKEN) + initial_expires_at = session.expires_at + + SessionManager.renew_session(session) + + db_session = SessionModel.objects.get(token=SessionTestData.P_PETROV_SESSION_TOKEN) + self.assertGreater(db_session.expires_at, initial_expires_at) + + def test_protected_create_session_model_writes_correct_record(self) -> None: + """ + Прямой вызов защищенного метода фабрики для хирургического теста записи + """ + + ivanov = AccountModel.objects.get(id=AccountTestData.I_IVANOV_ID) + + # Запускаем защищенный метод фабрики в обход цепочек обновления TTL + session = SessionManager._create_session_model(account=ivanov, actual_account=ivanov) + + # Доказываем линтеру, что мы получили объект, и проверяем его наличие в БД + self.assertIsInstance(session, SessionModel) + self.assertTrue(SessionModel.objects.filter(token=session.token).exists()) + + @override_settings(AUTH_SESSION_MODEL="invalid_app.MissingModel") + def test_protected_get_session_model_class_raises_on_missing_model(self) -> None: + """ + Защищенный метод разрешения класса должен падать, если модель не найдена + """ + + # Сбрасываем внутренний кэш класса в синглтоне менеджера перед тестом + SessionManager._SessionManager__model_class = None + + with self.assertRaises(ImproperlyConfigured) as context: + SessionManager._get_session_model_class() + + self.assertIn("must be set in settings.py", str(context.exception))