diff --git a/doc/source/authentication-plugins.rst b/doc/source/authentication-plugins.rst index 9ddb3750..981865d7 100644 --- a/doc/source/authentication-plugins.rst +++ b/doc/source/authentication-plugins.rst @@ -147,18 +147,17 @@ V3 OAuth 1.0a Plugins There also exists a plugin for OAuth 1.0a authentication. We provide a helper authentication plugin at: -:py:class:`~keystoneauth1.v3.contrib.oauth1.auth.OAuth`. +:py:class:`~keystoneauth1.extras.oauth1.V3OAuth1`. The plugin requires the OAuth consumer's key and secret, as well as the OAuth access token's key and secret. For example:: - >>> from keystoneauth1.v3.contrib.oauth1 import auth + >>> from keystoneauth1.extras import oauth1 >>> from keystoneauth1 import session - >>> from keystoneauth1.v3 import client - >>> a = auth.OAuth('http://my.keystone.com:5000/v3', - ... consumer_key=consumer_id, - ... consumer_secret=consumer_secret, - ... access_key=access_token_key, - ... access_secret=access_token_secret) + >>> a = auth.V3OAuth1('http://my.keystone.com:5000/v3', + ... consumer_key=consumer_id, + ... consumer_secret=consumer_secret, + ... access_key=access_token_key, + ... access_secret=access_token_secret) >>> s = session.Session(auth=a) diff --git a/keystoneauth1/extras/oauth1/__init__.py b/keystoneauth1/extras/oauth1/__init__.py new file mode 100644 index 00000000..a519edd4 --- /dev/null +++ b/keystoneauth1/extras/oauth1/__init__.py @@ -0,0 +1,19 @@ +# 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 keystoneauth1.extras.oauth1 import v3 + +__all__ = ('V3OAuth1Method', 'V3OAuth') + + +V3OAuth1Method = v3.OAuth1Method +V3OAuth1 = v3.OAuth1 diff --git a/keystoneauth1/extras/oauth1/_loading.py b/keystoneauth1/extras/oauth1/_loading.py new file mode 100644 index 00000000..20129f00 --- /dev/null +++ b/keystoneauth1/extras/oauth1/_loading.py @@ -0,0 +1,43 @@ +# 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 keystoneauth1.extras.oauth1 import v3 +from keystoneauth1 import loading + + +# NOTE(jamielennox): This is not a BaseV3Loader because we don't want to +# include the scoping options like project-id in the option list +class V3OAuth1(loading.BaseIdentityLoader): + + @property + def plugin_class(self): + return v3.OAuth1 + + def get_options(self): + options = super(V3OAuth1, self).get_options() + + options.extend([ + loading.Opt('consumer-key', + required=True, + help='OAuth Consumer ID/Key'), + loading.Opt('consumer-secret', + required=True, + help='OAuth Consumer Secret'), + loading.Opt('access-key', + required=True, + help='OAuth Access Key'), + loading.Opt('access-secret', + required=True, + help='OAuth Access Secret'), + ]) + + return options diff --git a/keystoneauth1/extras/oauth1/v3.py b/keystoneauth1/extras/oauth1/v3.py new file mode 100644 index 00000000..bad801b5 --- /dev/null +++ b/keystoneauth1/extras/oauth1/v3.py @@ -0,0 +1,77 @@ +# 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. + +"""Oauth authentication plugins. + +.. warning:: + This module requires installation of an extra package (`oauthlib`) + not installed by default. Without the extra package an import error will + occur. The extra package can be installed using:: + + $ pip install keystoneauth['oauth1'] + +""" + +import logging + +from oauthlib import oauth1 + +from keystoneauth1.identity import v3 + +__all__ = ('OAuth1Method', 'OAuth1') + +LOG = logging.getLogger(__name__) + + +class OAuth1Method(v3.AuthMethod): + """OAuth based authentication method. + + :param string consumer_key: Consumer key. + :param string consumer_secret: Consumer secret. + :param string access_key: Access token key. + :param string access_secret: Access token secret. + """ + + _method_parameters = ['consumer_key', 'consumer_secret', + 'access_key', 'access_secret'] + + def get_auth_data(self, session, auth, headers, **kwargs): + # Add the oauth specific content into the headers + oauth_client = oauth1.Client(self.consumer_key, + client_secret=self.consumer_secret, + resource_owner_key=self.access_key, + resource_owner_secret=self.access_secret, + signature_method=oauth1.SIGNATURE_HMAC) + + o_url, o_headers, o_body = oauth_client.sign(auth.token_url, + http_method='POST') + headers.update(o_headers) + + return 'oauth1', {} + + def get_cache_id_elements(self): + return dict(('oauth1_%s' % p, getattr(self, p)) + for p in self._method_parameters) + + +class OAuth1(v3.AuthConstructor): + + _auth_method_class = OAuth1Method + + def __init__(self, *args, **kwargs): + super(OAuth1, self).__init__(*args, **kwargs) + + if self.has_scope_parameters: + LOG.warning('Scoping parameters such as a project were provided ' + 'to the OAuth1 plugin. Because OAuth1 access is ' + 'always scoped to a project these will be ignored by ' + 'the identity server') diff --git a/keystoneauth1/tests/unit/extras/oauth1/__init__.py b/keystoneauth1/tests/unit/extras/oauth1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/keystoneauth1/tests/unit/extras/oauth1/test_oauth1.py b/keystoneauth1/tests/unit/extras/oauth1/test_oauth1.py new file mode 100644 index 00000000..482c1555 --- /dev/null +++ b/keystoneauth1/tests/unit/extras/oauth1/test_oauth1.py @@ -0,0 +1,117 @@ +# 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 uuid + +from oauthlib import oauth1 +import six +from testtools import matchers + +from keystoneauth1.extras import oauth1 as ksa_oauth1 +from keystoneauth1 import fixture +from keystoneauth1 import session +from keystoneauth1.tests.unit import utils as test_utils + + +class OAuth1AuthTests(test_utils.TestCase): + + TEST_ROOT_URL = 'http://127.0.0.1:5000/' + TEST_URL = '%s%s' % (TEST_ROOT_URL, 'v3') + TEST_TOKEN = uuid.uuid4().hex + + def stub_auth(self, subject_token=None, **kwargs): + if not subject_token: + subject_token = self.TEST_TOKEN + + self.stub_url('POST', ['auth', 'tokens'], + headers={'X-Subject-Token': subject_token}, **kwargs) + + def _validate_oauth_headers(self, auth_header, oauth_client): + """Validate data in the headers. + + Assert that the data in the headers matches the data + that is produced from oauthlib. + """ + self.assertThat(auth_header, matchers.StartsWith('OAuth ')) + parameters = dict( + oauth1.rfc5849.utils.parse_authorization_header(auth_header)) + + self.assertEqual('HMAC-SHA1', parameters['oauth_signature_method']) + self.assertEqual('1.0', parameters['oauth_version']) + self.assertIsInstance(parameters['oauth_nonce'], six.string_types) + self.assertEqual(oauth_client.client_key, + parameters['oauth_consumer_key']) + if oauth_client.resource_owner_key: + self.assertEqual(oauth_client.resource_owner_key, + parameters['oauth_token'],) + if oauth_client.verifier: + self.assertEqual(oauth_client.verifier, + parameters['oauth_verifier']) + if oauth_client.callback_uri: + self.assertEqual(oauth_client.callback_uri, + parameters['oauth_callback']) + return parameters + + def test_oauth_authenticate_success(self): + consumer_key = uuid.uuid4().hex + consumer_secret = uuid.uuid4().hex + access_key = uuid.uuid4().hex + access_secret = uuid.uuid4().hex + + oauth_token = fixture.V3Token(methods=['oauth1'], + oauth_consumer_id=consumer_key, + oauth_access_token_id=access_key) + oauth_token.set_project_scope() + + self.stub_auth(json=oauth_token) + + a = ksa_oauth1.V3OAuth1(self.TEST_URL, + consumer_key=consumer_key, + consumer_secret=consumer_secret, + access_key=access_key, + access_secret=access_secret) + + s = session.Session(auth=a) + t = s.get_token() + + self.assertEqual(self.TEST_TOKEN, t) + + OAUTH_REQUEST_BODY = { + "auth": { + "identity": { + "methods": ["oauth1"], + "oauth1": {} + } + } + } + + self.assertRequestBodyIs(json=OAUTH_REQUEST_BODY) + + # Assert that the headers have the same oauthlib data + req_headers = self.requests_mock.last_request.headers + oauth_client = oauth1.Client(consumer_key, + client_secret=consumer_secret, + resource_owner_key=access_key, + resource_owner_secret=access_secret, + signature_method=oauth1.SIGNATURE_HMAC) + self._validate_oauth_headers(req_headers['Authorization'], + oauth_client) + + def test_warning_dual_scope(self): + ksa_oauth1.V3OAuth1(self.TEST_URL, + consumer_key=uuid.uuid4().hex, + consumer_secret=uuid.uuid4().hex, + access_key=uuid.uuid4().hex, + access_secret=uuid.uuid4().hex, + project_id=uuid.uuid4().hex) + + self.assertIn('ignored by the identity server', self.logger.output) diff --git a/keystoneauth1/tests/unit/extras/oauth1/test_oauth1_loading.py b/keystoneauth1/tests/unit/extras/oauth1/test_oauth1_loading.py new file mode 100644 index 00000000..ef3ffc05 --- /dev/null +++ b/keystoneauth1/tests/unit/extras/oauth1/test_oauth1_loading.py @@ -0,0 +1,57 @@ +# 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 uuid + +from keystoneauth1 import loading +from keystoneauth1.tests.unit import utils as test_utils + + +class OAuth1LoadingTests(test_utils.TestCase): + + def setUp(self): + super(OAuth1LoadingTests, self).setUp() + self.auth_url = uuid.uuid4().hex + + def create(self, **kwargs): + kwargs.setdefault('auth_url', self.auth_url) + loader = loading.get_plugin_loader('v3oauth1') + return loader.load_from_options(**kwargs) + + def test_basic(self): + access_key = uuid.uuid4().hex + access_secret = uuid.uuid4().hex + consumer_key = uuid.uuid4().hex + consumer_secret = uuid.uuid4().hex + + p = self.create(access_key=access_key, + access_secret=access_secret, + consumer_key=consumer_key, + consumer_secret=consumer_secret) + + oauth_method = p.auth_methods[0] + + self.assertEqual(self.auth_url, p.auth_url) + self.assertEqual(access_key, oauth_method.access_key) + self.assertEqual(access_secret, oauth_method.access_secret) + self.assertEqual(consumer_key, oauth_method.consumer_key) + self.assertEqual(consumer_secret, oauth_method.consumer_secret) + + def test_options(self): + options = loading.get_plugin_loader('v3oauth1').get_options() + + self.assertEqual(set([o.name for o in options]), + set(['auth-url', + 'access-key', + 'access-secret', + 'consumer-key', + 'consumer-secret'])) diff --git a/setup.cfg b/setup.cfg index 80612d43..e1c9bdb5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -27,6 +27,8 @@ kerberos = requests-kerberos>=0.6:python_version=='2.7' or python_version=='2.6' # MIT saml2 = lxml>=2.3 # BSD +oauth1 = + oauthlib>=0.6 # BSD betamax = betamax>=0.6.0 # Apache-2.0 fixtures<2.0,>=1.3.1 # Apache-2.0/BSD @@ -44,6 +46,7 @@ keystoneauth1.plugin = v3token = keystoneauth1.loading._plugins.identity.v3:Token v3oidcpassword = keystoneauth1.loading._plugins.identity.v3:OpenIDConnectPassword v3oidcauthcode = keystoneauth1.loading._plugins.identity.v3:OpenIDConnectAuthorizationCode + v3oauth1 = keystoneauth1.extras.oauth1._loading:V3OAuth1 [build_sphinx] source-dir = doc/source diff --git a/tox.ini b/tox.ini index 8c8acdf8..4f1d8619 100644 --- a/tox.ini +++ b/tox.ini @@ -12,7 +12,7 @@ setenv = VIRTUAL_ENV={envdir} deps = -r{toxinidir}/requirements.txt -r{toxinidir}/test-requirements.txt - .[kerberos,saml2,betamax] + .[kerberos,saml2,betamax,oauth1] commands = ostestr {posargs} [testenv:pep8]