From 49e9fdf27175bb2d9d518de1a05ffc44ab7f1c77 Mon Sep 17 00:00:00 2001 From: Alexey Kuzmenko Date: Wed, 14 Jul 2021 21:39:04 -0700 Subject: [PATCH 1/2] Add SimpleJWT authentication support, cleanups * Transparent support for Django SimpleJWT authentication variant * Tests for SimpleJWT workflow added * Fixed scenario where base URL not ending with slash will create invalid URL paths * unittest2 is for python2 - py3 has `unittest` in standard library * pytest-localserver is dead since 2017 and had a hardcoded certificate which no longer acceptable by OpenSSL, failing SSL tests * Removed unused `_handle_redirect` from RestResource * Removed unneeded `_copy_kwargs` and `_iterator` methods - there's `dict.copy()` since py2.2 * Enforcing consistent use of `requests.Session` in API * Using that session object to store common headers - including auth. * de-generalized self._store - `session` and `base_url` aren't really optional in our case. * `requests` knows how to handle JSON - no need for dumps/loads * centralized SSL exception conversion --- .gitignore | 3 + RELEASE.md | 7 + archfx_cloud/api/connection.py | 271 +++++++++++---------------------- pyproject.toml | 2 + requirements-test.txt | 16 +- setup.py | 2 +- tests/__init__.py | 1 - tests/test_api.py | 82 +++++++++- tests/test_convert.py | 2 +- tests/test_flexible_report.py | 6 +- tests/test_resources.py | 25 +-- tests/test_slugs.py | 4 +- tests/test_ssl_verification.py | 43 +++++- version.py | 2 +- 14 files changed, 236 insertions(+), 230 deletions(-) create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore index b6e4761..2f59dd2 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,6 @@ dmypy.json # Pyre type checker .pyre/ + +# IDEs +.vscode diff --git a/RELEASE.md b/RELEASE.md index eb13386..3263a2c 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -2,6 +2,13 @@ All major changes in each released version of the archfx-cloud plugin are listed here. +## 0.11.0 + +- Support Django SimpleJWT authentication variety +- Fix scenario where base URL not ending with slash will create invalid URL paths +- Use session for storage of authentication headers +- Code modernization and cleanups + ## 0.10.3 - Fix bug where slug expansion produces malformed slugs diff --git a/archfx_cloud/api/connection.py b/archfx_cloud/api/connection.py index 58cfa9f..f71fc85 100644 --- a/archfx_cloud/api/connection.py +++ b/archfx_cloud/api/connection.py @@ -13,14 +13,19 @@ obj_one = api.some_model(1).get() api.logout() """ -import json -import requests import logging -from .exceptions import * +import requests +from .exceptions import ( + ImproperlyConfigured, + HttpClientError, + HttpCouldNotVerifyServerError, + HttpNotFoundError, + HttpServerError, + RestBaseException, +) DOMAIN_NAME = 'https://arch.archfx.io' API_PREFIX = 'api/v1' -DEFAULT_HEADERS = {'Content-Type': 'application/json'} DEFAULT_TOKEN_TYPE = 'jwt' logger = logging.getLogger(__name__) @@ -34,15 +39,10 @@ class RestResource: which may or may not have children. """ - def __init__(self, *args, **kwargs): + def __init__(self, session, base_url, *args, **kwargs): + self._session = session + self._base_url = base_url self._store = kwargs - self._session = kwargs.get('session') - - if self._session is None: - self._session = requests.Session() - - if 'use_token' not in self._store: - self._store['use_token'] = False def __call__(self, id=None): """ @@ -52,53 +52,28 @@ def __call__(self, id=None): a specific resource by it's ID. """ - kwargs = { - 'token': self._store['token'], - 'use_token': self._store['use_token'], - 'token_type': self._store['token_type'], - 'base_url': self._store['base_url'], - 'session': self._session - } - - new_url = self._store['base_url'] - if id is not None: - new_url = '{0}{1}/'.format(new_url, id) + new_url = self._base_url if not new_url.endswith('/'): new_url += '/' - kwargs['base_url'] = new_url + if id: + new_url += f'{id}/' - return self._get_resource(**kwargs) + return self._get_resource(session=self._session, base_url=new_url) def __getattr__(self, item): # Don't allow access to 'private' by convention attributes. if item.startswith("_"): raise AttributeError(item) - kwargs = self._copy_kwargs(self._store) - kwargs.update({'base_url': '{0}{1}/'.format(self._store["base_url"], item)}) - - return self._get_resource(**kwargs) - - def _copy_kwargs(self, dictionary): - kwargs = {} - for key, value in self._iterator(dictionary): - kwargs[key] = value + kwargs = self._store.copy() + return self._get_resource(self._session, f"{self._base_url}{item}/", **kwargs) - return kwargs - - def _iterator(self, d): - """ - Helper to get and a proper dict iterator with Py2k and Py3k - """ - try: - return d.iteritems() - except AttributeError: - return d.items() + def _get_resource(self, session, base_url, **kwargs): + return self.__class__(session, base_url, **kwargs) def _check_for_errors(self, resp, url): - if 400 <= resp.status_code <= 499: exception_class = HttpNotFoundError if resp.status_code == 404 else HttpClientError error_msg = 'Client Error {0}: {1}'.format(resp.status_code, url) @@ -108,29 +83,19 @@ def _check_for_errors(self, resp, url): elif 500 <= resp.status_code <= 599: raise HttpServerError("Server Error %s: %s" % (resp.status_code, url), response=resp, content=resp.content) - def _handle_redirect(self, resp, **kwargs): - # @@@ Hacky, see description in __call__ - resource_obj = self(url_override=resp.headers["location"]) - return resource_obj.get(**kwargs) - def _try_to_serialize_response(self, resp): if resp.status_code in [204, 205]: return - if resp.content: - if type(resp.content) == bytes: - try: - encoding = requests.utils.guess_json_utf(resp.content) - return json.loads(resp.content.decode(encoding)) - except Exception: - return resp.content - return json.loads(resp.content) - else: + if not resp.content: + return resp.content + try: + return resp.json() + except Exception: return resp.content def _process_response(self, resp): - - self._check_for_errors(resp, self.url()) + self._check_for_errors(resp, self._base_url) if 200 <= resp.status_code <= 299: return self._try_to_serialize_response(resp) @@ -138,77 +103,32 @@ def _process_response(self, resp): return # @@@ We should probably do some sort of error here? (Is this even possible?) def url(self): - url = self._store["base_url"] - return url - - def _get_header(self): - headers = DEFAULT_HEADERS.copy() - if self._store['use_token']: - if "token" not in self._store: - raise RestBaseException('No Token') - authorization_str = '{0} {1}'.format(self._store['token_type'], self._store["token"]) - headers['Authorization'] = authorization_str + return self._base_url - return headers - - def get(self, **kwargs): + def _convert_ssl_exception(self, requester, **kwargs): try: - resp = self._session.get(self.url(), headers=self._get_header(), params=kwargs) + return requester(self._base_url, **kwargs) except requests.exceptions.SSLError as err: - raise HttpCouldNotVerifyServerError("Could not verify the server's SSL certificate", err) + raise HttpCouldNotVerifyServerError("Could not verify the server's SSL certificate", err) from err + def get(self, **kwargs): + resp = self._convert_ssl_exception(self._session.get, params=kwargs) return self._process_response(resp) def post(self, data=None, **kwargs): - if data: - payload = json.dumps(data) - else: - payload = None - - try: - resp = self._session.post( - self.url(), data=payload, headers=self._get_header(), params=kwargs) - except requests.exceptions.SSLError as err: - raise HttpCouldNotVerifyServerError("Could not verify the server's SSL certificate", err) - + resp = self._convert_ssl_exception(self._session.post, json=data, params=kwargs) return self._process_response(resp) def patch(self, data=None, **kwargs): - if data: - payload = json.dumps(data) - else: - payload = None - - try: - resp = self._session.patch(self.url(), data=payload, headers=self._get_header(), params=kwargs) - except requests.exceptions.SSLError as err: - raise HttpCouldNotVerifyServerError("Could not verify the server's SSL certificate", err) - + resp = self._convert_ssl_exception(self._session.patch, json=data, params=kwargs) return self._process_response(resp) def put(self, data=None, **kwargs): - if data: - payload = json.dumps(data) - else: - payload = None - - try: - resp = self._session.put(self.url(), data=payload, headers=self._get_header(), params=kwargs) - except requests.exceptions.SSLError as err: - raise HttpCouldNotVerifyServerError("Could not verify the server's SSL certificate", err) - + resp = self._convert_ssl_exception(self._session.put, json=data, params=kwargs) return self._process_response(resp) def delete(self, data=None, **kwargs): - if data: - payload = json.dumps(data) - else: - payload = None - - try: - resp = self._session.delete(self.url(), headers=self._get_header(), data=payload, params=kwargs) - except requests.exceptions.SSLError as err: - raise HttpCouldNotVerifyServerError("Could not verify the server's SSL certificate", err) + resp = self._convert_ssl_exception(self._session.delete, json=data, params=kwargs) if 200 <= resp.status_code <= 299: if resp.status_code == 204: @@ -234,22 +154,9 @@ def upload_fp(self, fp, data=None, **kwargs): 'file': fp } - headers = {} - authorization_str = '{0} {1}'.format(self._store['token_type'], self._store["token"]) - headers['Authorization'] = authorization_str logger.debug('Uploading file to {}'.format(str(kwargs))) - try: - resp = self._session.post( - self.url(), - data=data, - files=files, - headers=headers, - params=kwargs, - ) - except requests.exceptions.SSLError as err: - raise HttpCouldNotVerifyServerError("Could not verify the server's SSL certificate", err) - + resp = self._convert_ssl_exception(self._session.post, data=data, files=files, params=kwargs) return self._process_response(resp) def upload_file(self, filename, data=None, mode='rb', **kwargs): @@ -268,10 +175,7 @@ def upload_file(self, filename, data=None, mode='rb', **kwargs): with open(filename, mode) as fp: return self.upload_fp(fp, data, **kwargs) - raise RestBaseException('Unable to open and/or upload file') - - def _get_resource(self, **kwargs): - return self.__class__(**kwargs) + raise RestBaseException("Unable to open and/or upload file") class _TimeoutHTTPAdapter(requests.adapters.HTTPAdapter): @@ -282,6 +186,7 @@ class _TimeoutHTTPAdapter(requests.adapters.HTTPAdapter): Short answer is that Session() objects don't support timeouts. """ + def __init__(self, timeout=None, *args, **kwargs): self.timeout = timeout super(_TimeoutHTTPAdapter, self).__init__(*args, **kwargs) @@ -293,6 +198,7 @@ def send(self, *args, **kwargs): class Api(object): token = None + refresh_token_data = None token_type = DEFAULT_TOKEN_TYPE domain = DOMAIN_NAME resource_class = RestResource @@ -301,7 +207,7 @@ def __init__(self, domain=None, token_type=None, verify=True, timeout=None, retr if domain: self.domain = domain - self.base_url = '{0}/{1}'.format(self.domain, API_PREFIX) + self.base_url = f"{self.domain}/{API_PREFIX}" self.use_token = True if token_type: self.token_type = token_type @@ -311,53 +217,70 @@ def __init__(self, domain=None, token_type=None, verify=True, timeout=None, retr if retries is not None or timeout is not None: adapter = _TimeoutHTTPAdapter(max_retries=retries, timeout=timeout) - self.session.mount('https://', adapter) - self.session.mount('http://', adapter) + self.session.mount("https://", adapter) + self.session.mount("http://", adapter) + + def _destroy_tokens(self): + self.token = None + self.refresh_token_data = None + self.session.headers.pop("Authorization", "") + + def _validate_and_set_tokens(self, data): + success = False + if isinstance(data, str): + self.token = data + success = True + elif "token" in data: + self.token = data["token"] + success = True + elif "access" in data: + self.token = data["access"] + self.refresh_token_data = data["refresh"] + success = True + if success: + self.session.headers["Authorization"] = f"{self.token_type} {self.token}" + return success def set_token(self, token, token_type=None): - self.token = token if token_type: self.token_type = token_type + if not self._validate_and_set_tokens(token): + raise ImproperlyConfigured(f"Invalid token: %s") - def login(self, password, email): - data = {'email': email, 'password': password} - url = '{0}/{1}'.format(self.base_url, 'auth/login/') - - payload = json.dumps(data) + def url(self, section): + return f"{self.base_url}/{section}/" + def login(self, password, email): try: - r = self.session.post(url, data=payload, headers=DEFAULT_HEADERS) + r = self.session.post(self.url("auth/login"), json={"email": email, "password": password}) except requests.exceptions.SSLError as err: - raise HttpCouldNotVerifyServerError("Could not verify the server's SSL certificate", err) + raise HttpCouldNotVerifyServerError("Could not verify the server's SSL certificate", err) from err if r.status_code == 200: - content = json.loads(r.content.decode()) + content = r.json() if self.token_type in content: - self.token = content[self.token_type] + if not self._validate_and_set_tokens(content[self.token_type]): + logger.warning("Incompatible %s token received from server", self.token_type) self.username = content['username'] logger.debug('Welcome @{0}'.format(self.username)) return True else: - logger.error('Login failed: ' + str(r.status_code) + ' ' + r.content.decode()) + logger.error("Login failed: " + str(r.status_code) + " " + r.content.decode()) return False def logout(self): - url = '{0}/{1}'.format(self.base_url, 'auth/logout/') - headers = DEFAULT_HEADERS.copy() - headers['Authorization'] = '{0} {1}'.format(self.token_type, self.token) - try: - r = self.session.post(url, headers=headers) + r = self.session.post(self.url("auth/logout"), json={}) except requests.exceptions.SSLError as err: - raise HttpCouldNotVerifyServerError("Could not verify the server's SSL certificate", err) + raise HttpCouldNotVerifyServerError("Could not verify the server's SSL certificate", err) from err if r.status_code == 204: logger.debug('Goodbye @{0}'.format(self.username)) self.username = None - self.token = None + self._destroy_tokens() else: - logger.error('Logout failed: ' + str(r.status_code) + ' ' + r.content.decode()) + logger.error('Logout failed: %s %s', r.status_code, r.content.decode()) def refresh_token(self): """ @@ -366,25 +289,24 @@ def refresh_token(self): :return: True if token was refreshed. False otherwise """ assert self.token_type == DEFAULT_TOKEN_TYPE - url = '{0}/{1}'.format(self.base_url, 'auth/api-jwt-refresh/') - - payload = json.dumps({'token': self.token}) + if self.refresh_token_data: + data = {"refresh": self.refresh_token_data} + else: + data = {"token": self.token} try: - r = self.session.post(url, data=payload, headers=DEFAULT_HEADERS) + r = self.session.post(self.url("auth/api-jwt-refresh"), json=data) except requests.exceptions.SSLError as err: - raise HttpCouldNotVerifyServerError("Could not verify the server's SSL certificate", err) + raise HttpCouldNotVerifyServerError("Could not verify the server's SSL certificate", err) from err if r.status_code == 200: - content = json.loads(r.content.decode()) - if 'token' in content: - self.token = content['token'] - + content = r.json() + if self._validate_and_set_tokens(content): logger.info('Token refreshed') return True - logger.error('Token refresh failed: ' + str(r.status_code) + ' ' + r.content.decode()) - self.token = None + logger.error("Token refresh failed: %s %s", r.status_code, r.content.decode()) + self._destroy_tokens() return False def __getattr__(self, item): @@ -398,17 +320,4 @@ def __getattr__(self, item): if item.startswith("_"): raise AttributeError(item) - kwargs = { - 'token': self.token, - 'base_url': self.base_url, - 'use_token': self.use_token, - 'token_type': self.token_type, - 'session': self.session - } - - kwargs.update({'base_url': '{0}/{1}/'.format(kwargs['base_url'], item)}) - - return self._get_resource(**kwargs) - - def _get_resource(self, **kwargs): - return self.resource_class(**kwargs) + return self.resource_class(session=self.session, base_url=self.url(item)) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e34796e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,2 @@ +[tool.black] +line-length = 120 \ No newline at end of file diff --git a/requirements-test.txt b/requirements-test.txt index 847feee..11617b9 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,8 +1,8 @@ -pytest==6.1.1 -pytest-localserver==0.5.0 -pyOpenSSL==19.1.0 -unittest2==1.1 -coverage==5.3 -coveralls==2.1.2 -mock==4.0.2 -requests-mock==1.8.0 +coverage>=5.3 +coveralls>=2.1.2 +mock>=4.0.2 +pytest>=6.1.1 +pytest-httpserver>=1.0.0 +pyOpenSSL>=20.0.0 +requests-mock>=1.8.0 +trustme>=0.8.0 diff --git a/setup.py b/setup.py index c703e4d..65e112b 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ 'python-dateutil', 'pytz', 'msgpack>=1.0.2,<1.1', - 'typedargs>=1.0.0,<2', + 'typedargs>=1.1.2,<2', ], keywords=["iotile", "archfx", "arch", "iiot", "automation"], classifiers=[ diff --git a/tests/__init__.py b/tests/__init__.py index c35039f..b4dcc34 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,4 +1,3 @@ -import os.path import unittest diff --git a/tests/test_api.py b/tests/test_api.py index dbb37aa..d67b73e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,19 +1,16 @@ -import sys +from io import BytesIO import json -import mock import requests import requests_mock -import unittest2 as unittest -import pytest +import unittest -from archfx_cloud.api.connection import Api, RestResource +from archfx_cloud.api.connection import Api from archfx_cloud.api.exceptions import HttpClientError, HttpServerError class ApiTestCase(unittest.TestCase): def test_init(self): - api = Api() self.assertEqual(api.domain, 'https://arch.archfx.io') self.assertEqual(api.base_url, 'https://arch.archfx.io/api/v1') @@ -21,7 +18,6 @@ def test_init(self): self.assertEqual(api.token_type, 'jwt') def test_set_token(self): - api = Api() self.assertEqual(api.token, None) api.set_token('big-token') @@ -47,6 +43,7 @@ def test_login(self, m): self.assertTrue(ok) self.assertEqual(api.username, 'user1') self.assertEqual(api.token, 'big-token') + self.assertIsNone(api.refresh_token_data) @requests_mock.Mocker() def test_logout(self, m): @@ -86,6 +83,69 @@ def test_token_refresh(self, m): api.refresh_token() self.assertEqual(api.token, 'new-token') + @requests_mock.Mocker() + def test_new_jwt_login(self, m): + m.post( + 'http://archfx.test/api/v1/auth/login/', + additional_matcher=lambda request: 'Authorization' not in request.headers, + json={ + 'username': 'user1', + 'jwt': { + 'access': 'access-token', + 'refresh': 'refresh-token', + }, + } + ) + api = Api(domain='http://archfx.test') + ok = api.login(email='user1@test.com', password='pass') + self.assertTrue(ok) + self.assertEqual(api.username, 'user1') + self.assertEqual(api.token, 'access-token') + self.assertEqual(api.refresh_token_data, 'refresh-token') + + @requests_mock.Mocker() + def test_new_jwt_refresh(self, m): + m.post( + 'http://archfx.test/api/v1/auth/login/', + additional_matcher=lambda request: 'Authorization' not in request.headers, + json={ + 'username': 'user1', + 'jwt': { + 'access': 'access-token', + 'refresh': 'refresh-token', + }, + } + ) + api = Api(domain='http://archfx.test') + ok = api.login(email='user1@test.com', password='pass') + self.assertTrue(ok) + + m.post( + 'http://archfx.test/api/v1/auth/api-jwt-refresh/', + additional_matcher=lambda request: request.json()['refresh'] == 'refresh-token', + json={ + 'access': 'new-access-token', + 'refresh': 'new-refresh-token', + } + ) + ok = api.refresh_token() + self.assertTrue(ok) + self.assertEqual(api.token, 'new-access-token') + self.assertEqual(api.refresh_token_data, 'new-refresh-token') + + @requests_mock.Mocker() + def test_upload_fp_content_type(self, m): + """Testing that file upload doesn't accidentally inherit application/json content type""" + m.post( + 'http://archfx.test/api/v1/test/', + additional_matcher=lambda r: 'json' not in r.headers.get('Content-Type'), + json={"result": "ok"}, + ) + + api = Api(domain='http://archfx.test') + resp = api.test.upload_fp(BytesIO(b"test")) # "No mock address" means content-type is broken! + self.assertEqual(resp['result'], 'ok') + @requests_mock.Mocker() def test_get_list(self, m): payload = { @@ -135,6 +195,14 @@ def test_get_detail_with_extra_args(self, m): resp = api.test('my-detail').get(foo='bar') self.assertEqual(resp, {'a': 'b', 'c': 'd'}) + @requests_mock.Mocker() + def test_binary_response_content(self, m): + m.get('http://archfx.test/api/v1/test/my-detail/', content=b'{"a": "b"}') + + api = Api(domain='http://archfx.test') + resp = api.test('my-detail').get(foo='bar') + self.assertEqual(resp, {'a': 'b'}) + @requests_mock.Mocker() def test_post(self, m): payload = { diff --git a/tests/test_convert.py b/tests/test_convert.py index 7d0e776..fdeb8ed 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -1,4 +1,4 @@ -import unittest2 as unittest +import unittest from archfx_cloud.utils.convert import ( int16gid, diff --git a/tests/test_flexible_report.py b/tests/test_flexible_report.py index 3237d5a..bfe6b5b 100644 --- a/tests/test_flexible_report.py +++ b/tests/test_flexible_report.py @@ -1,7 +1,9 @@ -import unittest2 as unittest -import msgpack from datetime import datetime +import unittest + import dateutil.parser +import msgpack + from archfx_cloud.reports.flexible_dictionary import ArchFXFlexibleDictionaryReport from archfx_cloud.reports.report import ArchFXDataPoint diff --git a/tests/test_resources.py b/tests/test_resources.py index f31541b..6e1fb52 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -1,36 +1,23 @@ -import sys import json -import mock import requests import requests_mock -import unittest2 as unittest +import unittest -from archfx_cloud.api.connection import Api, RestResource -from archfx_cloud.api import exceptions +from archfx_cloud.api.connection import RestResource class ResourceTestCase(unittest.TestCase): - def setUp(self): - self.base_resource = RestResource(base_url="http://archfx.test/api/v1/test/", - use_token=True, - token_type='jwt', - token='my-token') + self.base_resource = RestResource( + session=requests.Session(), + base_url="http://archfx.test/api/v1/test/", + ) def test_url(self): url = self.base_resource.url() self.assertEqual(url, 'http://archfx.test/api/v1/test/') - def test_headers(self): - expected_headers = { - 'Content-Type': 'application/json', - 'Authorization': 'jwt my-token' - } - - headers = self.base_resource._get_header() - self.assertEqual(headers, expected_headers) - @requests_mock.Mocker() def test_get_200(self, m): payload = { diff --git a/tests/test_slugs.py b/tests/test_slugs.py index 581c9dd..1bae9db 100644 --- a/tests/test_slugs.py +++ b/tests/test_slugs.py @@ -1,5 +1,6 @@ -import unittest2 as unittest import datetime +import unittest + import pytest from archfx_cloud.utils.slugs import ( @@ -7,7 +8,6 @@ ArchFxDeviceSlug, ArchFxVariableID, ArchFxStreamSlug, - ArchFxStreamerSlug ) ArchFxVariableID_CASES = ( diff --git a/tests/test_ssl_verification.py b/tests/test_ssl_verification.py index 5090373..6d00681 100644 --- a/tests/test_ssl_verification.py +++ b/tests/test_ssl_verification.py @@ -1,15 +1,42 @@ """Tests to ensure that Api() works with unverified remote servers.""" +import re +import ssl + import pytest -import json -from archfx_cloud.api.connection import Api, RestResource -from archfx_cloud.api.exceptions import HttpClientError, HttpServerError, HttpCouldNotVerifyServerError +import trustme + +from archfx_cloud.api.connection import Api +from archfx_cloud.api.exceptions import HttpCouldNotVerifyServerError + + +@pytest.fixture(scope="session") +def ca(): + return trustme.CA() + + +@pytest.fixture(scope="session") +def localhost_cert(ca): + return ca.issue_cert("localhost") + + +@pytest.fixture(scope="session") +def httpserver_ssl_context(localhost_cert): + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + + crt = localhost_cert.cert_chain_pems[0] + key = localhost_cert.private_key_pem + with crt.tempfile() as crt_file, key.tempfile() as key_file: + context.load_cert_chain(crt_file, key_file) + + return context -def test_deny_unverified_by_default(httpsserver): +def test_deny_unverified_by_default(httpserver): """Ensure that we throw an error by default for self-signed servers.""" + httpserver.expect_request(re.compile(".+")).respond_with_data(status=204) - api = Api(domain=httpsserver.url) + api = Api(domain=httpserver.url_for("/")) with pytest.raises(HttpCouldNotVerifyServerError): api.login('test@test.com', 'test') @@ -39,10 +66,12 @@ def test_deny_unverified_by_default(httpsserver): resource.delete() -def test_allow_unverified_option(httpsserver): +def test_allow_unverified_option(httpserver): """Ensure that we allow unverified servers if the user passes a flag.""" + # Any other status will cause some error. 204 silently skips all processing, it seems. + httpserver.expect_request(re.compile(".+")).respond_with_data(status=204) - api = Api(domain=httpsserver.url, verify=False) + api = Api(domain=httpserver.url_for("/"), verify=False) api.login('test@test.com', 'test') api.logout() diff --git a/version.py b/version.py index 2133c70..f5504c3 100644 --- a/version.py +++ b/version.py @@ -1 +1 @@ -version = '0.10.3' +version = '0.11.0' From 828116c899f86eb8b525e8d2996a1d17e3598c49 Mon Sep 17 00:00:00 2001 From: Alexey Kuzmenko Date: Thu, 15 Jul 2021 18:37:06 -0700 Subject: [PATCH 2/2] Make login backwards-compatible. --- archfx_cloud/api/connection.py | 10 ++++++---- tests/test_api.py | 14 +++++--------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/archfx_cloud/api/connection.py b/archfx_cloud/api/connection.py index f71fc85..1b9a3cf 100644 --- a/archfx_cloud/api/connection.py +++ b/archfx_cloud/api/connection.py @@ -15,7 +15,7 @@ """ import logging import requests -from .exceptions import ( +from archfx_cloud.api.exceptions import ( ImproperlyConfigured, HttpClientError, HttpCouldNotVerifyServerError, @@ -258,9 +258,11 @@ def login(self, password, email): if r.status_code == 200: content = r.json() - if self.token_type in content: - if not self._validate_and_set_tokens(content[self.token_type]): - logger.warning("Incompatible %s token received from server", self.token_type) + if access_token := content.get('jwt'): + if not self._validate_and_set_tokens(access_token): + logger.warning(f"Incompatible JWT token received from server: {access_token}") + if refresh_token := content.get('jwt_refresh_token'): + self.refresh_token_data = refresh_token self.username = content['username'] logger.debug('Welcome @{0}'.format(self.username)) diff --git a/tests/test_api.py b/tests/test_api.py index d67b73e..5fa5594 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -84,16 +84,14 @@ def test_token_refresh(self, m): self.assertEqual(api.token, 'new-token') @requests_mock.Mocker() - def test_new_jwt_login(self, m): + def test_login_new_jwt(self, m): m.post( 'http://archfx.test/api/v1/auth/login/', additional_matcher=lambda request: 'Authorization' not in request.headers, json={ 'username': 'user1', - 'jwt': { - 'access': 'access-token', - 'refresh': 'refresh-token', - }, + 'jwt': 'access-token', + 'jwt_refresh_token': 'refresh-token', } ) api = Api(domain='http://archfx.test') @@ -110,10 +108,8 @@ def test_new_jwt_refresh(self, m): additional_matcher=lambda request: 'Authorization' not in request.headers, json={ 'username': 'user1', - 'jwt': { - 'access': 'access-token', - 'refresh': 'refresh-token', - }, + 'jwt': 'access-token', + 'jwt_refresh_token': 'refresh-token', } ) api = Api(domain='http://archfx.test')