diff --git a/keystoneclient/tests/v3/test_oauth1.py b/keystoneclient/tests/v3/test_oauth1.py new file mode 100644 index 000000000..80082b61f --- /dev/null +++ b/keystoneclient/tests/v3/test_oauth1.py @@ -0,0 +1,229 @@ +# 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 + +import httpretty +import six +from testtools import matchers + +from keystoneclient.openstack.common import jsonutils +from keystoneclient.openstack.common import timeutils +from keystoneclient.tests.v3 import utils +from keystoneclient.v3.contrib.oauth1 import access_tokens +from keystoneclient.v3.contrib.oauth1 import consumers +from keystoneclient.v3.contrib.oauth1 import request_tokens + +try: + from oauthlib import oauth1 +except ImportError: + oauth1 = None + + +class BaseTest(utils.TestCase): + def setUp(self): + super(BaseTest, self).setUp() + if oauth1 is None: + self.skipTest('oauthlib package not available') + + +class ConsumerTests(BaseTest, utils.CrudTests): + def setUp(self): + super(ConsumerTests, self).setUp() + self.key = 'consumer' + self.collection_key = 'consumers' + self.model = consumers.Consumer + self.manager = self.client.oauth1.consumers + self.path_prefix = 'OS-OAUTH1' + + def new_ref(self, **kwargs): + kwargs = super(ConsumerTests, self).new_ref(**kwargs) + kwargs.setdefault('description', uuid.uuid4().hex) + return kwargs + + @httpretty.activate + def test_description_is_optional(self): + consumer_id = uuid.uuid4().hex + resp_ref = {'consumer': {'description': None, + 'id': consumer_id}} + + self.stub_url(httpretty.POST, + [self.path_prefix, self.collection_key], + status=201, json=resp_ref) + + consumer = self.manager.create() + self.assertEqual(consumer_id, consumer.id) + self.assertIsNone(consumer.description) + + @httpretty.activate + def test_description_not_included(self): + consumer_id = uuid.uuid4().hex + resp_ref = {'consumer': {'id': consumer_id}} + + self.stub_url(httpretty.POST, + [self.path_prefix, self.collection_key], + status=201, json=resp_ref) + + consumer = self.manager.create() + self.assertEqual(consumer_id, consumer.id) + + +class TokenTests(BaseTest): + def _new_oauth_token(self): + key = uuid.uuid4().hex + secret = uuid.uuid4().hex + token = 'oauth_token=%s&oauth_token_secret=%s' % (key, secret) + return (key, secret, token) + + def _new_oauth_token_with_expires_at(self): + key, secret, token = self._new_oauth_token() + expires_at = timeutils.strtime() + token += '&oauth_expires_at=%s' % expires_at + return (key, secret, expires_at, token) + + def _validate_oauth_headers(self, auth_header, oauth_client): + """Assert that the data in the headers matches the data + that is produced from oauthlib. + """ + + self.assertThat(auth_header, matchers.StartsWith('OAuth ')) + auth_header = auth_header[len('OAuth '):] + header_params = oauth_client.get_oauth_params() + parameters = dict(header_params) + + 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']) + if oauth_client.timestamp: + self.assertEqual(oauth_client.timestamp, + parameters['oauth_timestamp']) + return parameters + + +class RequestTokenTests(TokenTests): + def setUp(self): + super(RequestTokenTests, self).setUp() + self.model = request_tokens.RequestToken + self.manager = self.client.oauth1.request_tokens + self.path_prefix = 'OS-OAUTH1' + + @httpretty.activate + def test_authorize_request_token(self): + request_key = uuid.uuid4().hex + info = {'id': request_key, + 'key': request_key, + 'secret': uuid.uuid4().hex} + request_token = request_tokens.RequestToken(self.manager, info) + + verifier = uuid.uuid4().hex + resp_ref = {'token': {'oauth_verifier': verifier}} + self.stub_url(httpretty.PUT, + [self.path_prefix, 'authorize', request_key], + status=200, json=resp_ref) + + # Assert the manager is returning the expected data + role_id = uuid.uuid4().hex + token = request_token.authorize([role_id]) + self.assertEqual(verifier, token.oauth_verifier) + + # Assert that the request was sent in the expected structure + exp_body = {'roles': [{'id': role_id}]} + self.assertRequestBodyIs(json=exp_body) + + @httpretty.activate + def test_create_request_token(self): + project_id = uuid.uuid4().hex + consumer_key = uuid.uuid4().hex + consumer_secret = uuid.uuid4().hex + + request_key, request_secret, resp_ref = self._new_oauth_token() + + # NOTE(stevemar) The server expects the body to be JSON. Even though + # the resp_ref is a string it is not a JSON string. + self.stub_url(httpretty.POST, [self.path_prefix, 'request_token'], + status=201, body=jsonutils.dumps(resp_ref), + content_type='application/x-www-form-urlencoded') + + # Assert the manager is returning request token object + request_token = self.manager.create(consumer_key, consumer_secret, + project_id) + self.assertIsInstance(request_token, self.model) + self.assertEqual(request_key, request_token.key) + self.assertEqual(request_secret, request_token.secret) + + # Assert that the project id is in the header + self.assertRequestHeaderEqual('requested_project_id', project_id) + req_headers = httpretty.last_request().headers + + oauth_client = oauth1.Client(consumer_key, + client_secret=consumer_secret, + signature_method=oauth1.SIGNATURE_HMAC, + callback_uri="oob") + self._validate_oauth_headers(req_headers['Authorization'], + oauth_client) + + +class AccessTokenTests(TokenTests): + def setUp(self): + super(AccessTokenTests, self).setUp() + self.manager = self.client.oauth1.access_tokens + self.model = access_tokens.AccessToken + self.path_prefix = 'OS-OAUTH1' + + @httpretty.activate + def test_create_access_token_expires_at(self): + verifier = uuid.uuid4().hex + consumer_key = uuid.uuid4().hex + consumer_secret = uuid.uuid4().hex + request_key = uuid.uuid4().hex + request_secret = uuid.uuid4().hex + + t = self._new_oauth_token_with_expires_at() + access_key, access_secret, expires_at, resp_ref = t + + # NOTE(stevemar) The server expects the body to be JSON. Even though + # the resp_ref is a string it is not a JSON string. + self.stub_url(httpretty.POST, [self.path_prefix, 'access_token'], + status=201, body=jsonutils.dumps(resp_ref), + content_type='application/x-www-form-urlencoded') + + # Assert that the manager creates an access token object + access_token = self.manager.create(consumer_key, consumer_secret, + request_key, request_secret, + verifier) + self.assertIsInstance(access_token, self.model) + self.assertEqual(access_key, access_token.key) + self.assertEqual(access_secret, access_token.secret) + self.assertEqual(expires_at, access_token.expires) + + req_headers = httpretty.last_request().headers + oauth_client = oauth1.Client(consumer_key, + client_secret=consumer_secret, + resource_owner_key=request_key, + resource_owner_secret=request_secret, + signature_method=oauth1.SIGNATURE_HMAC, + verifier=verifier, + timestamp=expires_at) + self._validate_oauth_headers(req_headers['Authorization'], + oauth_client) diff --git a/keystoneclient/v3/client.py b/keystoneclient/v3/client.py index 417fdb2f5..32ec90c72 100644 --- a/keystoneclient/v3/client.py +++ b/keystoneclient/v3/client.py @@ -21,6 +21,7 @@ from keystoneclient import httpclient from keystoneclient.openstack.common import jsonutils from keystoneclient.v3.contrib import endpoint_filter from keystoneclient.v3.contrib import federation +from keystoneclient.v3.contrib import oauth1 from keystoneclient.v3.contrib import trusts from keystoneclient.v3 import credentials from keystoneclient.v3 import domains @@ -99,6 +100,7 @@ class Client(httpclient.HTTPClient): self.domains = domains.DomainManager(self) self.federation = federation.FederationManager(self) self.groups = groups.GroupManager(self) + self.oauth1 = oauth1.create_oauth_manager(self) self.policies = policies.PolicyManager(self) self.projects = projects.ProjectManager(self) self.roles = roles.RoleManager(self) diff --git a/keystoneclient/v3/contrib/oauth1/__init__.py b/keystoneclient/v3/contrib/oauth1/__init__.py new file mode 100644 index 000000000..e1fb8b797 --- /dev/null +++ b/keystoneclient/v3/contrib/oauth1/__init__.py @@ -0,0 +1,13 @@ +# 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 keystoneclient.v3.contrib.oauth1.core import * # noqa diff --git a/keystoneclient/v3/contrib/oauth1/access_tokens.py b/keystoneclient/v3/contrib/oauth1/access_tokens.py new file mode 100644 index 000000000..917586e99 --- /dev/null +++ b/keystoneclient/v3/contrib/oauth1/access_tokens.py @@ -0,0 +1,46 @@ +# 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 __future__ import unicode_literals + +from keystoneclient import base +from keystoneclient.v3.contrib.oauth1 import utils + +try: + from oauthlib import oauth1 +except ImportError: + oauth1 = None + + +class AccessToken(base.Resource): + pass + + +class AccessTokenManager(base.CrudManager): + """Manager class for manipulating identity OAuth access tokens.""" + resource_class = AccessToken + + def create(self, consumer_key, consumer_secret, request_key, + request_secret, verifier): + endpoint = utils.OAUTH_PATH + '/access_token' + oauth_client = oauth1.Client(consumer_key, + client_secret=consumer_secret, + resource_owner_key=request_key, + resource_owner_secret=request_secret, + signature_method=oauth1.SIGNATURE_HMAC, + verifier=verifier) + url = self.client.auth_url.rstrip("/") + endpoint + url, headers, body = oauth_client.sign(url, http_method='POST') + resp, body = self.client.post(endpoint, headers=headers) + token = utils.get_oauth_token_from_body(body) + return self.resource_class(self, token) diff --git a/keystoneclient/v3/contrib/oauth1/consumers.py b/keystoneclient/v3/contrib/oauth1/consumers.py new file mode 100644 index 000000000..25e8ca191 --- /dev/null +++ b/keystoneclient/v3/contrib/oauth1/consumers.py @@ -0,0 +1,52 @@ +# 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 keystoneclient import base +from keystoneclient.v3.contrib.oauth1 import utils + + +class Consumer(base.Resource): + """Represents an OAuth consumer. + + Attributes: + * id: a uuid that identifies the consumer + * description: a short description of the consumer + """ + pass + + +class ConsumerManager(base.CrudManager): + """Manager class for manipulating identity consumers.""" + resource_class = Consumer + collection_key = 'consumers' + key = 'consumer' + base_url = utils.OAUTH_PATH + + def create(self, description=None, **kwargs): + return super(ConsumerManager, self).create( + description=description, + **kwargs) + + def get(self, consumer): + return super(ConsumerManager, self).get( + consumer_id=base.getid(consumer)) + + def update(self, consumer, description=None, **kwargs): + return super(ConsumerManager, self).update( + consumer_id=base.getid(consumer), + description=description, + **kwargs) + + def delete(self, consumer): + return super(ConsumerManager, self).delete( + consumer_id=base.getid(consumer)) diff --git a/keystoneclient/v3/contrib/oauth1/core.py b/keystoneclient/v3/contrib/oauth1/core.py new file mode 100644 index 000000000..4820c022a --- /dev/null +++ b/keystoneclient/v3/contrib/oauth1/core.py @@ -0,0 +1,64 @@ +# 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 keystoneclient.v3.contrib.oauth1 import access_tokens +from keystoneclient.v3.contrib.oauth1 import consumers +from keystoneclient.v3.contrib.oauth1 import request_tokens + + +def create_oauth_manager(self): + # NOTE(stevemar): Attempt to import the oauthlib package at this point. + try: + import oauthlib # noqa + # NOTE(stevemar): Return an object instead of raising an exception here, + # this will allow users to see an exception only when trying to access the + # oauth portions of client. Otherwise an exception would be raised + # when the client is created. + except ImportError: + return OAuthManagerOptionalImportProxy() + else: + return OAuthManager(self) + + +class OAuthManager(object): + def __init__(self, api): + self.access_tokens = access_tokens.AccessTokenManager(api) + self.consumers = consumers.ConsumerManager(api) + self.request_tokens = request_tokens.RequestTokenManager(api) + + +class OAuthManagerOptionalImportProxy(object): + """Act as a proxy manager in case oauthlib is no installed. + + This class will only be created if oauthlib is not in the system, + trying to access any of the attributes in name (access_tokens, + consumers, request_tokens), will result in a NotImplementedError, + and a message. + + >>> manager.access_tokens.blah + NotImplementedError: To use 'access_tokens' oauthlib must be installed + + Otherwise, if trying to access an attribute other than the ones in name, + the manager will state that the attribute does not exist. + + >>> manager.dne.blah + AttributeError: 'OAuthManagerOptionalImportProxy' object has no + attribute 'dne' + """ + + def __getattribute__(self, name): + if name in ('access_tokens', 'comsumers', 'request_tokens'): + raise NotImplementedError( + 'To use %r oauthlib must be installed' % name) + return super(OAuthManagerOptionalImportProxy, + self).__getattribute__(name) diff --git a/keystoneclient/v3/contrib/oauth1/request_tokens.py b/keystoneclient/v3/contrib/oauth1/request_tokens.py new file mode 100644 index 000000000..29f428e14 --- /dev/null +++ b/keystoneclient/v3/contrib/oauth1/request_tokens.py @@ -0,0 +1,70 @@ +# 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 __future__ import unicode_literals + +from six.moves.urllib import parse as urlparse + +from keystoneclient import base +from keystoneclient.v3.contrib.oauth1 import utils + +try: + from oauthlib import oauth1 +except ImportError: + oauth1 = None + + +class RequestToken(base.Resource): + def authorize(self, roles): + try: + retval = self.manager.authorize(self.id, roles) + self = retval + except Exception: + retval = None + + return retval + + +class RequestTokenManager(base.CrudManager): + """Manager class for manipulating identity OAuth request tokens.""" + resource_class = RequestToken + + def authorize(self, request_token, roles): + """Authorize a request token with specific roles. + + Utilize Identity API operation: + PUT /OS-OAUTH1/authorize/$request_token_id + + :param request_token: a request token that will be authorized, and + can be exchanged for an access token. + :param roles: a list of roles, that will be delegated to the user. + """ + + request_id = urlparse.quote(base.getid(request_token)) + endpoint = utils.OAUTH_PATH + '/authorize/%s' % (request_id) + body = {'roles': [{'id': base.getid(r_id)} for r_id in roles]} + return self._put(endpoint, body, "token") + + def create(self, consumer_key, consumer_secret, project): + endpoint = utils.OAUTH_PATH + '/request_token' + headers = {'requested_project_id': base.getid(project)} + oauth_client = oauth1.Client(consumer_key, + client_secret=consumer_secret, + signature_method=oauth1.SIGNATURE_HMAC, + callback_uri="oob") + url = self.client.auth_url.rstrip("/") + endpoint + url, headers, body = oauth_client.sign(url, http_method='POST', + headers=headers) + resp, body = self.client.post(endpoint, headers=headers) + token = utils.get_oauth_token_from_body(body) + return self.resource_class(self, token) diff --git a/keystoneclient/v3/contrib/oauth1/utils.py b/keystoneclient/v3/contrib/oauth1/utils.py new file mode 100644 index 000000000..5d02f94f6 --- /dev/null +++ b/keystoneclient/v3/contrib/oauth1/utils.py @@ -0,0 +1,35 @@ +# 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 six.moves.urllib import parse as urlparse + + +OAUTH_PATH = '/OS-OAUTH1' + + +def get_oauth_token_from_body(body): + """Parse the URL response body to retrieve the oauth token key and secret + + The response body will look like: + 'oauth_token=12345&oauth_token_secret=67890' with + 'oauth_expires_at=2013-03-30T05:27:19.463201' possibly there, too. + """ + + credentials = urlparse.parse_qs(body) + key = credentials['oauth_token'][0] + secret = credentials['oauth_token_secret'][0] + token = {'key': key, 'id': key, 'secret': secret} + expires_at = credentials.get('oauth_expires_at') + if expires_at: + token['expires'] = expires_at[0] + return token diff --git a/test-requirements.txt b/test-requirements.txt index b69c92618..2528b17bb 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -6,6 +6,7 @@ httpretty>=0.8.0 keyring>=2.1 mock>=1.0 mox3>=0.7.0 +oauthlib>=0.6 pycrypto>=2.6 sphinx>=1.1.2,<1.2 stevedore>=0.14