py.lib.aw_web_tools
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