From 215394c927ae69220a5577040902a8d05ed703bb Mon Sep 17 00:00:00 2001 From: Emilio Reyes Date: Wed, 22 Nov 2023 13:03:18 -0700 Subject: [PATCH] feat: add ability to load certificate from private key (#38) * fix: include Py 3.11 and 3.12 drop 3.7 * feat: add ability to load certificate from private key * doc: document CABundle override options --------- Signed-off-by: Emilio Reyes --- .github/workflows/main.yml | 2 +- README.md | 17 +++++++++++++++-- build.py | 5 +++-- build.sh | 2 +- src/main/python/rest3client/restclient.py | 5 +++-- src/main/python/rest3client/ssladapter.py | 6 ++++-- src/unittest/python/test_RESTclient.py | 15 ++++++++++++++- src/unittest/python/test_SSLAadapter.py | 12 ++++++++++-- 8 files changed, 51 insertions(+), 13 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2e8ac4d..7c56af4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,7 +12,7 @@ jobs: build-images: strategy: matrix: - version: ['3.7', '3.8', '3.9', '3.10'] + version: ['3.8', '3.9', '3.10', '3.11', '3.12'] name: Build Python Docker images runs-on: ubuntu-20.04 steps: diff --git a/README.md b/README.md index 1e37647..d9331f9 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Code Coverage](https://codecov.io/gh/soda480/rest3client/branch/master/graph/badge.svg)](https://codecov.io/gh/soda480/rest3client) [![vulnerabilities](https://img.shields.io/badge/vulnerabilities-None-brightgreen)](https://pypi.org/project/bandit/) [![PyPI version](https://badge.fury.io/py/rest3client.svg)](https://badge.fury.io/py/rest3client) -[![python](https://img.shields.io/badge/python-3.7%20%7C%203.8%20%7C%203.9%20%7C%203.10-teal)](https://www.python.org/downloads/) +[![python](https://img.shields.io/badge/python-3.8%20%7C%203.9%20%7C%203.10%20%7C%203.11%20%7C%203.12-teal)](https://www.python.org/downloads/) rest3client is an abstraction of the HTTP requests library (https://pypi.org/project/requests/) providing a simpler API for consuming HTTP REST APIs. @@ -48,9 +48,12 @@ The examples below show how RESTclient can be used to consume the GitHub REST AP # token authentication >>> client = RESTclient('codecov.io', token='--my-token--') -# certificate-based authentication +# certificate-based authentication using certificate and password >>> client = RESTclient('my-api.my-company.com', certfile='/path/to/my-certificate.pem', certpass='--my-certificate-password--') +# certificate-based authentication using certificate and private key +>>> client = RESTclient('my-api.my-company.com', certfile='/path/to/my-certificate.pem', certkey='/path/to/my-certificate-private.key') + # jwt authentication >>> client = RESTclient('my-api.my-company.com', jwt='--my-jwt--') ``` @@ -154,6 +157,16 @@ export RETRY_CONNECTION_ERROR_WAIT_RANDOM_MIN = 5000 export RETRY_CONNECTION_ERROR_WAIT_RANDOM_MAX = 15000 ``` +#### Certificate Authority (CA) Bundle + +The `rest3client` module's default location for the CA Bundle is `/etc/ssl/certs/ca-certificates.crt`. This location can be overridden in two different ways: + +* setting the `REQUESTS_CA_BUNDLE` environment variable to the desired location +* specifying the `cabundle` parameter to the RESTclient constructor: +```Python +client = RESTclient(bearer_token="--token--", cabundle='/location/to/your/cabundle') +``` + #### Real Eamples See [GitHub3API](https://github.com/soda480/github3api) for an example of how RESTclient can be subclassed to provide further custom functionality for a specific REST API (including retry on exceptions). diff --git a/build.py b/build.py index b306434..e3ed682 100644 --- a/build.py +++ b/build.py @@ -30,7 +30,7 @@ authors = [Author('Emilio Reyes', 'emilio.reyes@intel.com')] summary = 'An abstraction of the requests library providing a simpler API for consuming HTTP REST APIs' url = 'https://github.com/soda480/rest3client' -version = '0.5.1' +version = '0.6.0' default_task = [ 'clean', 'analyze', @@ -67,10 +67,11 @@ def set_properties(project): 'License :: OSI Approved :: Apache Software License', 'Operating System :: POSIX :: Linux', 'Programming Language :: Python', - 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', 'Topic :: Software Development :: Libraries', 'Topic :: Software Development :: Libraries :: Python Modules', 'Topic :: System :: Networking', diff --git a/build.sh b/build.sh index 0da3eeb..3a66308 100755 --- a/build.sh +++ b/build.sh @@ -1,4 +1,4 @@ -versions=( '3.7' '3.8' '3.9' '3.10' ) +versions=( '3.8' '3.9' '3.10' '3.11' '3.12' ) for version in "${versions[@]}"; do docker image build --target build-image --build-arg PYTHON_VERSION=$version -t rest3client:$version . diff --git a/src/main/python/rest3client/restclient.py b/src/main/python/rest3client/restclient.py index c9a1bfd..990f089 100644 --- a/src/main/python/rest3client/restclient.py +++ b/src/main/python/rest3client/restclient.py @@ -68,9 +68,10 @@ def __init__(self, hostname, **kwargs): self.jwt = kwargs.get('jwt') self.certfile = kwargs.get('certfile') + self.certkey = kwargs.get('certkey') self.certpass = kwargs.get('certpass') - if self.certfile and self.certpass: - ssl_adapter = SSLAdapter(certfile=self.certfile, certpass=self.certpass) + if self.certfile and (self.certkey or self.certpass): + ssl_adapter = SSLAdapter(certfile=self.certfile, certkey=self.certkey, certpass=self.certpass) self.session.mount(f'https://{self.hostname}', ssl_adapter) self.retries = kwargs.get('retries', []) diff --git a/src/main/python/rest3client/ssladapter.py b/src/main/python/rest3client/ssladapter.py index dcf4f7f..57ded2d 100644 --- a/src/main/python/rest3client/ssladapter.py +++ b/src/main/python/rest3client/ssladapter.py @@ -26,10 +26,12 @@ class SSLAdapter(requests.adapters.HTTPAdapter): def __init__(self, *args, **kwargs): certfile = kwargs.pop('certfile') - certpass = str(kwargs.pop('certpass', '')) + certkey = kwargs.pop('certkey', None) + certpass = kwargs.pop('certpass', None) ssl_context = ssl.SSLContext() - ssl_context.load_cert_chain(certfile, password=certpass) + ssl_context.load_cert_chain(certfile, keyfile=certkey, password=certpass) self.ssl_context = ssl_context + logger.debug(f'loaded ssl context using certificate: {certfile}') super(SSLAdapter, self).__init__(*args, **kwargs) def init_poolmanager(self, *args, **kwargs): diff --git a/src/unittest/python/test_RESTclient.py b/src/unittest/python/test_RESTclient.py index baae39c..722c35a 100644 --- a/src/unittest/python/test_RESTclient.py +++ b/src/unittest/python/test_RESTclient.py @@ -119,7 +119,20 @@ def test__init_Should_InstantiateSslAdapterAndMountSslAdapterToSession_When_Cert certfile = '--certfile--' certpass = '--certpass--' client = RESTclient('api.name.com', certfile=certfile, certpass=certpass) - ssl_adapter_patch.assert_called_once_with(certfile=certfile, certpass=certpass) + ssl_adapter_patch.assert_called_once_with(certfile=certfile, certkey=None, certpass=certpass) + client.session.mount.assert_called_once_with(f'https://{hostname}', ssl_adapter_patch.return_value) + + @patch('rest3client.restclient.os.access') + @patch('rest3client.restclient.SSLAdapter') + @patch('rest3client.restclient.requests.Session') + def test__init_Should_InstantiateSslAdapterAndMountSslAdapterToSession_When_CertfileCertkey(self, session_patch, ssl_adapter_patch, *patches): + session_mock = Mock() + session_patch.return_value = session_mock + hostname = 'api.name.com' + certfile = '--certfile--' + certkey = '--certkey--' + client = RESTclient('api.name.com', certfile=certfile, certkey=certkey) + ssl_adapter_patch.assert_called_once_with(certfile=certfile, certkey=certkey, certpass=None) client.session.mount.assert_called_once_with(f'https://{hostname}', ssl_adapter_patch.return_value) @patch('rest3client.restclient.os.access', return_value=False) diff --git a/src/unittest/python/test_SSLAadapter.py b/src/unittest/python/test_SSLAadapter.py index 781a7bc..ca3bbd4 100644 --- a/src/unittest/python/test_SSLAadapter.py +++ b/src/unittest/python/test_SSLAadapter.py @@ -38,11 +38,19 @@ def tearDown(self): pass @patch('rest3client.ssladapter.ssl.SSLContext') - def test__init__Should_SetAttributes_When_Called(self, ssl_context_patch, *patches): + def test__init__Should_SetAttributes_When_Certpass(self, ssl_context_patch, *patches): ssl_context_mock = Mock() ssl_context_patch.return_value = ssl_context_mock adapter = SSLAdapter(certfile='-certfile-', certpass='-certpass-') - ssl_context_mock.load_cert_chain.assert_called_once_with('-certfile-', password='-certpass-') + ssl_context_mock.load_cert_chain.assert_called_once_with('-certfile-', keyfile=None, password='-certpass-') + self.assertEqual(adapter.ssl_context, ssl_context_mock) + + @patch('rest3client.ssladapter.ssl.SSLContext') + def test__init__Should_SetAttributes_When_Certkey(self, ssl_context_patch, *patches): + ssl_context_mock = Mock() + ssl_context_patch.return_value = ssl_context_mock + adapter = SSLAdapter(certfile='-certfile-', certkey='-certkey-') + ssl_context_mock.load_cert_chain.assert_called_once_with('-certfile-', keyfile='-certkey-', password=None) self.assertEqual(adapter.ssl_context, ssl_context_mock) @patch('rest3client.ssladapter.requests.adapters.HTTPAdapter.init_poolmanager')