bug-1040361: use keyring to store tokens

User can optionally turn off keyring by specifying the --no-cache option.
It can also be disabled with environment variable OS-NO-CACHE.

Change-Id: I8935260bf7fd6befa14798da9b4d02c81e65c417
This commit is contained in:
Guang Yee
2012-11-08 16:32:17 -08:00
parent 94cbb61663
commit 5939541bc7
7 changed files with 279 additions and 48 deletions

View File

@@ -15,6 +15,15 @@
# limitations under the License. # 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): class AccessInfo(dict):
"""An object for encapsulating a raw authentication token from keystone """An object for encapsulating a raw authentication token from keystone
and helper methods for extracting useful values from that token.""" and helper methods for extracting useful values from that token."""
@@ -22,6 +31,29 @@ class AccessInfo(dict):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
dict.__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 @property
def auth_token(self): def auth_token(self):
""" Returns the token_id associated with the auth request, to be used """ Returns the token_id associated with the auth request, to be used

View File

@@ -32,6 +32,16 @@ from keystoneclient import exceptions
_logger = logging.getLogger(__name__) _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): class HTTPClient(httplib2.Http):
USER_AGENT = 'python-keystoneclient' USER_AGENT = 'python-keystoneclient'
@@ -40,7 +50,8 @@ class HTTPClient(httplib2.Http):
password=None, auth_url=None, region_name=None, timeout=None, password=None, auth_url=None, region_name=None, timeout=None,
endpoint=None, token=None, cacert=None, key=None, endpoint=None, token=None, cacert=None, key=None,
cert=None, insecure=False, original_ip=None, debug=False, 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) super(HTTPClient, self).__init__(timeout=timeout, ca_certs=cacert)
if cert: if cert:
if key: if key:
@@ -95,8 +106,141 @@ class HTTPClient(httplib2.Http):
_logger.setLevel(logging.DEBUG) _logger.setLevel(logging.DEBUG)
_logger.addHandler(ch) _logger.addHandler(ch)
def authenticate(self): # keyring setup
""" Authenticate against the Identity API. 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 Not implemented here because auth protocols should be API
version-specific. version-specific.
@@ -104,6 +248,9 @@ class HTTPClient(httplib2.Http):
Expected to authenticate or validate an existing authentication Expected to authenticate or validate an existing authentication
reference already associated with the client. Invoking this call reference already associated with the client. Invoking this call
*always* makes a call to the Keystone. *always* makes a call to the Keystone.
:returns: ``raw token``
""" """
raise NotImplementedError raise NotImplementedError

View File

@@ -25,6 +25,8 @@ import os
import sys import sys
import keystoneclient import keystoneclient
from keystoneclient import access
from keystoneclient import exceptions as exc from keystoneclient import exceptions as exc
from keystoneclient import utils from keystoneclient import utils
from keystoneclient.v2_0 import shell as shell_v2_0 from keystoneclient.v2_0 import shell as shell_v2_0
@@ -178,6 +180,37 @@ class OpenStackIdentityShell(object):
'against any certificate authorities. This ' 'against any certificate authorities. This '
'option should be used with caution.') '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='<seconds>',
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): #FIXME(heckj):
# deprecated command line options for essex compatibility. To be # deprecated command line options for essex compatibility. To be
# removed in Grizzly release cycle. # removed in Grizzly release cycle.
@@ -375,7 +408,10 @@ class OpenStackIdentityShell(object):
key=args.os_key, key=args.os_key,
cert=args.os_cert, cert=args.os_cert,
insecure=args.insecure, 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: try:
args.func(self.cs, args) args.func(self.cs, args)

View File

@@ -139,57 +139,41 @@ class Client(client.HTTPClient):
"""Returns True if this client provides a service catalog.""" """Returns True if this client provides a service catalog."""
return hasattr(self, 'service_catalog') return hasattr(self, 'service_catalog')
def authenticate(self, username=None, password=None, tenant_name=None, def process_token(self):
tenant_id=None, auth_url=None, token=None): """ 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. """ Authenticate against the Keystone API.
Uses the data provided at instantiation to authenticate against :returns: ``raw token`` if authentication was successful.
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 :raises: AuthorizationFailure if unable to authenticate or validate
the existing authorization token the existing authorization token
:raises: ValueError if insufficient parameters are used. :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: try:
raw_token = self._base_authN(auth_url, return self._base_authN(auth_url,
username=username, username=username,
tenant_id=tenant_id, tenant_id=tenant_id,
tenant_name=tenant_name, tenant_name=tenant_name,
password=password, password=password,
token=token) 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
except (exceptions.AuthorizationFailure, exceptions.Unauthorized): except (exceptions.AuthorizationFailure, exceptions.Unauthorized):
_logger.debug("Authorization Failed.") _logger.debug("Authorization Failed.")
raise raise

View File

@@ -1,4 +1,7 @@
import datetime
from keystoneclient import access from keystoneclient import access
from keystoneclient.openstack.common import timeutils
from tests import utils from tests import utils
from tests import client_fixtures from tests import client_fixtures
@@ -28,6 +31,16 @@ class AccessInfoTest(utils.TestCase):
self.assertFalse(auth_ref.scoped) 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): def test_building_scoped_accessinfo(self):
auth_ref = access.AccessInfo(PROJECT_SCOPED_TOKEN['access']) auth_ref = access.AccessInfo(PROJECT_SCOPED_TOKEN['access'])

View File

@@ -89,6 +89,24 @@ class ShellTest(utils.TestCase):
'wilma', 'betty', '2.0') 'wilma', 'betty', '2.0')
self.assertTrue(all([x == y for x, y in zip(actual, expect)])) 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): def test_shell_user_create_args(self):
"""Test user-create args""" """Test user-create args"""
do_uc_mock = mock.MagicMock() do_uc_mock = mock.MagicMock()

View File

@@ -1,6 +1,7 @@
distribute>=0.6.24 distribute>=0.6.24
coverage coverage
keyring
mock mock
mox mox
nose nose