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

View File

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

View File

@ -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='<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):
# 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)

View File

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

View File

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

View File

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

View File

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