Create Authentication Plugins

Provides the framework for creating authentication plugins and using
them from a session object.

To allow this system to co-exist with the original client there is a bit
of a hack. The client object itself is now also an authentication
plugin, that supports the original client pattern. If a client is
created without a session object then that session object uses the
client as it's authentication plugin.

Change-Id: I682c8dcd3705148aaa804a91f4ed48a5b74bdc12
blueprint: auth-plugins
This commit is contained in:
Jamie Lennox 2013-12-09 16:46:09 +10:00
parent 1263bd7c3a
commit 96267731ec
10 changed files with 244 additions and 19 deletions

View File

View File

@ -0,0 +1,38 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# 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 BaseAuthPlugin(object):
"""The basic structure of an authentication plugin."""
@abc.abstractmethod
def get_token(self, session, **kwargs):
"""Obtain a token.
How the token is obtained is up to the plugin. If it is still valid
it may be re-used, retrieved from cache or invoke an authentication
request against a server.
There are no required kwargs. They are passed directly to the auth
plugin and they are implementation specific.
Returning None will indicate that no token was able to be retrieved.
:param session: A session object so the plugin can make HTTP calls.
:return string: A token to use.
"""

View File

View File

@ -0,0 +1,60 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# 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 logging
import six
from keystoneclient.auth import base
LOG = logging.getLogger(__name__)
@six.add_metaclass(abc.ABCMeta)
class BaseIdentityPlugin(base.BaseAuthPlugin):
def __init__(self,
auth_url=None,
username=None,
password=None,
token=None,
trust_id=None):
super(BaseIdentityPlugin, self).__init__()
self.auth_url = auth_url
self.username = username
self.password = password
self.token = token
self.trust_id = trust_id
self.auth_ref = None
@abc.abstractmethod
def get_auth_ref(self, session, **kwargs):
"""Obtain a token from an OpenStack Identity Service.
This method is overridden by the various token version plugins.
This function should not be called independently and is expected to be
invoked via the do_authenticate function.
:returns AccessInfo: Token access information.
"""
def get_token(self, session, **kwargs):
if not self.auth_ref or self.auth_ref.will_expire_soon(1):
self.auth_ref = self.get_auth_ref(session, **kwargs)
return self.auth_ref.auth_token

View File

@ -0,0 +1,41 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# 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.
class Client(object):
def __init__(self, session):
self.session = session
def request(self, url, method, **kwargs):
kwargs.setdefault('authenticated', True)
return self.session.request(url, method, **kwargs)
def get(self, url, **kwargs):
return self.request(url, 'GET', **kwargs)
def head(self, url, **kwargs):
return self.request(url, 'HEAD', **kwargs)
def post(self, url, **kwargs):
return self.request(url, 'POST', **kwargs)
def put(self, url, **kwargs):
return self.request(url, 'PUT', **kwargs)
def patch(self, url, **kwargs):
return self.request(url, 'PATCH', **kwargs)
def delete(self, url, **kwargs):
return self.request(url, 'DELETE', **kwargs)

View File

@ -49,3 +49,7 @@ class DiscoveryFailure(ClientException):
class VersionNotAvailable(DiscoveryFailure):
"""Discovery failed as the version you requested is not available."""
class MissingAuthPlugin(ClientException):
"""An authenticated request is required but no plugin available."""

View File

@ -39,6 +39,8 @@ if not hasattr(urlparse, 'parse_qsl'):
from keystoneclient import access
from keystoneclient.auth import base
from keystoneclient import baseclient
from keystoneclient import exceptions
from keystoneclient.openstack.common import jsonutils
from keystoneclient import session as client_session
@ -52,7 +54,7 @@ USER_AGENT = client_session.USER_AGENT
request = client_session.request
class HTTPClient(object):
class HTTPClient(baseclient.Client, base.BaseAuthPlugin):
def __init__(self, username=None, tenant_id=None, tenant_name=None,
password=None, auth_url=None, region_name=None, endpoint=None,
@ -121,7 +123,6 @@ class HTTPClient(object):
"""
# set baseline defaults
self.user_id = None
self.username = None
self.user_domain_id = None
@ -223,8 +224,9 @@ class HTTPClient(object):
if not session:
session = client_session.Session.construct(kwargs)
session.auth = self
self.session = session
super(HTTPClient, self).__init__(session=session)
self.domain = ''
self.debug_log = debug
@ -236,6 +238,9 @@ class HTTPClient(object):
self.stale_duration = stale_duration or access.STALE_TOKEN_DURATION
self.stale_duration = int(self.stale_duration)
def get_token(self, session, **kwargs):
return self.auth_token
@property
def auth_token(self):
if self._auth_token:
@ -377,14 +382,21 @@ class HTTPClient(object):
if auth_ref is None or self.force_new_token:
new_token_needed = True
kwargs['password'] = password
resp, body = self.get_raw_token_from_identity_service(**kwargs)
resp = self.get_raw_token_from_identity_service(**kwargs)
# TODO(jamielennox): passing region_name here is wrong but required
# for backwards compatibility. Deprecate and provide warning.
self.auth_ref = access.AccessInfo.factory(resp, body,
region_name=region_name)
if isinstance(resp, access.AccessInfo):
self.auth_ref = resp
else:
self.auth_ref = access.AccessInfo.factory(*resp)
# NOTE(jamielennox): The original client relies on being able to
# push the region name into the service catalog but new auth
# it in.
if region_name:
self.auth_ref.service_catalog._region_name = region_name
else:
self.auth_ref = auth_ref
self.process_token(region_name=region_name)
if new_token_needed:
self.store_auth_ref_into_keyring(keyring_key)
@ -540,7 +552,8 @@ class HTTPClient(object):
except KeyError:
pass
resp = self.session.request(url, method, **kwargs)
kwargs.setdefault('authenticated', False)
resp = super(HTTPClient, self).request(url, method, **kwargs)
return resp, self._decode_body(resp)
def _cs_request(self, url, method, **kwargs):
@ -559,13 +572,9 @@ class HTTPClient(object):
if is_management:
url_to_use = self.management_url
kwargs.setdefault('headers', {})
if self.auth_token:
kwargs['headers']['X-Auth-Token'] = self.auth_token
resp, body = self.request(url_to_use + url, method,
**kwargs)
return resp, body
kwargs.setdefault('authenticated', None)
return self.request(url_to_use + url, method,
**kwargs)
def get(self, url, **kwargs):
return self._cs_request(url, 'GET', **kwargs)

View File

@ -37,14 +37,18 @@ class Session(object):
REDIRECT_STATUSES = (301, 302, 303, 305, 307)
DEFAULT_REDIRECT_LIMIT = 30
def __init__(self, session=None, original_ip=None, verify=True, cert=None,
timeout=None, user_agent=None,
def __init__(self, auth=None, session=None, original_ip=None, verify=True,
cert=None, timeout=None, user_agent=None,
redirect=DEFAULT_REDIRECT_LIMIT):
"""Maintains client communication state and common functionality.
As much as possible the parameters to this class reflect and are passed
directly to the requests library.
:param auth: An authentication plugin to authenticate the session with.
(optional, defaults to None)
:param requests.Session session: A requests session object that can be
used for issuing requests. (optional)
:param string original_ip: The original IP of the requesting user
which will be sent to identity service in a
'Forwarded' header. (optional)
@ -74,6 +78,7 @@ class Session(object):
if not session:
session = requests.Session()
self.auth = auth
self.session = session
self.original_ip = original_ip
self.verify = verify
@ -89,7 +94,8 @@ class Session(object):
self.user_agent = user_agent
def request(self, url, method, json=None, original_ip=None,
user_agent=None, redirect=None, **kwargs):
user_agent=None, redirect=None, authenticated=None,
**kwargs):
"""Send an HTTP request with the specified characteristics.
Wrapper around `requests.Session.request` to handle tasks such as
@ -111,6 +117,10 @@ class Session(object):
can be followed by a request. Either an
integer for a specific count or True/False
for forever/never. (optional)
:param bool authenticated: True if a token should be attached to this
request, False if not or None for attach if
an auth_plugin is available.
(optional, defaults to None)
:param kwargs: any other parameter that can be passed to
requests.Session.request (such as `headers`). Except:
'data' will be overwritten by the data in 'json' param.
@ -125,6 +135,17 @@ class Session(object):
headers = kwargs.setdefault('headers', dict())
if authenticated is None:
authenticated = self.auth is not None
if authenticated:
token = self.get_token()
if not token:
raise exceptions.AuthorizationFailure("No token Available")
headers['X-Auth-Token'] = token
if self.cert:
kwargs.setdefault('cert', self.cert)
@ -286,3 +307,10 @@ class Session(object):
session=kwargs.pop('session', None),
original_ip=kwargs.pop('original_ip', None),
user_agent=kwargs.pop('user_agent', None))
def get_token(self):
"""Return a token as provided by the auth plugin."""
if not self.auth:
raise exceptions.MissingAuthPlugin("Token Required")
return self.auth.get_token(self)

View File

View File

@ -17,6 +17,7 @@ import httpretty
import mock
import requests
from keystoneclient.auth import base
from keystoneclient import exceptions
from keystoneclient import session as client_session
from keystoneclient.tests import utils
@ -252,3 +253,47 @@ class ConstructSessionFromArgsTests(utils.TestCase):
args = {key: value}
self.assertEqual(getattr(self._s(args), key), value)
self.assertNotIn(key, args)
class AuthPlugin(base.BaseAuthPlugin):
"""Very simple debug authentication plugin.
Takes Parameters such that it can throw exceptions at the right times.
"""
TEST_TOKEN = 'aToken'
def __init__(self, token=TEST_TOKEN):
self.token = token
def get_token(self, session):
return self.token
class SessionAuthTests(utils.TestCase):
TEST_URL = 'http://127.0.0.1:5000/'
TEST_JSON = {'hello': 'world'}
@httpretty.activate
def test_auth_plugin_default_with_plugin(self):
self.stub_url('GET', base_url=self.TEST_URL, json=self.TEST_JSON)
# if there is an auth_plugin then it should default to authenticated
auth = AuthPlugin()
sess = client_session.Session(auth=auth)
resp = sess.get(self.TEST_URL)
self.assertDictEqual(resp.json(), self.TEST_JSON)
self.assertRequestHeaderEqual('X-Auth-Token', AuthPlugin.TEST_TOKEN)
@httpretty.activate
def test_auth_plugin_disable(self):
self.stub_url('GET', base_url=self.TEST_URL, json=self.TEST_JSON)
auth = AuthPlugin()
sess = client_session.Session(auth=auth)
resp = sess.get(self.TEST_URL, authenticated=False)
self.assertDictEqual(resp.json(), self.TEST_JSON)
self.assertRequestHeaderEqual('X-Auth-Token', None)