Merge "Create Authentication Plugins"

This commit is contained in:
Jenkins
2014-02-07 03:38:55 +00:00
committed by Gerrit Code Review
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): class VersionNotAvailable(DiscoveryFailure):
"""Discovery failed as the version you requested is not available.""" """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 import access
from keystoneclient.auth import base
from keystoneclient import baseclient
from keystoneclient import exceptions from keystoneclient import exceptions
from keystoneclient.openstack.common import jsonutils from keystoneclient.openstack.common import jsonutils
from keystoneclient import session as client_session from keystoneclient import session as client_session
@@ -52,7 +54,7 @@ USER_AGENT = client_session.USER_AGENT
request = client_session.request request = client_session.request
class HTTPClient(object): class HTTPClient(baseclient.Client, base.BaseAuthPlugin):
def __init__(self, username=None, tenant_id=None, tenant_name=None, def __init__(self, username=None, tenant_id=None, tenant_name=None,
password=None, auth_url=None, region_name=None, endpoint=None, password=None, auth_url=None, region_name=None, endpoint=None,
@@ -121,7 +123,6 @@ class HTTPClient(object):
""" """
# set baseline defaults # set baseline defaults
self.user_id = None self.user_id = None
self.username = None self.username = None
self.user_domain_id = None self.user_domain_id = None
@@ -223,8 +224,9 @@ class HTTPClient(object):
if not session: if not session:
session = client_session.Session.construct(kwargs) session = client_session.Session.construct(kwargs)
session.auth = self
self.session = session super(HTTPClient, self).__init__(session=session)
self.domain = '' self.domain = ''
self.debug_log = debug self.debug_log = debug
@@ -236,6 +238,9 @@ class HTTPClient(object):
self.stale_duration = stale_duration or access.STALE_TOKEN_DURATION self.stale_duration = stale_duration or access.STALE_TOKEN_DURATION
self.stale_duration = int(self.stale_duration) self.stale_duration = int(self.stale_duration)
def get_token(self, session, **kwargs):
return self.auth_token
@property @property
def auth_token(self): def auth_token(self):
if self._auth_token: if self._auth_token:
@@ -377,14 +382,21 @@ class HTTPClient(object):
if auth_ref is None or self.force_new_token: if auth_ref is None or self.force_new_token:
new_token_needed = True new_token_needed = True
kwargs['password'] = password 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 if isinstance(resp, access.AccessInfo):
# for backwards compatibility. Deprecate and provide warning. self.auth_ref = resp
self.auth_ref = access.AccessInfo.factory(resp, body, else:
region_name=region_name) 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: else:
self.auth_ref = auth_ref self.auth_ref = auth_ref
self.process_token(region_name=region_name) self.process_token(region_name=region_name)
if new_token_needed: if new_token_needed:
self.store_auth_ref_into_keyring(keyring_key) self.store_auth_ref_into_keyring(keyring_key)
@@ -540,7 +552,8 @@ class HTTPClient(object):
except KeyError: except KeyError:
pass 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) return resp, self._decode_body(resp)
def _cs_request(self, url, method, **kwargs): def _cs_request(self, url, method, **kwargs):
@@ -559,13 +572,9 @@ class HTTPClient(object):
if is_management: if is_management:
url_to_use = self.management_url url_to_use = self.management_url
kwargs.setdefault('headers', {}) kwargs.setdefault('authenticated', None)
if self.auth_token: return self.request(url_to_use + url, method,
kwargs['headers']['X-Auth-Token'] = self.auth_token **kwargs)
resp, body = self.request(url_to_use + url, method,
**kwargs)
return resp, body
def get(self, url, **kwargs): def get(self, url, **kwargs):
return self._cs_request(url, 'GET', **kwargs) return self._cs_request(url, 'GET', **kwargs)

View File

@@ -37,14 +37,18 @@ class Session(object):
REDIRECT_STATUSES = (301, 302, 303, 305, 307) REDIRECT_STATUSES = (301, 302, 303, 305, 307)
DEFAULT_REDIRECT_LIMIT = 30 DEFAULT_REDIRECT_LIMIT = 30
def __init__(self, session=None, original_ip=None, verify=True, cert=None, def __init__(self, auth=None, session=None, original_ip=None, verify=True,
timeout=None, user_agent=None, cert=None, timeout=None, user_agent=None,
redirect=DEFAULT_REDIRECT_LIMIT): redirect=DEFAULT_REDIRECT_LIMIT):
"""Maintains client communication state and common functionality. """Maintains client communication state and common functionality.
As much as possible the parameters to this class reflect and are passed As much as possible the parameters to this class reflect and are passed
directly to the requests library. 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 :param string original_ip: The original IP of the requesting user
which will be sent to identity service in a which will be sent to identity service in a
'Forwarded' header. (optional) 'Forwarded' header. (optional)
@@ -74,6 +78,7 @@ class Session(object):
if not session: if not session:
session = requests.Session() session = requests.Session()
self.auth = auth
self.session = session self.session = session
self.original_ip = original_ip self.original_ip = original_ip
self.verify = verify self.verify = verify
@@ -89,7 +94,8 @@ class Session(object):
self.user_agent = user_agent self.user_agent = user_agent
def request(self, url, method, json=None, original_ip=None, 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. """Send an HTTP request with the specified characteristics.
Wrapper around `requests.Session.request` to handle tasks such as 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 can be followed by a request. Either an
integer for a specific count or True/False integer for a specific count or True/False
for forever/never. (optional) 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 :param kwargs: any other parameter that can be passed to
requests.Session.request (such as `headers`). Except: requests.Session.request (such as `headers`). Except:
'data' will be overwritten by the data in 'json' param. 'data' will be overwritten by the data in 'json' param.
@@ -125,6 +135,17 @@ class Session(object):
headers = kwargs.setdefault('headers', dict()) 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: if self.cert:
kwargs.setdefault('cert', self.cert) kwargs.setdefault('cert', self.cert)
@@ -286,3 +307,10 @@ class Session(object):
session=kwargs.pop('session', None), session=kwargs.pop('session', None),
original_ip=kwargs.pop('original_ip', None), original_ip=kwargs.pop('original_ip', None),
user_agent=kwargs.pop('user_agent', 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 mock
import requests import requests
from keystoneclient.auth import base
from keystoneclient import exceptions from keystoneclient import exceptions
from keystoneclient import session as client_session from keystoneclient import session as client_session
from keystoneclient.tests import utils from keystoneclient.tests import utils
@@ -252,3 +253,47 @@ class ConstructSessionFromArgsTests(utils.TestCase):
args = {key: value} args = {key: value}
self.assertEqual(getattr(self._s(args), key), value) self.assertEqual(getattr(self._s(args), key), value)
self.assertNotIn(key, args) 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)