diff --git a/keystoneclient/access.py b/keystoneclient/access.py index 6d0e9fa75..3947b6f10 100644 --- a/keystoneclient/access.py +++ b/keystoneclient/access.py @@ -15,6 +15,15 @@ # limitations under the License. +import datetime + +from keystoneclient.openstack.common import timeutils + + +# gap, in seconds, to determine whether the given token is about to expire +STALE_TOKEN_DURATION = '30' + + class AccessInfo(dict): """An object for encapsulating a raw authentication token from keystone and helper methods for extracting useful values from that token.""" @@ -22,6 +31,29 @@ class AccessInfo(dict): def __init__(self, *args, **kwargs): dict.__init__(self, *args, **kwargs) + def will_expire_soon(self, stale_duration=None): + """ Determines if expiration is about to occur. + + :return: boolean : true if expiration is within the given duration + + """ + stale_duration = stale_duration or TALE_TOKEN_DURATION + norm_expires = timeutils.normalize_time(self.expires) + # (gyee) should we move auth_token.will_expire_soon() to timeutils + # instead of duplicating code here? + soon = (timeutils.utcnow() + datetime.timedelta( + seconds=stale_duration)) + return norm_expires < soon + + @property + def expires(self): + """ Returns the token expiration (as datetime object) + + :returns: datetime + + """ + return timeutils.parse_isotime(self['token']['expires']) + @property def auth_token(self): """ Returns the token_id associated with the auth request, to be used diff --git a/keystoneclient/client.py b/keystoneclient/client.py index de86258e9..e3bd62302 100644 --- a/keystoneclient/client.py +++ b/keystoneclient/client.py @@ -32,6 +32,16 @@ from keystoneclient import exceptions _logger = logging.getLogger(__name__) +# keyring init +keyring_available = True +try: + import keyring + import pickle +except ImportError: + _logger.warning('Failed to load keyring modules.') + keyring_available = False + + class HTTPClient(httplib2.Http): USER_AGENT = 'python-keystoneclient' @@ -40,7 +50,8 @@ class HTTPClient(httplib2.Http): password=None, auth_url=None, region_name=None, timeout=None, endpoint=None, token=None, cacert=None, key=None, cert=None, insecure=False, original_ip=None, debug=False, - auth_ref=None): + auth_ref=None, use_keyring=True, force_new_token=False, + stale_duration=None): super(HTTPClient, self).__init__(timeout=timeout, ca_certs=cacert) if cert: if key: @@ -95,8 +106,141 @@ class HTTPClient(httplib2.Http): _logger.setLevel(logging.DEBUG) _logger.addHandler(ch) - def authenticate(self): - """ Authenticate against the Identity API. + # keyring setup + self.use_keyring = use_keyring and keyring_available + self.force_new_token = force_new_token + self.stale_duration = stale_duration or access.STALE_TOKEN_DURATION + self.stale_duration = int(self.stale_duration) + + def authenticate(self, username=None, password=None, tenant_name=None, + tenant_id=None, auth_url=None, token=None): + """ Authenticate user. + + Uses the data provided at instantiation to authenticate against + the Keystone server. This may use either a username and password + or token for authentication. If a tenant name or id was provided + then the resulting authenticated client will be scoped to that + tenant and contain a service catalog of available endpoints. + + With the v2.0 API, if a tenant name or ID is not provided, the + authenication token returned will be 'unscoped' and limited in + capabilities until a fully-scoped token is acquired. + + If successful, sets the self.auth_ref and self.auth_token with + the returned token. If not already set, will also set + self.management_url from the details provided in the token. + + :returns: ``True`` if authentication was successful. + :raises: AuthorizationFailure if unable to authenticate or validate + the existing authorization token + :raises: ValueError if insufficient parameters are used. + + If keyring is used, token is retrieved from keyring instead. + Authentication will only be necessary if any of the following + conditions are met: + + * keyring is not used + * if token is not found in keyring + * if token retrieved from keyring is expired or about to + expired (as determined by stale_duration) + * if force_new_token is true + + """ + auth_url = auth_url or self.auth_url + username = username or self.username + password = password or self.password + tenant_name = tenant_name or self.tenant_name + tenant_id = tenant_id or self.tenant_id + token = token or self.auth_token + + (keyring_key, auth_ref) = self.get_auth_ref_from_keyring(auth_url, + username, + tenant_name, + tenant_id, + token) + new_token_needed = False + if auth_ref is None or self.force_new_token: + new_token_needed = True + raw_token = self.get_raw_token_from_identity_service(auth_url, + username, + password, + tenant_name, + tenant_id, + token) + self.auth_ref = access.AccessInfo(**raw_token) + else: + self.auth_ref = auth_ref + self.process_token() + if new_token_needed: + self.store_auth_ref_into_keyring(keyring_key) + return True + + def _build_keyring_key(self, auth_url, username, tenant_name, + tenant_id, token): + """ Create a unique key for keyring. + + Used to store and retrieve auth_ref from keyring. + + """ + keys = [auth_url, username, tenant_name, tenant_id, token] + for index, key in enumerate(keys): + if key is None: + keys[index] = '?' + keyring_key = '/'.join(keys) + return keyring_key + + def get_auth_ref_from_keyring(self, auth_url, username, tenant_name, + tenant_id, token): + """ Retrieve auth_ref from keyring. + + If auth_ref is found in keyring, (keyring_key, auth_ref) is returned. + Otherwise, (keyring_key, None) is returned. + + :returns: (keyring_key, auth_ref) or (keyring_key, None) + + """ + keyring_key = None + auth_ref = None + if self.use_keyring: + keyring_key = self._build_keyring_key(auth_url, username, + tenant_name, tenant_id, + token) + try: + auth_ref = keyring.get_password("keystoneclient_auth", + keyring_key) + if auth_ref: + auth_ref = pickle.loads(auth_ref) + if auth_ref.will_expire_soon(self.stale_duration): + # token has expired, don't use it + auth_ref = None + except Exception as e: + auth_ref = None + _logger.warning('Unable to retrieve token from keyring %s' % ( + e)) + return (keyring_key, auth_ref) + + def store_auth_ref_into_keyring(self, keyring_key): + """ Store auth_ref into keyring. + + """ + if self.use_keyring: + try: + keyring.set_password("keystoneclient_auth", + keyring_key, + pickle.dumps(self.auth_ref)) + except Exception as e: + _logger.warning("Failed to store token into keyring %s" % (e)) + + def process_token(self): + """ Extract and process information from the new auth_ref. + + """ + raise NotImplementedError + + def get_raw_token_from_identity_service(self, auth_url, username=None, + password=None, tenant_name=None, + tenant_id=None, token=None): + """ Authenticate against the Identity API and get a token. Not implemented here because auth protocols should be API version-specific. @@ -104,6 +248,9 @@ class HTTPClient(httplib2.Http): Expected to authenticate or validate an existing authentication reference already associated with the client. Invoking this call *always* makes a call to the Keystone. + + :returns: ``raw token`` + """ raise NotImplementedError diff --git a/keystoneclient/shell.py b/keystoneclient/shell.py index 578c9830f..9d9ed386e 100644 --- a/keystoneclient/shell.py +++ b/keystoneclient/shell.py @@ -25,6 +25,8 @@ import os import sys import keystoneclient + +from keystoneclient import access from keystoneclient import exceptions as exc from keystoneclient import utils from keystoneclient.v2_0 import shell as shell_v2_0 @@ -178,6 +180,37 @@ class OpenStackIdentityShell(object): 'against any certificate authorities. This ' 'option should be used with caution.') + parser.add_argument('--no-cache', + default=env('OS_NO_CACHE', + default=False), + action='store_true', + help='Don\'t use the auth token cache. ' + 'Default to env[OS_NO_CACHE]') + parser.add_argument('--no_cache', + help=argparse.SUPPRESS) + + parser.add_argument('--force-new-token', + default=False, + action="store_true", + dest='force_new_token', + help="If keyring is available and in used, " + "token will always be stored and fetched " + "from the keyring, until the token has " + "expired. Use this option to request a " + "new token and replace the existing one " + "in keyring.") + + parser.add_argument('--stale-duration', + metavar='', + default=access.STALE_TOKEN_DURATION, + dest='stale_duration', + help="Stale duration (in seconds) used to " + "determine whether a token has expired " + "when retrieving it from keyring. This " + "is useful in mitigating process or " + "network delays. Default is %s seconds." % ( + access.STALE_TOKEN_DURATION)) + #FIXME(heckj): # deprecated command line options for essex compatibility. To be # removed in Grizzly release cycle. @@ -375,7 +408,10 @@ class OpenStackIdentityShell(object): key=args.os_key, cert=args.os_cert, insecure=args.insecure, - debug=args.debug) + debug=args.debug, + use_keyring=(not args.no_cache), + force_new_token=args.force_new_token, + stale_duration=args.stale_duration) try: args.func(self.cs, args) diff --git a/keystoneclient/v2_0/client.py b/keystoneclient/v2_0/client.py index 34fcdfc79..7ffaba681 100644 --- a/keystoneclient/v2_0/client.py +++ b/keystoneclient/v2_0/client.py @@ -139,57 +139,41 @@ class Client(client.HTTPClient): """Returns True if this client provides a service catalog.""" return hasattr(self, 'service_catalog') - def authenticate(self, username=None, password=None, tenant_name=None, - tenant_id=None, auth_url=None, token=None): + def process_token(self): + """ Extract and process information from the new auth_ref. + + And set the relevant authentication information. + """ + # if we got a response without a service catalog, set the local + # list of tenants for introspection, and leave to client user + # to determine what to do. Otherwise, load up the service catalog + self.auth_token = self.auth_ref.auth_token + if self.auth_ref.scoped: + if self.management_url is None: + self.management_url = self.auth_ref.management_url[0] + self.tenant_name = self.auth_ref.tenant_name + self.tenant_id = self.auth_ref.tenant_id + self.user_id = self.auth_ref.user_id + self._extract_service_catalog(self.auth_url, self.auth_ref) + + def get_raw_token_from_identity_service(self, auth_url, username=None, + password=None, tenant_name=None, + tenant_id=None, token=None): """ Authenticate against the Keystone API. - Uses the data provided at instantiation to authenticate against - the Keystone server. This may use either a username and password - or token for authentication. If a tenant name or id was provided - then the resulting authenticated client will be scoped to that - tenant and contain a service catalog of available endpoints. - - With the v2.0 API, if a tenant name or ID is not provided, the - authenication token returned will be 'unscoped' and limited in - capabilities until a fully-scoped token is acquired. - - If successful, sets the self.auth_ref and self.auth_token with - the returned token. If not already set, will also set - self.management_url from the details provided in the token. - - :returns: ``True`` if authentication was successful. + :returns: ``raw token`` if authentication was successful. :raises: AuthorizationFailure if unable to authenticate or validate the existing authorization token :raises: ValueError if insufficient parameters are used. - """ - auth_url = auth_url or self.auth_url - username = username or self.username - password = password or self.password - tenant_name = tenant_name or self.tenant_name - tenant_id = tenant_id or self.tenant_id - token = token or self.auth_token + """ try: - raw_token = self._base_authN(auth_url, - username=username, - tenant_id=tenant_id, - tenant_name=tenant_name, - password=password, - token=token) - self.auth_ref = access.AccessInfo(**raw_token) - # if we got a response without a service catalog, set the local - # list of tenants for introspection, and leave to client user - # to determine what to do. Otherwise, load up the service catalog - self.auth_token = self.auth_ref.auth_token - if self.auth_ref.scoped: - if self.management_url is None \ - and self.auth_ref.management_url: - self.management_url = self.auth_ref.management_url[0] - self.tenant_name = self.auth_ref.tenant_name - self.tenant_id = self.auth_ref.tenant_id - self.user_id = self.auth_ref.user_id - self._extract_service_catalog(self.auth_url, self.auth_ref) - return True + return self._base_authN(auth_url, + username=username, + tenant_id=tenant_id, + tenant_name=tenant_name, + password=password, + token=token) except (exceptions.AuthorizationFailure, exceptions.Unauthorized): _logger.debug("Authorization Failed.") raise diff --git a/tests/test_access.py b/tests/test_access.py index be2a186bc..621fefc5a 100644 --- a/tests/test_access.py +++ b/tests/test_access.py @@ -1,4 +1,7 @@ +import datetime + from keystoneclient import access +from keystoneclient.openstack.common import timeutils from tests import utils from tests import client_fixtures @@ -28,6 +31,16 @@ class AccessInfoTest(utils.TestCase): self.assertFalse(auth_ref.scoped) + self.assertEquals(auth_ref.expires, timeutils.parse_isotime( + UNSCOPED_TOKEN['access']['token']['expires'])) + + def test_will_expire_soon(self): + expires = timeutils.utcnow() + datetime.timedelta(minutes=5) + UNSCOPED_TOKEN['access']['token']['expires'] = expires.isoformat() + auth_ref = access.AccessInfo(UNSCOPED_TOKEN['access']) + self.assertFalse(auth_ref.will_expire_soon(stale_duration=120)) + self.assertTrue(auth_ref.will_expire_soon(stale_duration=300)) + def test_building_scoped_accessinfo(self): auth_ref = access.AccessInfo(PROJECT_SCOPED_TOKEN['access']) diff --git a/tests/test_shell.py b/tests/test_shell.py index 116e380f0..2d2e3bfc3 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -89,6 +89,24 @@ class ShellTest(utils.TestCase): 'wilma', 'betty', '2.0') self.assertTrue(all([x == y for x, y in zip(actual, expect)])) + # Test keyring options + shell('--os-auth-url http://1.1.1.1:5000/ --os-password xyzpdq ' + '--os-tenant-id 4321 --os-tenant-name wilma ' + '--os-username betty ' + '--os-identity-api-version 2.0 ' + '--no-cache ' + '--stale-duration 500 ' + '--force-new-token user-list') + assert do_tenant_mock.called + ((a, b), c) = do_tenant_mock.call_args + actual = (b.os_auth_url, b.os_password, b.os_tenant_id, + b.os_tenant_name, b.os_username, + b.os_identity_api_version, b.no_cache, + b.stale_duration, b.force_new_token) + expect = ('http://1.1.1.1:5000/', 'xyzpdq', '4321', + 'wilma', 'betty', '2.0', True, '500', True) + self.assertTrue(all([x == y for x, y in zip(actual, expect)])) + def test_shell_user_create_args(self): """Test user-create args""" do_uc_mock = mock.MagicMock() diff --git a/tools/test-requires b/tools/test-requires index 69d94ba90..caa6e2c20 100644 --- a/tools/test-requires +++ b/tools/test-requires @@ -1,6 +1,7 @@ distribute>=0.6.24 coverage +keyring mock mox nose