diff --git a/kmip/services/server/auth/__init__.py b/kmip/services/server/auth/__init__.py new file mode 100644 index 0000000..c4141a5 --- /dev/null +++ b/kmip/services/server/auth/__init__.py @@ -0,0 +1,34 @@ +# Copyright (c) 2018 The Johns Hopkins University/Applied Physics Laboratory +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from kmip.services.server.auth.api import AuthAPI +from kmip.services.server.auth.slugs import SLUGSConnector + +from kmip.services.server.auth.utils import get_certificate_from_connection +from kmip.services.server.auth.utils import \ + get_client_identity_from_certificate +from kmip.services.server.auth.utils import get_common_names_from_certificate +from kmip.services.server.auth.utils import \ + get_extended_key_usage_from_certificate + + +__all__ = [ + 'AuthAPI', + 'SLUGSConnector', + 'get_certificate_from_connection', + 'get_client_identity_from_certificate', + 'get_common_names_from_certificate', + 'get_extended_key_usage_from_certificate' +] diff --git a/kmip/services/server/auth/api.py b/kmip/services/server/auth/api.py new file mode 100644 index 0000000..536c866 --- /dev/null +++ b/kmip/services/server/auth/api.py @@ -0,0 +1,45 @@ +# Copyright (c) 2018 The Johns Hopkins University/Applied Physics Laboratory +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import abc +import six + + +@six.add_metaclass(abc.ABCMeta) +class AuthAPI: + """ + The base class for an authentication API connector. + """ + + @abc.abstractmethod + def authenticate(self, + connection_certificate=None, + connection_info=None, + request_credentials=None): + """ + Query the configured authentication service with the given credentials. + + Args: + connection_certificate (cryptography.x509.Certificate): An X.509 + certificate object obtained from the connection being + authenticated. Optional, defaults to None. + connection_info (tuple): A tuple of information pertaining to the + connection being authenticated, including the source IP address + and a timestamp (e.g., ('127.0.0.1', 1519759267.467451)). + Optional, defaults to None. + request_credentials (list): A list of KMIP Credential structures + containing credential information to use for authentication. + Optional, defaults to None. + """ diff --git a/kmip/services/server/auth/slugs.py b/kmip/services/server/auth/slugs.py new file mode 100644 index 0000000..0bf15e2 --- /dev/null +++ b/kmip/services/server/auth/slugs.py @@ -0,0 +1,108 @@ +# Copyright (c) 2018 The Johns Hopkins University/Applied Physics Laboratory +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import requests +import six + +from kmip.core import exceptions +from kmip.services.server.auth import api +from kmip.services.server.auth import utils + + +class SLUGSConnector(api.AuthAPI): + """ + An authentication API connector for a SLUGS service. + """ + + def __init__(self, url=None): + """ + Construct a SLUGSConnector. + + Args: + url (string): The base URL for the remote SLUGS instance. Optional, + defaults to None. Required for authentication. + """ + self._url = None + self.users_url = None + self.groups_url = None + + self.url = url + + @property + def url(self): + return self._url + + @url.setter + def url(self, value): + if value is None: + self._url = None + self.users_url = None + self.groups_url = None + elif isinstance(value, six.string_types): + self._url = value + if not self._url.endswith("/"): + self._url += "/" + self.users_url = self._url + "users/{}" + self.groups_url = self.users_url + "/groups" + else: + raise TypeError("URL must be a string.") + + def authenticate(self, + connection_certificate=None, + connection_info=None, + request_credentials=None): + """ + Query the configured SLUGS service with the provided credentials. + + Args: + connection_certificate (cryptography.x509.Certificate): An X.509 + certificate object obtained from the connection being + authenticated. Required for SLUGS authentication. + connection_info (tuple): A tuple of information pertaining to the + connection being authenticated, including the source IP address + and a timestamp (e.g., ('127.0.0.1', 1519759267.467451)). + Optional, defaults to None. Ignored for SLUGS authentication. + request_credentials (list): A list of KMIP Credential structures + containing credential information to use for authentication. + Optional, defaults to None. Ignored for SLUGS authentication. + """ + if (self.users_url is None) or (self.groups_url is None): + raise exceptions.ConfigurationError( + "The SLUGS URL must be specified." + ) + + user_id = utils.get_client_identity_from_certificate( + connection_certificate + ) + + try: + response = requests.get(self.users_url.format(user_id)) + except Exception: + raise exceptions.ConfigurationError( + "A connection could not be established using the SLUGS URL." + ) + if response.status_code == 404: + raise exceptions.PermissionDenied( + "Unrecognized user ID: {}".format(user_id) + ) + + response = requests.get(self.groups_url.format(user_id)) + if response.status_code == 404: + raise exceptions.PermissionDenied( + "Group information could not be retrieved for user ID: " + "{}".format(user_id) + ) + + return user_id, response.json().get('groups') diff --git a/kmip/services/server/auth/utils.py b/kmip/services/server/auth/utils.py new file mode 100644 index 0000000..072eb6d --- /dev/null +++ b/kmip/services/server/auth/utils.py @@ -0,0 +1,75 @@ +# Copyright (c) 2018 The Johns Hopkins University/Applied Physics Laboratory +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from cryptography import x509 +from cryptography.hazmat import backends + +from kmip.core import exceptions + + +def get_certificate_from_connection(connection): + """ + Extract an X.509 certificate from a socket connection. + """ + certificate = connection.getpeercert(binary_form=True) + if certificate: + return x509.load_der_x509_certificate( + certificate, + backends.default_backend() + ) + return None + + +def get_extended_key_usage_from_certificate(certificate): + """ + Given an X.509 certificate, extract and return the extendedKeyUsage + extension. + """ + try: + return certificate.extensions.get_extension_for_oid( + x509.oid.ExtensionOID.EXTENDED_KEY_USAGE + ).value + except x509.ExtensionNotFound: + return None + + +def get_common_names_from_certificate(certificate): + """ + Given an X.509 certificate, extract and return all common names. + """ + + common_names = certificate.subject.get_attributes_for_oid( + x509.oid.NameOID.COMMON_NAME + ) + return [common_name.value for common_name in common_names] + + +def get_client_identity_from_certificate(certificate): + """ + Given an X.509 certificate, extract and return the client identity. + """ + client_ids = get_common_names_from_certificate(certificate) + + if len(client_ids) > 0: + if len(client_ids) > 1: + raise exceptions.PermissionDenied( + "Multiple client identities found." + ) + return client_ids[0] + else: + raise exceptions.PermissionDenied( + "The certificate does not define any subject common names. " + "Client identity unavailable." + ) diff --git a/kmip/tests/unit/services/server/auth/__init__.py b/kmip/tests/unit/services/server/auth/__init__.py new file mode 100644 index 0000000..b3c1fbd --- /dev/null +++ b/kmip/tests/unit/services/server/auth/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) 2018 The Johns Hopkins University/Applied Physics Laboratory +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. diff --git a/kmip/tests/unit/services/server/auth/test_slugs.py b/kmip/tests/unit/services/server/auth/test_slugs.py new file mode 100644 index 0000000..ae4931f --- /dev/null +++ b/kmip/tests/unit/services/server/auth/test_slugs.py @@ -0,0 +1,251 @@ +# Copyright (c) 2018 The Johns Hopkins University/Applied Physics Laboratory +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import mock +import requests +import testtools + +from kmip.core import exceptions +from kmip.services.server import auth + + +class TestSLUGSConnector(testtools.TestCase): + """ + Test suite for the SLUGSConnector. + """ + + def setUp(self): + super(TestSLUGSConnector, self).setUp() + + def tearDown(self): + super(TestSLUGSConnector, self).tearDown() + + def test_init(self): + """ + Test that a SLUGSConnector can be constructed without arguments. + """ + auth.SLUGSConnector() + + def test_init_with_args(self): + """ + Test that a SLUGSConnector can be constructed with arguments. + """ + connector = auth.SLUGSConnector(url='http://127.0.0.1:8080/slugs/') + + self.assertEqual('http://127.0.0.1:8080/slugs/', connector.url) + self.assertEqual( + 'http://127.0.0.1:8080/slugs/users/{}', + connector.users_url + ) + self.assertEqual( + 'http://127.0.0.1:8080/slugs/users/{}/groups', + connector.groups_url + ) + + def test_url_formatting(self): + """ + Test that a URL without a trailing slash is handled properly when used + to set the URL of a SLUGSConnector. + """ + connector = auth.SLUGSConnector(url="http://127.0.0.1:8080/slugs") + + self.assertEqual('http://127.0.0.1:8080/slugs/', connector.url) + self.assertEqual( + 'http://127.0.0.1:8080/slugs/users/{}', + connector.users_url + ) + self.assertEqual( + 'http://127.0.0.1:8080/slugs/users/{}/groups', + connector.groups_url + ) + + connector = auth.SLUGSConnector() + + self.assertEqual(None, connector.url) + self.assertEqual(None, connector.users_url) + self.assertEqual(None, connector.groups_url) + + connector.url = "http://127.0.0.1:8080/slugs" + + self.assertEqual('http://127.0.0.1:8080/slugs/', connector.url) + self.assertEqual( + 'http://127.0.0.1:8080/slugs/users/{}', + connector.users_url + ) + self.assertEqual( + 'http://127.0.0.1:8080/slugs/users/{}/groups', + connector.groups_url + ) + + def test_invalid_url(self): + """ + Test that a TypeError is raised when an invalid value is used to set + the URL of a SLUGSConnector. + """ + kwargs = {'url': 0} + self.assertRaisesRegexp( + TypeError, + "URL must be a string.", + auth.SLUGSConnector, + **kwargs + ) + + connector = auth.SLUGSConnector() + args = (connector, "url", 0) + self.assertRaisesRegexp( + TypeError, + "URL must be a string.", + setattr, + *args + ) + + @mock.patch('requests.get') + @mock.patch( + 'kmip.services.server.auth.utils.get_client_identity_from_certificate' + ) + def test_authenticate(self, mock_get_client_identity, mock_request_get): + """ + Test that a call to authenticate with the SLUGSConnector triggers the + right utility and SLUGS API calls. + """ + mock_get_client_identity.return_value = "John Doe" + + users_response = mock.MagicMock(requests.Response) + users_response.status_code = 200 + groups_response = mock.MagicMock(requests.Response) + groups_response.status_code = 200 + groups_response.json.return_value = {'groups': ['Group A', 'Group B']} + + mock_request_get.side_effect = [users_response, groups_response] + + connector = auth.SLUGSConnector( + url="http://127.0.0.1:8080/test/slugs/" + ) + result = connector.authenticate("test") + + mock_get_client_identity.assert_called_once_with("test") + mock_request_get.assert_any_call( + "http://127.0.0.1:8080/test/slugs/users/John Doe" + ) + mock_request_get.assert_any_call( + "http://127.0.0.1:8080/test/slugs/users/John Doe/groups" + ) + self.assertEqual(('John Doe', ['Group A', 'Group B']), result) + + @mock.patch('requests.get') + @mock.patch( + 'kmip.services.server.auth.utils.get_client_identity_from_certificate' + ) + def test_authenticate_with_url_unset(self, + mock_get_client_identity, + mock_request_get): + """ + Test that a ConfigurationError is raised when attempting to + authenticate with an unset URL. + """ + connector = auth.SLUGSConnector() + + args = ("test", ) + self.assertRaisesRegexp( + exceptions.ConfigurationError, + "The SLUGS URL must be specified.", + connector.authenticate, + *args + ) + + @mock.patch('requests.get') + @mock.patch( + 'kmip.services.server.auth.utils.get_client_identity_from_certificate' + ) + def test_authenticate_with_connection_failure(self, + mock_get_client_identity, + mock_request_get): + """ + Test that a ConfigurationError is raised when attempting to + authenticate with an invalid URL. + """ + mock_get_client_identity.return_value = "John Doe" + mock_request_get.side_effect = [requests.exceptions.ConnectionError()] + + connector = auth.SLUGSConnector( + url="http://127.0.0.1:8080/test/slugs/" + ) + args = ("test", ) + self.assertRaisesRegexp( + exceptions.ConfigurationError, + "A connection could not be established using the SLUGS URL.", + connector.authenticate, + *args + ) + + @mock.patch('requests.get') + @mock.patch( + 'kmip.services.server.auth.utils.get_client_identity_from_certificate' + ) + def test_authenticate_with_users_failure(self, + mock_get_client_identity, + mock_request_get): + """ + Test that a PermissionDenied error is raised when an invalid user ID + is used to query SLUGS. + """ + mock_get_client_identity.return_value = "John Doe" + + users_response = mock.MagicMock(requests.Response) + users_response.status_code = 404 + + mock_request_get.return_value = users_response + + connector = auth.SLUGSConnector( + url="http://127.0.0.1:8080/test/slugs/" + ) + args = ("test", ) + self.assertRaisesRegexp( + exceptions.PermissionDenied, + "Unrecognized user ID: John Doe", + connector.authenticate, + *args + ) + + @mock.patch('requests.get') + @mock.patch( + 'kmip.services.server.auth.utils.get_client_identity_from_certificate' + ) + def test_authenticate_with_groups_failure(self, + mock_get_client_identity, + mock_request_get): + """ + Test that a PermissionDenied error is raised when a groups request to + SLUGS fails. + """ + mock_get_client_identity.return_value = "John Doe" + + users_response = mock.MagicMock(requests.Response) + users_response.status_code = 200 + groups_response = mock.MagicMock(requests.Response) + groups_response.status_code = 404 + + mock_request_get.side_effect = [users_response, groups_response] + + connector = auth.SLUGSConnector( + url="http://127.0.0.1:8080/test/slugs/" + ) + args = ("test", ) + self.assertRaisesRegexp( + exceptions.PermissionDenied, + "Group information could not be retrieved for user ID: John Doe", + connector.authenticate, + *args + ) diff --git a/kmip/tests/unit/services/server/auth/test_utils.py b/kmip/tests/unit/services/server/auth/test_utils.py new file mode 100644 index 0000000..96c0491 --- /dev/null +++ b/kmip/tests/unit/services/server/auth/test_utils.py @@ -0,0 +1,270 @@ +# Copyright (c) 2018 The Johns Hopkins University/Applied Physics Laboratory +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from cryptography import x509 +from cryptography.hazmat import backends +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import rsa + +import datetime +import mock +import ssl +import testtools + +from kmip.core import exceptions +from kmip.services.server.auth import utils + + +class TestUtils(testtools.TestCase): + """ + Test suite for authentication utilities. + """ + + def setUp(self): + super(TestUtils, self).setUp() + + self.certificate_bytes = ( + b'\x30\x82\x03\x7c\x30\x82\x02\x64\xa0\x03\x02\x01\x02\x02\x01\x02' + b'\x30\x0d\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x01\x0b\x05\x00\x30' + b'\x45\x31\x0b\x30\x09\x06\x03\x55\x04\x06\x13\x02\x55\x53\x31\x1f' + b'\x30\x1d\x06\x03\x55\x04\x0a\x13\x16\x54\x65\x73\x74\x20\x43\x65' + b'\x72\x74\x69\x66\x69\x63\x61\x74\x65\x73\x20\x32\x30\x31\x31\x31' + b'\x15\x30\x13\x06\x03\x55\x04\x03\x13\x0c\x54\x72\x75\x73\x74\x20' + b'\x41\x6e\x63\x68\x6f\x72\x30\x1e\x17\x0d\x31\x30\x30\x31\x30\x31' + b'\x30\x38\x33\x30\x30\x30\x5a\x17\x0d\x33\x30\x31\x32\x33\x31\x30' + b'\x38\x33\x30\x30\x30\x5a\x30\x40\x31\x0b\x30\x09\x06\x03\x55\x04' + b'\x06\x13\x02\x55\x53\x31\x1f\x30\x1d\x06\x03\x55\x04\x0a\x13\x16' + b'\x54\x65\x73\x74\x20\x43\x65\x72\x74\x69\x66\x69\x63\x61\x74\x65' + b'\x73\x20\x32\x30\x31\x31\x31\x10\x30\x0e\x06\x03\x55\x04\x03\x13' + b'\x07\x47\x6f\x6f\x64\x20\x43\x41\x30\x82\x01\x22\x30\x0d\x06\x09' + b'\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01\x05\x00\x03\x82\x01\x0f\x00' + b'\x30\x82\x01\x0a\x02\x82\x01\x01\x00\x90\x58\x9a\x47\x62\x8d\xfb' + b'\x5d\xf6\xfb\xa0\x94\x8f\x7b\xe5\xaf\x7d\x39\x73\x20\x6d\xb5\x59' + b'\x0e\xcc\xc8\xc6\xc6\xb4\xaf\xe6\xf2\x67\xa3\x0b\x34\x7a\x73\xe7' + b'\xff\xa4\x98\x44\x1f\xf3\x9c\x0d\x23\x2c\x5e\xaf\x21\xe6\x45\xda' + b'\x04\x6a\x96\x2b\xeb\xd2\xc0\x3f\xcf\xce\x9e\x4e\x60\x6a\x6d\x5e' + b'\x61\x8f\x72\xd8\x43\xb4\x0c\x25\xad\xa7\xe4\x18\xe4\xb8\x1a\xa2' + b'\x09\xf3\xe9\x3d\x5c\x62\xac\xfa\xf4\x14\x5c\x92\xac\x3a\x4e\x3b' + b'\x46\xec\xc3\xe8\xf6\x6e\xa6\xae\x2c\xd7\xac\x5a\x2d\x5a\x98\x6d' + b'\x40\xb6\xe9\x47\x18\xd3\xc1\xa9\x9e\x82\xcd\x1c\x96\x52\xfc\x49' + b'\x97\xc3\x56\x59\xdd\xde\x18\x66\x33\x65\xa4\x8a\x56\x14\xd1\xe7' + b'\x50\x69\x9d\x88\x62\x97\x50\xf5\xff\xf4\x7d\x1f\x56\x32\x00\x69' + b'\x0c\x23\x9c\x60\x1b\xa6\x0c\x82\xba\x65\xa0\xcc\x8c\x0f\xa5\x7f' + b'\x84\x94\x53\x94\xaf\x7c\xfb\x06\x85\x67\x14\xa8\x48\x5f\x37\xbe' + b'\x56\x64\x06\x49\x6c\x59\xc6\xf5\x83\x50\xdf\x74\x52\x5d\x2d\x2c' + b'\x4a\x4b\x82\x4d\xce\x57\x15\x01\xe1\x55\x06\xb9\xfd\x79\x38\x93' + b'\xa9\x82\x8d\x71\x89\xb2\x0d\x3e\x65\xad\xd7\x85\x5d\x6b\x63\x7d' + b'\xca\xb3\x4a\x96\x82\x46\x64\xda\x8b\x02\x03\x01\x00\x01\xa3\x7c' + b'\x30\x7a\x30\x1f\x06\x03\x55\x1d\x23\x04\x18\x30\x16\x80\x14\xe4' + b'\x7d\x5f\xd1\x5c\x95\x86\x08\x2c\x05\xae\xbe\x75\xb6\x65\xa7\xd9' + b'\x5d\xa8\x66\x30\x1d\x06\x03\x55\x1d\x0e\x04\x16\x04\x14\x58\x01' + b'\x84\x24\x1b\xbc\x2b\x52\x94\x4a\x3d\xa5\x10\x72\x14\x51\xf5\xaf' + b'\x3a\xc9\x30\x0e\x06\x03\x55\x1d\x0f\x01\x01\xff\x04\x04\x03\x02' + b'\x01\x06\x30\x17\x06\x03\x55\x1d\x20\x04\x10\x30\x0e\x30\x0c\x06' + b'\x0a\x60\x86\x48\x01\x65\x03\x02\x01\x30\x01\x30\x0f\x06\x03\x55' + b'\x1d\x13\x01\x01\xff\x04\x05\x30\x03\x01\x01\xff\x30\x0d\x06\x09' + b'\x2a\x86\x48\x86\xf7\x0d\x01\x01\x0b\x05\x00\x03\x82\x01\x01\x00' + b'\x35\x87\x97\x16\xe6\x75\x35\xcd\xc0\x12\xff\x96\x5c\x21\x42\xac' + b'\x27\x6b\x32\xbb\x08\x2d\x96\xb1\x70\x41\xaa\x03\x4f\x5a\x3e\xe6' + b'\xb6\xf4\x3e\x68\xb1\xbc\xff\x9d\x10\x73\x64\xae\x9f\xba\x36\x56' + b'\x7c\x05\xf4\x3d\x7c\x51\x47\xbc\x1a\x3d\xee\x3d\x46\x07\xfa\x84' + b'\x88\xd6\xf0\xdd\xc8\xa7\x23\x98\xc6\xca\x45\x4e\x2b\x93\x47\xa8' + b'\xdd\x41\xcd\x0d\x7c\x2a\x21\x57\x3d\x09\x04\xbd\xb2\x6c\x95\xfb' + b'\x1d\x47\x0b\x02\xf8\x4d\x3a\xea\xf8\xb5\xcb\x2b\x1f\xea\x56\x28' + b'\xf4\x62\xa9\x3e\x50\x97\xc0\xb6\xb8\x36\x8e\x76\x0a\x5e\xc0\xae' + b'\x14\xc0\x50\x42\x75\x82\x1a\xbc\x1a\xd6\x0d\x53\xa6\x14\x69\xfd' + b'\x19\x98\x1e\x73\x32\x9d\x81\x66\x66\xb5\xed\xcc\x5c\xfe\x53\xd5' + b'\xc4\x03\xb0\xbe\x80\xfa\xb8\x92\xa0\xc8\xfe\x25\x5f\x21\x3d\x6c' + b'\xea\x50\x6d\x74\x1e\x74\x96\xb0\xd5\xc2\x5d\xa8\x61\xf0\x2f\x5b' + b'\xfe\xac\x0b\x6b\x1e\xd9\x09\x5e\x66\x27\x54\x9a\xbc\xe2\x54\xd3' + b'\xf8\xa0\x47\x97\x20\xda\x24\x53\xa4\xfa\xa7\xff\xc7\x33\x51\x46' + b'\x41\x8c\x36\x8c\xeb\xe9\x29\xc2\xad\x58\x24\x80\x9d\xe8\x04\x6e' + b'\x0b\x06\x63\x30\x13\x2a\x39\x8f\x24\xf2\x74\x9e\x91\xc5\xab\x33' + ) + + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + backend=backends.default_backend() + ) + subject = issuer = x509.Name([ + x509.NameAttribute(x509.NameOID.COMMON_NAME, u"Jane Doe") + ]) + subject_no_common_name = issuer_no_common_name = x509.Name([ + x509.NameAttribute(x509.NameOID.ORGANIZATION_NAME, u"Test, Inc.") + ]) + self.certificate = x509.CertificateBuilder().subject_name( + subject + ).issuer_name( + issuer + ).public_key( + private_key.public_key() + ).serial_number( + x509.random_serial_number() + ).not_valid_before( + datetime.datetime.utcnow() + ).not_valid_after( + datetime.datetime.utcnow() + datetime.timedelta(days=1) + ).add_extension( + x509.ExtendedKeyUsage([x509.ExtendedKeyUsageOID.CLIENT_AUTH]), + critical=True + ).sign(private_key, hashes.SHA256(), backends.default_backend()) + + self.certificate_no_name = x509.CertificateBuilder().subject_name( + subject_no_common_name + ).issuer_name( + issuer_no_common_name + ).public_key( + private_key.public_key() + ).serial_number( + x509.random_serial_number() + ).not_valid_before( + datetime.datetime.utcnow() + ).not_valid_after( + datetime.datetime.utcnow() + datetime.timedelta(days=1) + ).sign(private_key, hashes.SHA256(), backends.default_backend()) + + self.certificate_no_extension = x509.CertificateBuilder().subject_name( + subject + ).issuer_name( + issuer + ).public_key( + private_key.public_key() + ).serial_number( + x509.random_serial_number() + ).not_valid_before( + datetime.datetime.utcnow() + ).not_valid_after( + datetime.datetime.utcnow() + datetime.timedelta(days=1) + ).sign(private_key, hashes.SHA256(), backends.default_backend()) + + def tearDown(self): + super(TestUtils, self).tearDown() + + def test_get_certificate_from_connection(self): + """ + Test that the certificate can be retrieved from a provided connection. + """ + mock_connection = mock.MagicMock(ssl.SSLSocket) + mock_connection.getpeercert.return_value = self.certificate_bytes + result = utils.get_certificate_from_connection( + mock_connection + ) + + self.assertIsInstance(result, x509.Certificate) + + def test_get_certificate_from_connection_with_load_failure(self): + """ + Test that the right value is returned when the certificate cannot be + retrieved from the provided connection. + """ + mock_connection = mock.MagicMock(ssl.SSLSocket) + mock_connection.getpeercert.return_value = None + result = utils.get_certificate_from_connection( + mock_connection + ) + + self.assertEqual(None, result) + + def test_get_extended_key_usage_from_certificate(self): + """ + Test that the ExtendedKeyUsage extension can be retrieved from a + certificate. + """ + extension = utils.get_extended_key_usage_from_certificate( + self.certificate + ) + + self.assertIsInstance(extension, x509.ExtendedKeyUsage) + self.assertIn(x509.ExtendedKeyUsageOID.CLIENT_AUTH, extension) + + def test_get_extended_key_usage_from_certificate_with_no_extension(self): + """ + Test that the right value is returned when the ExtendedKeyUsage + extension cannot be retrieved from a certificate. + """ + extension = utils.get_extended_key_usage_from_certificate( + self.certificate_no_extension + ) + + self.assertEqual(None, extension) + + def test_get_common_names_from_certificate(self): + """ + Test that the common names can be retrieved from a certificate. + """ + common_names = utils.get_common_names_from_certificate( + self.certificate + ) + + self.assertEqual(["Jane Doe"], common_names) + + def test_get_common_names_from_certificate_no_common_names(self): + """ + Test that the right value is returned when no common names can be + retrieved from a certificate. + """ + common_names = utils.get_common_names_from_certificate( + self.certificate_no_name + ) + + self.assertEqual([], common_names) + + def test_get_client_identity_from_certificate(self): + """ + Test that the common names from a certificate can be processed into a + client identity. + """ + result = utils.get_client_identity_from_certificate(self.certificate) + + self.assertEqual("Jane Doe", result) + + @mock.patch( + 'kmip.services.server.auth.utils.get_common_names_from_certificate' + ) + def test_get_client_identity_from_certificate_multiple_names(self, + mock_get): + """ + Test that the a PermissionDenied error is raised if multiple possible + client identities are discovered. + """ + mock_get.return_value = ["John Doe", "Jane Doe"] + + args = ("test", ) + self.assertRaisesRegexp( + exceptions.PermissionDenied, + "Multiple client identities found.", + utils.get_client_identity_from_certificate, + *args + ) + + @mock.patch( + 'kmip.services.server.auth.utils.get_common_names_from_certificate' + ) + def test_get_client_identity_from_certificate_no_names(self, mock_get): + """ + Test that the a PermissionDenied error is raised if no possible client + identities are discovered. + """ + mock_get.return_value = [] + + args = ("test", ) + self.assertRaisesRegexp( + exceptions.PermissionDenied, + "The certificate does not define any subject common names. Client " + "identity unavailable.", + utils.get_client_identity_from_certificate, + *args + ) diff --git a/requirements.txt b/requirements.txt index 3170237..414594c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ cryptography>=1.3 enum34 +requests six>=1.9.0 sqlalchemy>=1.0