Add oauth plugin to keystoneauth

OAuth1 has been supported by keystone for a long time, and was supported
as an authentication plugin in keystoneclient. Port this work to
keystoneauth and add the ability to load it from the CLI.

Closes-Bug: #1524862
Change-Id: Ie53aeb1b926104cac692cd98551a701522f7fec4
This commit is contained in:
Jamie Lennox
2016-05-09 17:32:45 +10:00
parent f6e57bc706
commit c10722b789
9 changed files with 324 additions and 9 deletions

View File

@@ -147,18 +147,17 @@ V3 OAuth 1.0a Plugins
There also exists a plugin for OAuth 1.0a authentication. We provide a helper There also exists a plugin for OAuth 1.0a authentication. We provide a helper
authentication plugin at: 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 The plugin requires the OAuth consumer's key and secret, as well as the OAuth
access token's key and secret. For example:: 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 import session
>>> from keystoneauth1.v3 import client >>> a = auth.V3OAuth1('http://my.keystone.com:5000/v3',
>>> a = auth.OAuth('http://my.keystone.com:5000/v3', ... consumer_key=consumer_id,
... consumer_key=consumer_id, ... consumer_secret=consumer_secret,
... consumer_secret=consumer_secret, ... access_key=access_token_key,
... access_key=access_token_key, ... access_secret=access_token_secret)
... access_secret=access_token_secret)
>>> s = session.Session(auth=a) >>> s = session.Session(auth=a)

View File

@@ -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

View File

@@ -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

View File

@@ -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')

View File

@@ -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)

View File

@@ -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']))

View File

@@ -27,6 +27,8 @@ kerberos =
requests-kerberos>=0.6:python_version=='2.7' or python_version=='2.6' # MIT requests-kerberos>=0.6:python_version=='2.7' or python_version=='2.6' # MIT
saml2 = saml2 =
lxml>=2.3 # BSD lxml>=2.3 # BSD
oauth1 =
oauthlib>=0.6 # BSD
betamax = betamax =
betamax>=0.6.0 # Apache-2.0 betamax>=0.6.0 # Apache-2.0
fixtures<2.0,>=1.3.1 # Apache-2.0/BSD fixtures<2.0,>=1.3.1 # Apache-2.0/BSD
@@ -44,6 +46,7 @@ keystoneauth1.plugin =
v3token = keystoneauth1.loading._plugins.identity.v3:Token v3token = keystoneauth1.loading._plugins.identity.v3:Token
v3oidcpassword = keystoneauth1.loading._plugins.identity.v3:OpenIDConnectPassword v3oidcpassword = keystoneauth1.loading._plugins.identity.v3:OpenIDConnectPassword
v3oidcauthcode = keystoneauth1.loading._plugins.identity.v3:OpenIDConnectAuthorizationCode v3oidcauthcode = keystoneauth1.loading._plugins.identity.v3:OpenIDConnectAuthorizationCode
v3oauth1 = keystoneauth1.extras.oauth1._loading:V3OAuth1
[build_sphinx] [build_sphinx]
source-dir = doc/source source-dir = doc/source

View File

@@ -12,7 +12,7 @@ setenv = VIRTUAL_ENV={envdir}
deps = -r{toxinidir}/requirements.txt deps = -r{toxinidir}/requirements.txt
-r{toxinidir}/test-requirements.txt -r{toxinidir}/test-requirements.txt
.[kerberos,saml2,betamax] .[kerberos,saml2,betamax,oauth1]
commands = ostestr {posargs} commands = ostestr {posargs}
[testenv:pep8] [testenv:pep8]