diff --git a/keystoneclient/tests/v3/test_oauth1.py b/keystoneclient/tests/v3/test_oauth1.py new file mode 100644 index 000000000..662ab7aae --- /dev/null +++ b/keystoneclient/tests/v3/test_oauth1.py @@ -0,0 +1,176 @@ +# 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 oauthlib.oauth1 as oauth1 + +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 + + +class ConsumerTests(utils.TestCase, 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, **kwargs): + 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=200, json=resp_ref) + + consumer = self.manager.create() + self.assertIsNone(consumer.description) + + +class TokenTests(utils.TestCase): + 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 = uuid.uuid4().hex + secret = uuid.uuid4().hex + expires_at = uuid.uuid4().hex + token = ('oauth_token=%s&oauth_token_secret=%s' + '&oauth_expires_at=%s' % (key, secret, expires_at)) + return (key, secret, expires_at, token) + + def _validate_oauth_headers(self, auth_header, oc): + self.assertTrue(auth_header.startswith('OAuth ')) + auth_header = auth_header[6:] + header_params = oc.get_oauth_params() + parameters = dict(header_params) + + self.assertEqual(parameters['oauth_signature_method'], 'HMAC-SHA1') + self.assertEqual(parameters['oauth_version'], '1.0') + self.assertIsNotNone(parameters['oauth_nonce']) + self.assertIsNotNone(parameters['oauth_timestamp']) + self.assertEqual(parameters['oauth_consumer_key'], oc.client_key) + if oc.resource_owner_key: + self.assertEqual(parameters['oauth_token'], oc.resource_owner_key) + if oc.verifier: + self.assertEqual(parameters['oauth_verifier'], oc.verifier) + 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 + verifier = uuid.uuid4().hex + resp_ref = {'token': {'oauth_verifier': verifier}} + role_ids = uuid.uuid4().hex + exp_body = {'roles': [{'id': role_ids}]} + + self.stub_url(httpretty.PUT, + [self.path_prefix, 'authorize', request_key], + status=200, json=resp_ref) + + # Assert the manager is returning the expected data + token = self.manager.authorize(request_key, [role_ids]) + self.assertEqual(token.oauth_verifier, verifier) + + # Assert that the request was sent in the expected structure + 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() + self.stub_url(httpretty.POST, [self.path_prefix, 'request_token'], + status=201, json=resp_ref) + + # 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_token.request_key, request_key) + self.assertEqual(request_token.request_secret, request_secret) + + # Assert that the project id is in the header + self.assertRequestHeaderEqual('requested_project_id', project_id) + req_headers = httpretty.last_request().headers + + # Assert that the headers have the same oauthlib data + oc = oauth1.Client(consumer_key, client_secret=consumer_secret, + signature_method=oauth1.SIGNATURE_HMAC, + callback_uri="oob") + self._validate_oauth_headers(req_headers['Authorization'], oc) + + +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 + self.stub_url(httpretty.POST, [self.path_prefix, 'access_token'], + status=201, json=resp_ref) + + # 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_token.access_key, access_key) + self.assertEqual(access_token.access_secret, access_secret) + self.assertEqual(access_token.expires, expires_at) + + # Assert that the headers have the same oauthlib data + req_headers = httpretty.last_request().headers + oc = 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) + self._validate_oauth_headers(req_headers['Authorization'], oc) diff --git a/keystoneclient/v3/client.py b/keystoneclient/v3/client.py index f4d025ea3..d6da1c53e 100644 --- a/keystoneclient/v3/client.py +++ b/keystoneclient/v3/client.py @@ -19,6 +19,7 @@ from keystoneclient.auth.identity import v3 as v3_auth from keystoneclient import exceptions from keystoneclient import httpclient from keystoneclient.openstack.common import jsonutils +from keystoneclient.v3.contrib import oauth1 from keystoneclient.v3.contrib import trusts from keystoneclient.v3 import credentials from keystoneclient.v3 import domains @@ -96,6 +97,7 @@ class Client(httpclient.HTTPClient): self.endpoints = endpoints.EndpointManager(self) self.domains = domains.DomainManager(self) self.groups = groups.GroupManager(self) + self.oauth1 = oauth1.OAuthManager(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..3bdb5332d --- /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 * # flake8: 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..9068973f5 --- /dev/null +++ b/keystoneclient/v3/contrib/oauth1/access_tokens.py @@ -0,0 +1,59 @@ +# 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 + +import oauthlib.oauth1 as oauth1 +from six.moves.urllib import parse as urlparse + +from keystoneclient import base + + +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): + # sign the request using oauthlib + endpoint = '/OS-OAUTH1/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 + endpoint + url, headers, body = oauth_client.sign(url, http_method='POST') + + # actually send the request + resp, body = self.client.post(endpoint, headers=headers) + + # returned format will be: + # 'oauth_token=12345&oauth_token_secret=67890' + # with 'oauth_expires_at' possibly there, too + credentials = urlparse.parse_qs(body) + key = credentials['oauth_token'][0] + secret = credentials['oauth_token_secret'][0] + access_token = {'access_key': key, 'id': key, + 'access_secret': secret} + + if credentials.get('oauth_expires_at'): + expires = credentials['oauth_expires_at'][0] + access_token['expires'] = expires + + return self.resource_class(self, access_token) diff --git a/keystoneclient/v3/contrib/oauth1/consumers.py b/keystoneclient/v3/contrib/oauth1/consumers.py new file mode 100644 index 000000000..79958ffae --- /dev/null +++ b/keystoneclient/v3/contrib/oauth1/consumers.py @@ -0,0 +1,51 @@ +# 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 + + +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 = '/OS-OAUTH1' + + 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..25713eb17 --- /dev/null +++ b/keystoneclient/v3/contrib/oauth1/core.py @@ -0,0 +1,23 @@ +# 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 + + +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) diff --git a/keystoneclient/v3/contrib/oauth1/request_tokens.py b/keystoneclient/v3/contrib/oauth1/request_tokens.py new file mode 100644 index 000000000..934b42e3c --- /dev/null +++ b/keystoneclient/v3/contrib/oauth1/request_tokens.py @@ -0,0 +1,65 @@ +# 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 + +import oauthlib.oauth1 as oauth1 +from six.moves.urllib import parse as urlparse + +from keystoneclient import base + + +class RequestToken(base.Resource): + def authorize(self, roles): + self.manager.authorize(self, roles) + + +class RequestTokenManager(base.CrudManager): + """Manager class for manipulating Identity OAuth Request Tokens.""" + resource_class = RequestToken + + def authorize(self, request_token, roles): + request_id = base.getid(request_token) + endpoint = '/OS-OAUTH1/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): + # sign the request using oauthlib + endpoint = '/OS-OAUTH1/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 + endpoint + url, headers, body = oauth_client.sign(url, http_method='POST', + headers=headers) + + # actually send the request + resp, body = self.client.post(endpoint, headers=headers) + + # returned format will be: + # 'oauth_token=12345&oauth_token_secret=67890' + # with 'oauth_expires_at' possibly there, too + credentials = urlparse.parse_qs(body) + key = credentials.get('oauth_token')[0] + secret = credentials.get('oauth_token_secret')[0] + request_token = {'request_key': key, 'id': key, + 'request_secret': secret} + + if credentials.get('oauth_expires_at'): + expires = credentials.get('oauth_expires_at')[0] + request_token['expires'] = expires + + return self.resource_class(self, request_token) diff --git a/requirements.txt b/requirements.txt index 055bf42b5..e3b9ae34a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ pbr>=0.6,<1.0 PrettyTable>=0.7,<0.8 requests>=1.1 six>=1.5.2 +oauthlib>=0.6