├── LICENSE.md ├── README.md ├── esia ├── __init__.py ├── client.py ├── exceptions.py └── utils.py ├── requirements.txt └── setup.py /LICENSE.md: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2016, Sergey V. Sokolov 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # esia-oauth2 2 | ## Модуль для доступа к ЕСИА REST сервису (https://esia.gosuslugi.ru) 3 | Основан на коде esia-connector https://github.com/eigenmethod/esia-connector, лицензия: https://github.com/eigenmethod/esia-connector/blob/master/LICENSE.txt 4 | 5 | ### Позволяет: 6 | * Сформировать ссылку для перехода на сайт ЕСИА с целью авторизации 7 | * Завершает процедуру авторизации обменивая временный код на access token 8 | * Опционально может производить JWT (JSON Web Token) валидацию ответа ЕСИА (при наличии публичного ключа ЕСИА) 9 | * Для формирования открепленной подписи запросов, в качестве бэкенда может использоваться 10 | модуль M2Crypto или openssl через системный вызов (указывается в настройках) 11 | * Выполнять информационные запросы к ЕСИА REST сервису для получения сведений о персоне: 12 | * Основаная информация 13 | * Адреса 14 | * Контактная информация 15 | * Документы 16 | * Дети 17 | * Транспортные средства 18 | 19 | ### Установка: 20 | ``` 21 | pip install --upgrade git+https://github.com/sokolovs/esia-oauth2.git 22 | pip install -r https://raw.githubusercontent.com/sokolovs/esia-oauth2/master/requirements.txt 23 | ``` 24 | 25 | ### Предварительные условия 26 | 27 | Для работы требуется наличие публичного и приватного ключа в соответствии с методическими рекомендациями 28 | по работе с ЕСИА. Допускается использование самоподписного сертифката, который можно сгенерировать 29 | следующей командой: 30 | ``` 31 | openssl req -x509 -nodes -days 3650 -newkey rsa:2048 -sha1 -keyout my_private.key -out my_public_cert.crt 32 | ``` 33 | 34 | Полученный в результате файл my_public_cert.crt должен быть привязан к информационной системе вашей организации 35 | на сайте Госуслуг, а также направлен вместе с заявкой на доступ к ЕСИА 36 | (подробнее см. документы http://minsvyaz.ru/ru/documents/?words=ЕСИА). 37 | 38 | **Внимание!** С 01 апреля 2020 прекращается поддержка использования самоподписных сертификатов. Необходимо 39 | получить ключ ГОСТ 2012 в одном из сертификационных центров и использовать алгоритм подписи ГОСТ Р 34.10-2012. 40 | Для этого необходимо установить на сервере КриптоПРО CSP, установить контейнер с закрытым ключем, а так же 41 | привязать сертификат связанный с закрытым ключем к своей информационной системе. 42 | 43 | Для валидации ответов от ЕСИА потребуется публичный ключ, который можно запросить в технической поддержке ЕСИА, 44 | уже после регистрации информационной системы и получения доступа к тестовой среде ЕСИА. Валидация опциональна. 45 | 46 | ### Пример использования в Django 47 | 48 | Создайте конфигурационный файл esia.ini следующего содержания: 49 | ``` 50 | [esia] 51 | ### Внимание! Все пути указываются относительно данного файла. 52 | # Базовый адрес сервиса ЕСИА, в данном случае указана тестовая среда 53 | SERVICE_URL: https://esia-portal1.test.gosuslugi.ru 54 | 55 | # Идентификатор информационной системы, указывается в заявке на подключение 56 | CLIENT_ID: MYIS01 57 | 58 | # Публичный ключ/сертфикат (необязателен, используется только для m2crypto или openssl) 59 | CERT_FILE: keys/my_public_cert.crt 60 | 61 | # Приватный ключ (необязателен, используется только для m2crypto или openssl) 62 | PRIV_KEY_FILE: keys/my_private.key 63 | 64 | # Публичный ключ сервиса ЕСИА, для валидации ответов (необязателен) 65 | JWT_CHECK_KEY: keys/esia_test_pub.key 66 | 67 | # Адрес страницы, на которую будет перенаправлен браузер после авторизации в ЕСИА 68 | REDIRECT_URI: http://127.0.0.1:8000/esia/callback/ 69 | 70 | # Адрес страницы, на которую необходимо перенаправить браузер после логаута в ЕСИА (опционально) 71 | LOGOUT_REDIRECT_URI: http://127.0.0.1:8000 72 | 73 | # Список scope через пробел. Указывается в заявке, openid при авторизации обязателен 74 | SCOPE: openid http://esia.gosuslugi.ru/usr_inf 75 | 76 | # Используемый крипто бэкенд: m2crypto, openssl (системный вызов) 77 | # или csp (системный вызов утилиты cryptcp из состава КриптоПРО CSP) 78 | CRYPTO_BACKEND: m2crypto 79 | 80 | # SHA1 отпечаток сертификата связанного с закрытым ключем, смотреть по выводу certmgr --list 81 | # (необязателен, используется только для csp) 82 | CSP_CERT_THUMBPRINT: 5c84a6a58bbeb6578ff7d26f4ea65b6de5f9f5b8 83 | 84 | # Пароль (пин-код) контейнера с закрктым ключем 85 | # (необязателен, используется только для csp) 86 | CSP_CONTAINER_PWD: 12345678 87 | ``` 88 | 89 | В свой urls.py добавьте: 90 | ```python 91 | url(r'^esia/login/$', views.esia_login, name='esia_login'), 92 | url(r'^esia/callback/$', views.esia_callback, name='esia_callback'), 93 | ``` 94 | 95 | В свой views.py добавьте: 96 | ```python 97 | import json 98 | from django.http import HttpResponseRedirect, HttpResponse 99 | from django.contrib.auth.views import logout 100 | from esia.client import EsiaConfig, EsiaAuth 101 | 102 | ESIA_SETTINGS = EsiaConfig('/full/path/to/esia.ini') 103 | 104 | def esia_login(request): 105 | esia_auth = EsiaAuth(ESIA_SETTINGS) 106 | esia_login_url = esia_auth.get_auth_url() 107 | return HttpResponseRedirect(esia_login_url) 108 | 109 | def esia_logout(request): 110 | kwargs = {} 111 | esia_auth = EsiaAuth(ESIA_SETTINGS) 112 | kwargs['next_page'] = esia_auth.get_logout_url() 113 | return logout(request, **kwargs) 114 | 115 | def esia_callback(request): 116 | esia_auth = EsiaAuth(ESIA_SETTINGS) 117 | if request.GET.has_key('error'): 118 | data = { 119 | 'error': request.GET['error'], 120 | 'error_description': request.GET['error_description'], 121 | } 122 | else: 123 | data = [] 124 | code = request.GET['code'] 125 | state = request.GET['state'] 126 | esia_client = esia_auth.complete_authorization(code, state) 127 | # Для отключения JWT валидации ответов ЕСИА, можно так: 128 | # esia_client = esia_auth.complete_authorization(code, state, validate_token=False) 129 | 130 | # Запрос информации о персоне 131 | main_info = esia_client.get_person_main_info() 132 | pers_doc = esia_client.get_person_documents() 133 | pars_addr = esia_client.get_person_addresses() 134 | pers_contacts = esia_client.get_person_contacts() 135 | pers_kids = esia_client.get_person_kids() 136 | pers_trans = esia_client.get_person_transport() 137 | 138 | data.append(main_info) 139 | data.append(pers_doc) 140 | data.append(pars_addr) 141 | data.append(pers_contacts) 142 | data.append(pers_kids) 143 | data.append(pers_trans) 144 | 145 | # Просто выводим информацию. Здесь далее должна идти внутренняя логика авторизации 146 | # вашей информационной системы. 147 | return HttpResponse(json.dumps(data, cls=json.JSONEncoder, ensure_ascii=False, indent=4), 148 | content_type='application/json') 149 | ``` 150 | -------------------------------------------------------------------------------- /esia/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sokolovs/esia-oauth2/6956884a9cbccd7a76aa0213b5bbdbaa4fae1236/esia/__init__.py -------------------------------------------------------------------------------- /esia/client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Код основан на пакете esia-connector 3 | # https://github.com/eigenmethod/esia-connector 4 | # Лицензия: 5 | # https://github.com/eigenmethod/esia-connector/blob/master/LICENSE.txt 6 | # Copyright (c) 2015, Septem Capital 7 | import os 8 | import os.path 9 | import uuid 10 | 11 | try: 12 | from configparser import RawConfigParser 13 | except ImportError: 14 | from ConfigParser import RawConfigParser 15 | 16 | try: 17 | from urllib.parse import quote_plus, urlencode 18 | except ImportError: 19 | from urllib import quote_plus, urlencode 20 | 21 | import jwt 22 | from jwt.exceptions import InvalidTokenError 23 | 24 | from .exceptions import ( 25 | ConfigFileError, CryptoBackendError, IncorrectMarkerError) 26 | 27 | from .utils import get_timestamp, make_request, sign_params 28 | 29 | 30 | class EsiaSettings(object): 31 | def __init__( 32 | self, esia_client_id, redirect_uri, certificate_file, 33 | private_key_file, esia_service_url, esia_scope, 34 | crypto_backend='m2crypto', esia_token_check_key=None, 35 | logout_redirect_uri=None, csp_cert_thumbprint='', 36 | csp_container_pwd='', ssl_verify=True): 37 | """ 38 | Класс настроек ЕСИА 39 | :param str esia_client_id: идентификатор клиента в ЕСИА 40 | (указывается в заявке) 41 | :param str redirect_uri: URI по которому браузер будет перенаправлен 42 | после ввода учетных данны в ЕСИА 43 | :param str certificate_file: путь к сертификату клиента 44 | (прилагается к заявке) 45 | :param str private_key_file: путь к приватному ключу клиента 46 | :param str esia_service_url: базовый URL сервиса ЕСИА 47 | :param str esia_scope: список scope, разделенный пробелами, доступный 48 | клиенту (указывается в заявке) 49 | :param str or None esia_token_check_key: путь к публичному ключу для 50 | проверки JWT (access token) 51 | необходимо запросить у технической поддержки ЕСИА 52 | :param str crypto_backend: optional, задает крипто бэкенд, может 53 | принимать значения: m2crypto, openssl, csp 54 | :param str csp_cert_thumbprint: optional, задает SHA1 отпечаток 55 | сертификата, связанного с контейнером (отображается по выводу 56 | certmgr --list), например: 5c84a6a58bbeb6578ff7d26f4ea65b6de5f9f5b8 57 | :param str csp_container_pwd: optional, пароль для контейнера 58 | закрытого ключа 59 | :param boolean ssl_verify: optional, производить ли верификацию 60 | ssl-сертификата при запросах к сервису ЕСИА? 61 | """ 62 | self.esia_client_id = esia_client_id 63 | self.redirect_uri = redirect_uri 64 | self.certificate_file = certificate_file 65 | self.private_key_file = private_key_file 66 | self.esia_service_url = esia_service_url 67 | self.esia_scope = esia_scope 68 | self.esia_token_check_key = esia_token_check_key 69 | self.crypto_backend = crypto_backend 70 | self.logout_redirect_uri = logout_redirect_uri 71 | self.csp_cert_thumbprint = csp_cert_thumbprint 72 | self.csp_container_pwd = csp_container_pwd 73 | self.ssl_verify = ssl_verify 74 | 75 | if self.crypto_backend == 'csp' and not self.csp_cert_thumbprint: 76 | raise CryptoBackendError( 77 | 'Crypro backend is "csp" but "CSP_CERT_THUMBPRINT" ' 78 | 'variable is empty') 79 | 80 | 81 | class EsiaConfig(EsiaSettings): 82 | def __init__(self, config_file, *args, **kwargs): 83 | """ 84 | Класс настроек ЕСИА на основе конфигурационного файла 85 | 86 | :param str config_file: путь к конфигурационному ini-файлу 87 | :raises ConfigFileError: если указан неверный путь или файл недоступен 88 | для чтения 89 | :raises ConfigParser.*: при ошибках в формате файла или параметра 90 | """ 91 | if os.path.isfile(config_file) and os.access(config_file, os.R_OK): 92 | conf = RawConfigParser() 93 | conf.read(config_file) 94 | base_dir = os.path.dirname(config_file) 95 | 96 | kwargs = { 97 | 'esia_client_id': conf.get('esia', 'CLIENT_ID'), 98 | 'redirect_uri': conf.get('esia', 'REDIRECT_URI'), 99 | 'esia_service_url': conf.get('esia', 'SERVICE_URL'), 100 | 'esia_scope': conf.get('esia', 'SCOPE'), 101 | 'crypto_backend': conf.get('esia', 'CRYPTO_BACKEND'), 102 | 'certificate_file': None, 103 | 'private_key_file': None, 104 | 'csp_cert_thumbprint': None, 105 | 'csp_container_pwd': None, 106 | 'ssl_verify': True 107 | } 108 | 109 | # Openssl, M2Crypto params 110 | if conf.has_option('esia', 'CERT_FILE') and \ 111 | conf.has_option('esia', 'PRIV_KEY_FILE'): 112 | cert_f = conf.get('esia', 'CERT_FILE') 113 | pkey_f = conf.get('esia', 'PRIV_KEY_FILE') 114 | kwargs['certificate_file'] = base_dir + '/' + cert_f 115 | kwargs['private_key_file'] = base_dir + '/' + pkey_f 116 | 117 | # CryptoPro CSP params 118 | if conf.has_option('esia', 'CSP_CERT_THUMBPRINT'): 119 | kwargs['csp_cert_thumbprint'] = conf.get( 120 | 'esia', 'CSP_CERT_THUMBPRINT') 121 | kwargs['csp_container_pwd'] = conf.get( 122 | 'esia', 'CSP_CONTAINER_PWD') 123 | 124 | if conf.has_option('esia', 'JWT_CHECK_KEY'): 125 | token_check_key = conf.get('esia', 'JWT_CHECK_KEY') 126 | kwargs['esia_token_check_key'] = \ 127 | base_dir + '/' + token_check_key 128 | 129 | if conf.has_option('esia', 'LOGOUT_REDIRECT_URI'): 130 | redir = conf.get('esia', 'LOGOUT_REDIRECT_URI') 131 | kwargs['logout_redirect_uri'] = redir 132 | 133 | if conf.has_option('esia', 'SSL_VERIFY'): 134 | ssl_verify = conf.getboolean('esia', 'SSL_VERIFY') 135 | kwargs['ssl_verify'] = ssl_verify 136 | 137 | super(EsiaConfig, self).__init__(*args, **kwargs) 138 | else: 139 | raise ConfigFileError("Config file not exists or not readable!") 140 | 141 | 142 | class EsiaAuth(object): 143 | """ 144 | Класс отвечает за OAuth2 авторизацию черещ ЕСИА 145 | """ 146 | _ESIA_ISSUER_NAME = 'http://esia.gosuslugi.ru/' 147 | _AUTHORIZATION_URL = '/aas/oauth2/ac' 148 | _TOKEN_EXCHANGE_URL = '/aas/oauth2/te' 149 | _LOGOUT_URL = '/idp/ext/Logout' 150 | 151 | def __init__(self, settings): 152 | """ 153 | :param EsiaSettings settings: параметры ЕСИА-клиента 154 | """ 155 | self.settings = settings 156 | 157 | def get_auth_url(self, state=None, redirect_uri=None): 158 | """ 159 | Возвращает URL для перехода к авторизации в ЕСИА или для 160 | автоматического редиректа по данному адресу 161 | 162 | :param str or None state: идентификатор, будет возвращен как 163 | GET параметр в redirected-запросе после авторизации. 164 | :param str or None redirect_uri: URI, по которому будет 165 | перенаправлен браузер после авторизации. 166 | :return: url 167 | :rtype: str 168 | """ 169 | params = { 170 | 'client_id': self.settings.esia_client_id, 171 | 'client_secret': '', 172 | 'redirect_uri': redirect_uri or self.settings.redirect_uri, 173 | 'scope': self.settings.esia_scope, 174 | 'response_type': 'code', 175 | 'state': state or str(uuid.uuid4()), 176 | 'timestamp': get_timestamp(), 177 | 'access_type': 'offline' 178 | } 179 | 180 | params = sign_params( 181 | params, self.settings, 182 | backend=self.settings.crypto_backend) 183 | 184 | # sorted needed to make uri deterministic for tests. 185 | params = urlencode(sorted(params.items())) 186 | 187 | return '{base_url}{auth_url}?{params}'.format( 188 | base_url=self.settings.esia_service_url, 189 | auth_url=self._AUTHORIZATION_URL, 190 | params=params) 191 | 192 | def complete_authorization( 193 | self, code, state, validate_token=True, redirect_uri=None): 194 | """ 195 | Завершает авторизацию. Обменивает полученный code на access token. 196 | При этом может опционально производить JWT-валидацию ответа на основе 197 | публичного ключа ЕСИА. Извлекает из ответа идентификатор пользователя 198 | и возвращает экземпляр ESIAInformationConnector для последующих 199 | обращений за данными пользователя. 200 | 201 | :param str code: Временный код полученный из GET-параметра, 202 | который обменивается на access token 203 | :param str state: UUID запроса полученный из GET-параметра 204 | :param boolean validate_token: производить ли JWT-валидацию 205 | ответа от ЕСИА 206 | :param str or None redirect_uri: URI на который браузер был 207 | перенаправлен после авторизации 208 | :rtype: EsiaInformationConnector 209 | :raises IncorrectJsonError: если ответ содержит невалидный JSON 210 | :raises HttpError: если код HTTP ответа отличен от кода 2XX 211 | :raises IncorrectMarkerError: если validate_token=True и полученный 212 | токен не прошел валидацию 213 | """ 214 | params = { 215 | 'client_id': self.settings.esia_client_id, 216 | 'code': code, 217 | 'grant_type': 'authorization_code', 218 | 'redirect_uri': redirect_uri or self.settings.redirect_uri, 219 | 'timestamp': get_timestamp(), 220 | 'token_type': 'Bearer', 221 | 'scope': self.settings.esia_scope, 222 | 'state': state, 223 | } 224 | 225 | params = sign_params( 226 | params, self.settings, 227 | backend=self.settings.crypto_backend 228 | ) 229 | 230 | url = '{base_url}{token_url}'.format( 231 | base_url=self.settings.esia_service_url, 232 | token_url=self._TOKEN_EXCHANGE_URL 233 | ) 234 | 235 | response_json = make_request( 236 | url=url, method='POST', data=params, 237 | verify=self.settings.ssl_verify) 238 | id_token = response_json['id_token'] 239 | 240 | if validate_token: 241 | payload = self._validate_token(id_token) 242 | else: 243 | payload = self._parse_token(id_token) 244 | 245 | return EsiaInformationConnector( 246 | access_token=response_json['access_token'], 247 | oid=self._get_user_id(payload), 248 | settings=self.settings 249 | ) 250 | 251 | def get_logout_url(self, redirect_uri=None): 252 | """ 253 | Возвращает URL для выхода пользователя из ЕСИА (логаут) 254 | 255 | :param str or None redirect_uri: URI, по которому будет перенаправлен 256 | браузер после логаута 257 | :return: url 258 | :rtype: str 259 | """ 260 | logout_url = '{base_url}{logout_url}?client_id={client_id}'.format( 261 | base_url=self.settings.esia_service_url, 262 | logout_url=self._LOGOUT_URL, 263 | client_id=self.settings.esia_client_id 264 | ) 265 | 266 | redirect = (redirect_uri or self.settings.logout_redirect_uri) 267 | if redirect: 268 | logout_url += '&redirect_url={redirect}'.format( 269 | redirect=quote_plus(redirect)) 270 | 271 | return logout_url 272 | 273 | @staticmethod 274 | def _parse_token(token): 275 | """ 276 | :param str token: токен для декодирования 277 | :rtype: dict 278 | """ 279 | return jwt.decode(token, verify=False) 280 | 281 | @staticmethod 282 | def _get_user_id(payload): 283 | """ 284 | :param dict payload: декодированные данные токена 285 | """ 286 | return payload.get('urn:esia:sbj', {}).get('urn:esia:sbj:oid') 287 | 288 | def _validate_token(self, token): 289 | """ 290 | :param str token: токен для валидации 291 | """ 292 | if self.settings.esia_token_check_key is None: 293 | raise ValueError( 294 | "To validate token you need to specify " 295 | "`esia_token_check_key` in settings!") 296 | 297 | with open(self.settings.esia_token_check_key, 'r') as f: 298 | data = f.read() 299 | 300 | try: 301 | return jwt.decode( 302 | token, key=data, 303 | audience=self.settings.esia_client_id, 304 | issuer=self._ESIA_ISSUER_NAME 305 | ) 306 | except InvalidTokenError as e: 307 | raise IncorrectMarkerError(e) 308 | 309 | 310 | class EsiaInformationConnector(object): 311 | """ 312 | Класс для получения данных от ЕСИА REST сервиса 313 | """ 314 | def __init__(self, access_token, oid, settings): 315 | """ 316 | :param str access_token: access token 317 | :param int oid: идентификатор объекта в ЕСИА 318 | (напрамер идентификатор персоны) 319 | :param EsiaSettings settings: параметры ЕСИА-клиента 320 | """ 321 | self.token = access_token 322 | self.oid = oid 323 | self.settings = settings 324 | self._rest_base_url = '%s/rs' % settings.esia_service_url 325 | 326 | def esia_request(self, endpoint_url, accept_schema=None): 327 | """ 328 | Формирует и направляет запрос к ЕСИА REST сервису и возвращает JSON 329 | 330 | :param str endpoint_url: endpoint URL 331 | :param str or None accept_schema: optional версия схемы ответа 332 | (влияет на формат ответа) 333 | :rtype: dict 334 | :raises IncorrectJsonError: если ответ содержит невалидный JSON 335 | :raises HttpError: если код HTTP ответа отличен от кода 2XX 336 | """ 337 | headers = { 338 | 'Authorization': "Bearer %s" % self.token 339 | } 340 | 341 | if accept_schema: 342 | headers['Accept'] = 'application/json; schema="%s"' % accept_schema 343 | else: 344 | headers['Accept'] = 'application/json' 345 | 346 | return make_request( 347 | url=endpoint_url, headers=headers, 348 | verify=self.settings.ssl_verify) 349 | 350 | def get_person_main_info(self, accept_schema=None): 351 | """ 352 | Возвращает основные сведения о персоне 353 | :rtype: dict 354 | """ 355 | url = '{base}/prns/{oid}'.format( 356 | base=self._rest_base_url, oid=self.oid) 357 | return self.esia_request(endpoint_url=url, accept_schema=accept_schema) 358 | 359 | def get_person_addresses(self, accept_schema=None): 360 | """ 361 | Возвращает адреса персоны 362 | :rtype: dict 363 | """ 364 | url = '{base}/prns/{oid}/addrs?embed=(elements)'.format( 365 | base=self._rest_base_url, oid=self.oid) 366 | return self.esia_request(endpoint_url=url, accept_schema=accept_schema) 367 | 368 | def get_person_contacts(self, accept_schema=None): 369 | """ 370 | Возвращает контактную информацию персоны 371 | :rtype: dict 372 | """ 373 | url = '{base}/prns/{oid}/ctts?embed=(elements)'.format( 374 | base=self._rest_base_url, oid=self.oid) 375 | return self.esia_request(endpoint_url=url, accept_schema=accept_schema) 376 | 377 | def get_person_documents(self, accept_schema=None): 378 | """ 379 | Возвращает документы персоны 380 | :rtype: dict 381 | """ 382 | url = '{base}/prns/{oid}/docs?embed=(elements)'.format( 383 | base=self._rest_base_url, oid=self.oid) 384 | return self.esia_request(endpoint_url=url, accept_schema=accept_schema) 385 | 386 | def get_person_kids(self, accept_schema=None): 387 | """ 388 | Возвращает информацию о детях персоны 389 | :rtype: dict 390 | """ 391 | url = '{base}/prns/{oid}/kids?embed=(elements)'.format( 392 | base=self._rest_base_url, oid=self.oid) 393 | return self.esia_request(endpoint_url=url, accept_schema=accept_schema) 394 | 395 | def get_person_transport(self, accept_schema=None): 396 | """ 397 | Возвращает информацию о транспортных средствах персоны 398 | :rtype: dict 399 | """ 400 | url = '{base}/prns/{oid}//vhls?embed=(elements)'.format( 401 | base=self._rest_base_url, oid=self.oid) 402 | return self.esia_request(endpoint_url=url, accept_schema=accept_schema) 403 | -------------------------------------------------------------------------------- /esia/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Код основан на пакете esia-connector 3 | # https://github.com/eigenmethod/esia-connector 4 | # Лицензия: 5 | # https://github.com/eigenmethod/esia-connector/blob/master/LICENSE.txt 6 | # Copyright (c) 2015, Septem Capital 7 | import jwt 8 | 9 | import requests.exceptions 10 | 11 | 12 | class EsiaError(Exception): 13 | pass 14 | 15 | 16 | class IncorrectJsonError(EsiaError, ValueError): 17 | pass 18 | 19 | 20 | class IncorrectMarkerError(EsiaError, jwt.InvalidTokenError): 21 | pass 22 | 23 | 24 | class HttpError(EsiaError, requests.exceptions.HTTPError): 25 | pass 26 | 27 | 28 | class CryptoBackendError(Exception): 29 | pass 30 | 31 | 32 | class ConfigFileError(Exception): 33 | pass 34 | -------------------------------------------------------------------------------- /esia/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Код основан на пакете esia-connector 3 | # https://github.com/eigenmethod/esia-connector 4 | # Лицензия: 5 | # https://github.com/eigenmethod/esia-connector/blob/master/LICENSE.txt 6 | # Copyright (c) 2015, Septem Capital 7 | import base64 8 | import datetime 9 | import json 10 | import os 11 | import tempfile 12 | 13 | import pytz 14 | 15 | import requests 16 | 17 | from .exceptions import CryptoBackendError, HttpError, IncorrectJsonError 18 | 19 | 20 | def make_request(url, method='GET', headers=None, data=None, verify=True): 21 | """ 22 | Выполняет запрос по заданному URL и возвращает dict на основе JSON-ответа 23 | 24 | :param str url: URL-адрес 25 | :param str method: (optional) HTTP-метод запроса, по умолчанию GET 26 | :param dict headers: (optional) массив HTTP-заголовков, по умолчанию None 27 | :param dict data: (optional) массив данных передаваемых в запросе, 28 | по умолчанию None 29 | :param boolean verify: optional, производить ли верификацию 30 | ssl-сертификата при запросае 31 | :return: dict на основе JSON-ответа 32 | :rtype: dict 33 | :raises HttpError: если выбрасыватеся исключение requests.HTTPError 34 | :raises IncorrectJsonError: если JSON-ответ не может быть 35 | корректно прочитан 36 | """ 37 | try: 38 | response = requests.request( 39 | method, url, headers=headers, data=data, verify=verify) 40 | response.raise_for_status() 41 | return json.loads(response.content) 42 | except requests.HTTPError as e: 43 | raise HttpError(e) 44 | except ValueError as e: 45 | raise IncorrectJsonError(e) 46 | 47 | 48 | def smime_sign(certificate_file, private_key_file, data, backend='m2crypto'): 49 | """ 50 | Подписывает данные в формате SMIME с использование sha256. 51 | В качестве бэкенда используется либо вызов openssl, либо 52 | библиотека M2Crypto 53 | 54 | :param str certificate_file: путь к сертификату 55 | :param str private_key_file: путь к приватному ключу 56 | :param str data: подписываемые данные 57 | :param str backend: (optional) бэкенд, используемый 58 | для подписи (m2crypto|openssl) 59 | :raises CryptoBackendError: если неверно указан backend 60 | :return: открепленная подпись 61 | :rtype: str 62 | """ 63 | if backend == 'm2crypto' or backend is None: 64 | from M2Crypto import SMIME, BIO 65 | 66 | if not isinstance(data, bytes): 67 | data = bytes(data) 68 | 69 | signer = SMIME.SMIME() 70 | signer.load_key(private_key_file, certificate_file) 71 | p7 = signer.sign( 72 | BIO.MemoryBuffer(data), flags=SMIME.PKCS7_DETACHED, algo='sha256') 73 | signed_message = BIO.MemoryBuffer() 74 | p7.write_der(signed_message) 75 | return signed_message.read() 76 | elif backend == 'openssl': 77 | source_file = tempfile.NamedTemporaryFile(mode='w', delete=False) 78 | source_file.write(data) 79 | source_file.close() 80 | source_path = source_file.name 81 | 82 | destination_file = tempfile.NamedTemporaryFile(mode='wb', delete=False) 83 | destination_file.close() 84 | destination_path = destination_file.name 85 | 86 | cmd = ( 87 | 'openssl smime -sign -md sha256 -in {f_in} -signer {cert} -inkey ' 88 | '{key} -out {f_out} -outform DER') 89 | os.system(cmd.format( 90 | f_in=source_path, 91 | cert=certificate_file, 92 | key=private_key_file, 93 | f_out=destination_path, 94 | )) 95 | 96 | signed_message = open(destination_path, 'rb').read() 97 | os.unlink(source_path) 98 | os.unlink(destination_path) 99 | return signed_message 100 | else: 101 | raise CryptoBackendError( 102 | 'Unknown cryptography backend. Use openssl or m2crypto value.') 103 | 104 | 105 | def csp_sign(thumbprint, password, data): 106 | """ 107 | Подписывает данные с использованием ГОСТ Р 34.10-2012 открепленной подписи. 108 | В качестве бэкенда используется утилита cryptcp из ПО КриптоПРО CSP. 109 | 110 | :param str thumbprint: SHA1 отпечаток сертификата, связанного 111 | с зкарытым ключем 112 | :param str password: пароль для контейнера закрытого ключа 113 | :param str data: подписываемые данные 114 | """ 115 | tmp_dir = tempfile.gettempdir() 116 | source_file = tempfile.NamedTemporaryFile( 117 | mode='w', delete=False, dir=tmp_dir) 118 | source_file.write(data) 119 | source_file.close() 120 | source_path = source_file.name 121 | destination_path = source_path + '.sgn' 122 | 123 | cmd = ( 124 | "cryptcp -signf -norev -dir {tmp_dir} -der -strict -cert -detached " 125 | "-thumbprint {thumbprint} -pin '{password}' {f_in} 2>&1 >/dev/null") 126 | os.system(cmd.format( 127 | tmp_dir=tmp_dir, 128 | thumbprint=thumbprint, 129 | password=password, 130 | f_in=source_path 131 | )) 132 | 133 | signed_message = open(destination_path, 'rb').read() 134 | os.unlink(source_path) 135 | os.unlink(destination_path) 136 | return signed_message 137 | 138 | 139 | def sign_params(params, settings, backend='csp'): 140 | """ 141 | Подписывает параметры запроса и добавляет в params ключ client_secret. 142 | Подпись основывается на полях: `scope`, `timestamp`, `client_id`, `state`. 143 | 144 | :param dict params: параметры запроса 145 | :param EsiaSettings settings: настройки модуля ЕСИА 146 | :param str backend: (optional) бэкенд используемый 147 | для подписи (m2crypto|openssl|csp) 148 | :raises CryptoBackendError: если неверно указан backend 149 | :return: подписанные параметры запроса 150 | :rtype: dict 151 | """ 152 | plaintext = params.get('scope', '') + params.get('timestamp', '') + \ 153 | params.get('client_id', '') + params.get('state', '') 154 | if backend == 'csp': 155 | raw_client_secret = csp_sign( 156 | settings.csp_cert_thumbprint, 157 | settings.csp_container_pwd, plaintext) 158 | else: 159 | raw_client_secret = smime_sign( 160 | settings.certificate_file, settings.private_key_file, 161 | plaintext, backend) 162 | params.update( 163 | client_secret=base64.urlsafe_b64encode( 164 | raw_client_secret).decode('utf-8'), 165 | ) 166 | return params 167 | 168 | 169 | def get_timestamp(): 170 | """ 171 | Возвращает текущую дату и время в строковом представлении с указанем зоны 172 | в формате пригодном для использования при взаимодействии с ЕСИА 173 | 174 | :return: текущая дата и время 175 | :rtype: str 176 | """ 177 | return datetime.datetime.now(pytz.utc).\ 178 | strftime('%Y.%m.%d %H:%M:%S %z').strip() 179 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | cryptography==3.3.2 2 | PyJWT==1.4.2 3 | pytz==2016.4 4 | requests==2.21.0 5 | # latest version m2crypto with alter digest alogorithms support for SMIME 6 | git+https://gitlab.com/m2crypto/m2crypto.git 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from distutils.core import setup 3 | 4 | setup( 5 | name='esia-oauth2', 6 | version='1.0.2', 7 | description=( 8 | 'ESIA OAuth2 Connector. Based on esia-connector ' 9 | '[https://github.com/eigenmethod/esia-connector/]'), 10 | author='Sergey V. Sokolov', 11 | author_email='sokolov@aksys.pro', 12 | url='https://github.com/sokolovs/esia-oauth2', 13 | packages=['esia'], 14 | ) 15 | --------------------------------------------------------------------------------