py.lib.aw_web_tools

Yohn Y. 2024-02-27 Parent:d20943f0eadf Child:41a5e209558a

5:4d3b509e0967 0.202402.3 Browse Files

+ Реализация плагина идентификации . Сводим все классы исключений к одному предку для удобства

pyproject.toml setup.py src/aw_web_tools/__init__.py src/aw_web_tools/id_helper.py src/aw_web_tools/jwt.py

     1.1 --- a/pyproject.toml	Tue Feb 27 13:25:16 2024 +0300
     1.2 +++ b/pyproject.toml	Tue Feb 27 19:29:01 2024 +0300
     1.3 @@ -7,7 +7,7 @@
     1.4  
     1.5  [project]
     1.6  name = "aw_web_tools"
     1.7 -version = "0.202402.2"
     1.8 +version = "0.202402.3"
     1.9  requires-python = ">=3.8"
    1.10  dependencies = [
    1.11      "PyJWT>=2.8.0",
     2.1 --- a/setup.py	Tue Feb 27 13:25:16 2024 +0300
     2.2 +++ b/setup.py	Tue Feb 27 19:29:01 2024 +0300
     2.3 @@ -2,7 +2,7 @@
     2.4  
     2.5  setup(
     2.6      name='aw_web_tools',
     2.7 -    version='0.202402.2',
     2.8 +    version='0.202402.3',
     2.9      packages=['aw_web_tools'],
    2.10      package_dir={'aw_web_tools': 'src/aw_web_tools'},
    2.11      url='https://devel.a0fs.ru/py.lib.aw_web_tools/',
     3.1 --- a/src/aw_web_tools/__init__.py	Tue Feb 27 13:25:16 2024 +0300
     3.2 +++ b/src/aw_web_tools/__init__.py	Tue Feb 27 19:29:01 2024 +0300
     3.3 @@ -1,1 +1,6 @@
     3.4  # coding: utf-8
     3.5 +
     3.6 +class Error(Exception):
     3.7 +    """\
     3.8 +    Общий класс ошибок для модуля
     3.9 +    """
    3.10 \ No newline at end of file
     4.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     4.2 +++ b/src/aw_web_tools/id_helper.py	Tue Feb 27 19:29:01 2024 +0300
     4.3 @@ -0,0 +1,353 @@
     4.4 +# coding: utf-8
     4.5 +"""\
     4.6 +Модуль вспомогательных средств обеспечения идентификации пользователей
     4.7 +"""
     4.8 +import bottle
     4.9 +from string import ascii_letters, digits
    4.10 +from typing import Optional, Dict, Iterable, Any
    4.11 +from . import btle_tools as tools
    4.12 +from . import Error
    4.13 +from . import jwt
    4.14 +from .cookie import Cookie
    4.15 +
    4.16 +
    4.17 +SESSION_TIMEOUT = 86400    # Продолжительность сессии по умолчанию.
    4.18 +
    4.19 +
    4.20 +class IDError(Error):
    4.21 +    """\
    4.22 +    Общий класс ошибок идентификации
    4.23 +    """
    4.24 +
    4.25 +
    4.26 +class IDCheckError(IDError):
    4.27 +    """\
    4.28 +    Ошибка проверки ID
    4.29 +    """
    4.30 +
    4.31 +
    4.32 +class IDNotFound(IDCheckError):
    4.33 +    """\
    4.34 +    Запрос не идентифицирован
    4.35 +    """
    4.36 +    def __init__(self):
    4.37 +        super().__init__('Запрос не идентифицирован')
    4.38 +
    4.39 +
    4.40 +class UIDError(IDError):
    4.41 +    """\
    4.42 +    Ошибки при работе с классом пользовательского идентификатора
    4.43 +    """
    4.44 +
    4.45 +
    4.46 +class UID(object):
    4.47 +    """\
    4.48 +    Класс пользовательского идентификатора.
    4.49 +
    4.50 +    Обеспечивает хранение идентификационной информации о пользователе.
    4.51 +    """
    4.52 +    ENV_NAME = 'AW_UID'  # Идентификатор для хранения объекта в контексте запроса.
    4.53 +
    4.54 +    def __init__(self, uname: str, acc_level: int = -1,
    4.55 +                 sess_id: Optional[str] = None,
    4.56 +                 acc_tags: Optional[Iterable[str]] = None,
    4.57 +                 user_meta: Optional[Dict[str, str]] = None
    4.58 +                 ):
    4.59 +        """\
    4.60 +        :param uname: Имя пользователя внутри системы
    4.61 +        :param acc_level: Уровень допуска пользователя. Интерпретация целиком на стороне приложения.
    4.62 +        :param sess_id: Идентификатор сессии пользователя
    4.63 +        :param acc_tags: Строковые константы, определяющие конкретное множество доступных пользователю ресурсов.
    4.64 +                        Интерпретация целиком на стороне приложения
    4.65 +        :param user_meta: Иная полезная приложению информация, сохранённая в виде тег-значение
    4.66 +        """
    4.67 +        self.name = uname
    4.68 +        self.level = acc_level
    4.69 +        self.sess_id = sess_id
    4.70 +        self._access = acc_tags
    4.71 +        self._meta = user_meta
    4.72 +
    4.73 +    def get_fp_id(self) -> str:
    4.74 +        """\
    4.75 +        Преобразует объект в форму, пригодную для получения подписи сессии
    4.76 +        """
    4.77 +        buf = ':'.join(sorted(map(lambda x: str(x).lower().strip(), self.get_access())))
    4.78 +        if self.level >= 0:
    4.79 +            buf += f':{self.level}'
    4.80 +
    4.81 +        return f'{self.name}:{buf}'
    4.82 +
    4.83 +    def __str__(self):
    4.84 +        return self.name
    4.85 +
    4.86 +    def get_access(self):
    4.87 +        """\
    4.88 +        Получить теги доступа пользователя, если они имелись
    4.89 +        """
    4.90 +        if self._access:
    4.91 +            return [i for i in self._access]
    4.92 +
    4.93 +        else:
    4.94 +            return []
    4.95 +
    4.96 +    def get_meta(self):
    4.97 +        """\
    4.98 +        Получить метаданные пользователя, если они имелись.
    4.99 +        """
   4.100 +        if self._meta:
   4.101 +            return dict([(k, v) for k, v in self._meta.items()])
   4.102 +
   4.103 +        else:
   4.104 +            return dict()
   4.105 +
   4.106 +    def to_dict(self) -> Dict[str, str]:
   4.107 +        """\
   4.108 +        Преобразование текущего состояния объекта в массив
   4.109 +        """
   4.110 +        res = dict(
   4.111 +            n=self.name,
   4.112 +            id=self.sess_id
   4.113 +        )
   4.114 +
   4.115 +        if self.level >= 0:
   4.116 +            res['al'] = self.level
   4.117 +
   4.118 +        if self._access:
   4.119 +            res['a'] = self.get_access()
   4.120 +
   4.121 +        if self._meta:
   4.122 +            res['m'] = self.get_meta()
   4.123 +
   4.124 +        return res
   4.125 +
   4.126 +    def to_env(self, request: bottle.BaseRequest = bottle.request):
   4.127 +        """\
   4.128 +        Сохранение своего экземпляра в контекст запроса
   4.129 +        """
   4.130 +        tools.set_env(self.ENV_NAME, self, request=request)
   4.131 +
   4.132 +    @classmethod
   4.133 +    def from_dict(cls, d: Dict[str, Any]):
   4.134 +        """\
   4.135 +        Разбор словаря, созданного методом ``to_dict`` и восстановление из него экземпляра объекта,
   4.136 +        подобного сохранённому
   4.137 +        """
   4.138 +        params = dict()
   4.139 +
   4.140 +        # User name
   4.141 +        uname = d.get('n')
   4.142 +        try:
   4.143 +            uname = str(uname) if uname is not None else ''
   4.144 +
   4.145 +        except (ValueError, TypeError) as e:
   4.146 +            raise UIDError(f'Не удалось представить имя пользователя как строку: name="{uname}" err="{e}"')
   4.147 +
   4.148 +        if not uname:
   4.149 +            raise UIDError(f'Идентификатор пользователя не задан в переданном словаре')
   4.150 +
   4.151 +        params['uname'] = uname
   4.152 +
   4.153 +        # Session Identity
   4.154 +        sess_id = d.get('id')
   4.155 +        if sess_id is not None:
   4.156 +            try:
   4.157 +                sess_id = str(sess_id)
   4.158 +
   4.159 +            except (TypeError, ValueError) as e:
   4.160 +                UIDError(f'Не удалось представить идентификатор сессии как строку: '
   4.161 +                         f' sess_id="{sess_id}" '
   4.162 +                         f' err="{e}"')
   4.163 +
   4.164 +        params['sess_id'] = sess_id
   4.165 +
   4.166 +        # Access Level
   4.167 +        level = d.get('al')
   4.168 +
   4.169 +        if level is not None:
   4.170 +            if type(level) is not int:
   4.171 +                try:
   4.172 +                    level = int(level)
   4.173 +
   4.174 +                except (TypeError, ValueError) as e:
   4.175 +                    raise UIDError(f'Не получилось представить уровень допуска пользователя в качестве числа:'
   4.176 +                                   f' level="{level}" err="{e}"'
   4.177 +                                   )
   4.178 +
   4.179 +            params['acc_level'] = level
   4.180 +
   4.181 +        # Access Tags
   4.182 +        acc_tags = d.get('a')
   4.183 +
   4.184 +        if acc_tags is not None:
   4.185 +            try:
   4.186 +                acc_tags = (t for t in iter(acc_tags))
   4.187 +
   4.188 +            except (ValueError, TypeError) as e:
   4.189 +                raise UIDError(f'Не вышло преобразовать теги доступа к строковому значению: '
   4.190 +                               f' acc_tags="{acc_tags}" '
   4.191 +                               f' err="{e}"')
   4.192 +
   4.193 +            params['acc_tags'] = acc_tags
   4.194 +
   4.195 +        # User Metadata
   4.196 +        u_meta = d.get('m')
   4.197 +
   4.198 +        if u_meta is not None:
   4.199 +            if type(u_meta) is not dict:
   4.200 +                raise UIDError(f'Пользовательские метаданные не имеют нужного типа: {type(u_meta).__name__}')
   4.201 +
   4.202 +            try:
   4.203 +                u_meta = dict((str(k), str(v)) for k, v in u_meta.items())
   4.204 +
   4.205 +            except (TypeError, ValueError) as e:
   4.206 +                raise UIDError(f'Не удалось преобразовать в словарь пользовательские метаданные: {e}')
   4.207 +
   4.208 +            params['user_meta'] = u_meta
   4.209 +
   4.210 +        return cls(**params)
   4.211 +
   4.212 +    @classmethod
   4.213 +    def from_env(cls, request: bottle.BaseRequest = bottle.request):
   4.214 +        """\
   4.215 +        Извлечение экземпляра объекта из контекста запроса, если он там сохранён.
   4.216 +        """
   4.217 +        uid = tools.get_env(cls.ENV_NAME, request=request)
   4.218 +
   4.219 +        if uid is None:
   4.220 +            raise IDNotFound()
   4.221 +
   4.222 +        if type(uid) is not cls:
   4.223 +            raise IDCheckError(f'Неожиданный тип ID: type="{type(uid).__name__}" val="{uid}"')
   4.224 +
   4.225 +        return uid
   4.226 +
   4.227 +
   4.228 +class IDHelper(object):
   4.229 +    """\
   4.230 +    Класс поддержки идентификации
   4.231 +    """
   4.232 +    def __init__(self,
   4.233 +                 app_name: str,
   4.234 +                 sign_secret: str,
   4.235 +                 sess_timeout: int = SESSION_TIMEOUT
   4.236 +                 ):
   4.237 +        """\
   4.238 +        :param app_name: Имя приложения. Допускаются цифры символы латиницы и знак ``-``
   4.239 +        :param sign_secret: Секрет, которым будет подписываться JWT.
   4.240 +        :param sess_timeout: Время жизни сессии пользователя.
   4.241 +        """
   4.242 +        if set(ascii_letters + digits + '-') < set(app_name):
   4.243 +            _buf = ', '.join(map(lambda x: f'"{x}"', set(app_name) - set(ascii_letters + digits + '-')))
   4.244 +            raise IDError(f'Не допустимые символы в имени приложения: {_buf}')
   4.245 +
   4.246 +        self.app_name = app_name
   4.247 +        self.cookie_name = f'X-ID-{self.app_name}'
   4.248 +        self.sess_timeout = sess_timeout
   4.249 +        self.jwt_helper = jwt.JWTHelper(sign_secret)
   4.250 +
   4.251 +        # Свойства необходимые для работы в качестве плагина Bottle
   4.252 +        self.name = 'IDHelper'
   4.253 +        self.api = 2
   4.254 +
   4.255 +    def make_cookie(self, uid: Optional[UID] = None,
   4.256 +                    request: bottle.BaseRequest = bottle.request,
   4.257 +                    is_secure: bool = True,
   4.258 +                    is_httponly: bool = True
   4.259 +                    ) -> Cookie:
   4.260 +        """\
   4.261 +        Формируем ``Cookie`` для идентификации сессии пользователя.
   4.262 +
   4.263 +        :param uid: Экземпляр ``UID``, идентифицирующий сессию. Если нет получаем из контекста запроса
   4.264 +        :param request: Экземпляр запроса, на основании которого формируется ответ.
   4.265 +        :param is_secure: Установить признак ``secure`` на cookie
   4.266 +        :param is_httponly: Установить признак ``httponly`` на cookie
   4.267 +        """
   4.268 +        if uid is None:
   4.269 +            uid = UID.from_env(request=request)
   4.270 +
   4.271 +        uid_dict = uid.to_dict()
   4.272 +        uid_dict['fp'] = tools.get_session_fingerprint(uid.get_fp_id(), request=request)
   4.273 +
   4.274 +        return Cookie(
   4.275 +            name=self.cookie_name,
   4.276 +            value=self.jwt_helper.encode(uid.to_dict(), timeout=self.sess_timeout),
   4.277 +            max_age=self.sess_timeout,
   4.278 +            secure=is_secure,
   4.279 +            httponly=is_httponly
   4.280 +        )
   4.281 +
   4.282 +    def set_id(self, uid: Optional[UID] = None, response: bottle.BaseResponse = bottle.response,
   4.283 +               request: bottle.BaseRequest = bottle.request,
   4.284 +               is_secure: bool = True,
   4.285 +               is_httponly: bool = True
   4.286 +               ):
   4.287 +        """\
   4.288 +        Установка cookie на ответ пользователю
   4.289 +
   4.290 +        :param uid: Объект ``UID``, которым идентифицируется сессия. По умолчанию берётся из контекста запроса.
   4.291 +        :param response: Объект ответа, на который устанавливаем cookie. По умолчанию ``bottle.response``
   4.292 +        :param request: Экземпляр запроса, на основании которого формируется ответ. По умолчанию ``bottle.request``
   4.293 +        :param is_secure: Установить признак ``secure`` на cookie
   4.294 +        :param is_httponly: Установить признак ``httponly`` на cookie
   4.295 +        """
   4.296 +        cookie = self.make_cookie(uid=uid, request=request, is_secure=is_secure, is_httponly=is_httponly)
   4.297 +        cookie.response_add(response)
   4.298 +
   4.299 +    def _get_id_impl(self, request: bottle.BaseRequest = bottle.request):
   4.300 +        """\
   4.301 +        Реализация получения ID и сохранения его в контекст запроса для последующего применения
   4.302 +        в других методах класса.
   4.303 +        """
   4.304 +        sid = tools.get_cookie(self.cookie_name)
   4.305 +        if sid is None:
   4.306 +            raise IDNotFound()
   4.307 +
   4.308 +        try:
   4.309 +            uid_raw = self.jwt_helper.decode(sid, check_timeout=True)
   4.310 +            uid = UID.from_dict(uid_raw)
   4.311 +
   4.312 +            fp = tools.get_session_fingerprint(uid.get_fp_id(), request=request)
   4.313 +
   4.314 +            if fp != uid_raw.get('fp'):
   4.315 +                raise IDCheckError('Проверка подписи сессии не прошла')
   4.316 +
   4.317 +            uid.to_env(request=request)
   4.318 +
   4.319 +        except (jwt.JWTAuthError, UIDError) as e:
   4.320 +            raise IDCheckError(f'Ошибка проверки ID: {e}')
   4.321 +
   4.322 +        except (jwt.JWTError, IDError) as e:
   4.323 +            raise IDError(f'Ошибка в обработке ID запроса: {e}')
   4.324 +
   4.325 +    def need_id(self):
   4.326 +        """\
   4.327 +        Декоратор для конкретных точек входа приложения
   4.328 +        """
   4.329 +        def d(callback):
   4.330 +            def f(*a, **kwa):
   4.331 +                self._get_id_impl()
   4.332 +                return callback(*a, **kwa)
   4.333 +
   4.334 +            return f
   4.335 +
   4.336 +        return d
   4.337 +
   4.338 +    @staticmethod
   4.339 +    def env_id(request: bottle.BaseRequest = bottle.request) -> UID:
   4.340 +        """\
   4.341 +        Получаем ID из контекста запроса (для случая, когда проверка и валидация ID проведена на уровне
   4.342 +        плагина или декоратора
   4.343 +        """
   4.344 +        return UID.from_env(request=request)
   4.345 +
   4.346 +    # Реализация методов для работы в качестве Bottle плагина
   4.347 +    def setup(self, app: bottle.Bottle):
   4.348 +        pass
   4.349 +
   4.350 +    def apply(self, callback: Any, route: bottle.Route):
   4.351 +        def f(*a, **kwa):
   4.352 +            self._get_id_impl()
   4.353 +
   4.354 +            return callback(*a, **kwa)
   4.355 +
   4.356 +        return f
     5.1 --- a/src/aw_web_tools/jwt.py	Tue Feb 27 13:25:16 2024 +0300
     5.2 +++ b/src/aw_web_tools/jwt.py	Tue Feb 27 19:29:01 2024 +0300
     5.3 @@ -4,10 +4,12 @@
     5.4  from datetime import datetime, timedelta
     5.5  from typing import Optional
     5.6  
     5.7 +from . import Error
     5.8 +
     5.9  JWT_HASH_ALGO = 'HS512'
    5.10  
    5.11  
    5.12 -class JWTError(Exception):
    5.13 +class JWTError(Error):
    5.14      pass
    5.15  
    5.16