Implements Pluggable V2 Token Provider

This patch implemented V2 token provider.

Abstract token provider backend to make token provider pluggable. It enables
deployers to customize token management to add their own capabilities.
Token provider is responsible for issuing, checking, validating, and
revoking tokens. Note the distinction between token 'driver' and 'provider'.
Token 'driver' simply provides token CRUD. It does not issue or interpret
tokens.

Token provider is specified by the 'provider' property in the '[token]'
section of the Keystone configuration file.

Change-Id: Ic418ec433bd9e3f2f70fa31c90e570e32c1ca687
This commit is contained in:
Guang Yee 2013-06-24 23:37:59 -07:00
parent c238ace309
commit ee27d6eef6
6 changed files with 287 additions and 229 deletions

View File

@ -26,7 +26,7 @@ def _build_policy_check_credentials(self, action, context, kwargs):
raise exception.Unauthorized()
creds = {}
if 'token_data' in token_ref:
if 'token_data' in token_ref and 'token' in token_ref['token_data']:
#V3 Tokens
token_data = token_ref['token_data']['token']
try:

View File

@ -97,7 +97,7 @@ class Ec2Extension(wsgi.ExtensionRouter):
conditions=dict(method=['DELETE']))
@dependency.requires('catalog_api', 'ec2_api')
@dependency.requires('catalog_api', 'ec2_api', 'token_provider_api')
class Ec2Controller(controller.V2Controller):
def check_signature(self, creds_ref, credentials):
signer = ec2_utils.Ec2Signer(creds_ref['secret'])
@ -172,17 +172,16 @@ class Ec2Controller(controller.V2Controller):
tenant_id=tenant_ref['id'],
metadata=metadata_ref)
token_ref = self.token_api.create_token(
token_id, dict(id=token_id,
user=user_ref,
tenant=tenant_ref,
metadata=metadata_ref))
# TODO(termie): i don't think the ec2 middleware currently expects a
# full return, but it contains a note saying that it
# would be better to expect a full return
return token.controllers.Auth.format_authenticate(
token_ref, roles_ref, catalog_ref)
auth_token_data = dict(user=user_ref,
tenant=tenant_ref,
metadata=metadata_ref,
id='placeholder')
(token_id, token_data) = self.token_provider_api.issue_token(
version=token.provider.V2,
token_ref=auth_token_data,
roles_ref=roles_ref,
catalog_ref=catalog_ref)
return token_data
def create_credential(self, context, user_id, tenant_id):
"""Create a secret/access pair for use with ec2 style auth.

View File

@ -1,16 +1,16 @@
import json
import sys
import uuid
from keystone.common import cms
from keystone.common import controller
from keystone.common import environment
from keystone.common import dependency
from keystone.common import logging
from keystone.common import utils
from keystone import config
from keystone import exception
from keystone.openstack.common import timeutils
from keystone.token import core
from keystone.token import provider as token_provider
CONF = config.CONF
LOG = logging.getLogger(__name__)
@ -22,6 +22,7 @@ class ExternalAuthNotApplicable(Exception):
pass
@dependency.requires('token_provider_api')
class Auth(controller.V2Controller):
def ca_cert(self, context, auth=None):
ca_file = open(CONF.signing.ca_certs, 'r')
@ -79,7 +80,6 @@ class Auth(controller.V2Controller):
user_ref, tenant_ref, metadata_ref, expiry = auth_info
core.validate_auth_info(self, user_ref, tenant_ref)
trust_id = metadata_ref.get('trust_id')
user_ref = self._filter_domain_id(user_ref)
if tenant_ref:
tenant_ref = self._filter_domain_id(tenant_ref)
@ -103,46 +103,11 @@ class Auth(controller.V2Controller):
role_ref = self.identity_api.get_role(role_id)
roles_ref.append(dict(name=role_ref['name']))
token_data = Auth.format_token(auth_token_data, roles_ref)
service_catalog = Auth.format_catalog(catalog_ref)
token_data['access']['serviceCatalog'] = service_catalog
if CONF.signing.token_format == 'UUID':
token_id = uuid.uuid4().hex
elif CONF.signing.token_format == 'PKI':
try:
token_id = cms.cms_sign_token(json.dumps(token_data),
CONF.signing.certfile,
CONF.signing.keyfile)
except environment.subprocess.CalledProcessError:
raise exception.UnexpectedError(_(
'Unable to sign token.'))
else:
raise exception.UnexpectedError(_(
'Invalid value for token_format: %s.'
' Allowed values are PKI or UUID.') %
CONF.signing.token_format)
try:
self.token_api.create_token(
token_id, dict(key=token_id,
id=token_id,
expires=auth_token_data['expires'],
user=user_ref,
tenant=tenant_ref,
metadata=metadata_ref,
trust_id=trust_id))
except Exception:
exc_info = sys.exc_info()
# an identical token may have been created already.
# if so, return the token_data as it is also identical
try:
self.token_api.get_token(token_id)
except exception.TokenNotFound:
raise exc_info[0], exc_info[1], exc_info[2]
token_data['access']['token']['id'] = token_id
(token_id, token_data) = self.token_provider_api.issue_token(
version=token_provider.V2,
token_ref=auth_token_data,
roles_ref=roles_ref,
catalog_ref=catalog_ref)
return token_data
def _authenticate_token(self, context, auth):
@ -416,45 +381,6 @@ class Auth(controller.V2Controller):
_('Token does not belong to specified tenant.'))
return data
def _assert_default_domain(self, token_ref):
"""Make sure we are operating on default domain only."""
if token_ref.get('token_data'):
# this is a V3 token
msg = _('Non-default domain is not supported')
# user in a non-default is prohibited
if (token_ref['token_data']['token']['user']['domain']['id'] !=
DEFAULT_DOMAIN_ID):
raise exception.Unauthorized(msg)
# domain scoping is prohibited
if token_ref['token_data']['token'].get('domain'):
raise exception.Unauthorized(
_('Domain scoped token is not supported'))
# project in non-default domain is prohibited
if token_ref['token_data']['token'].get('project'):
project = token_ref['token_data']['token']['project']
project_domain_id = project['domain']['id']
# scoped to project in non-default domain is prohibited
if project_domain_id != DEFAULT_DOMAIN_ID:
raise exception.Unauthorized(msg)
# if token is scoped to trust, both trustor and trustee must
# be in the default domain. Furthermore, the delegated project
# must also be in the default domain
metadata_ref = token_ref['metadata']
if CONF.trust.enabled and 'trust_id' in metadata_ref:
trust_ref = self.trust_api.get_trust(metadata_ref['trust_id'])
trustee_user_ref = self.identity_api.get_user(
trust_ref['trustee_user_id'])
if trustee_user_ref['domain_id'] != DEFAULT_DOMAIN_ID:
raise exception.Unauthorized(msg)
trustor_user_ref = self.identity_api.get_user(
trust_ref['trustor_user_id'])
if trustor_user_ref['domain_id'] != DEFAULT_DOMAIN_ID:
raise exception.Unauthorized(msg)
project_ref = self.identity_api.get_project(
trust_ref['project_id'])
if project_ref['domain_id'] != DEFAULT_DOMAIN_ID:
raise exception.Unauthorized(msg)
@controller.protected
def validate_token_head(self, context, token_id):
"""Check that a token is valid.
@ -465,9 +391,9 @@ class Auth(controller.V2Controller):
"""
belongs_to = context['query_string'].get('belongsTo')
token_ref = self._get_token_ref(token_id, belongs_to)
assert token_ref
self._assert_default_domain(token_ref)
self.token_provider_api.check_token(token_id,
belongs_to=belongs_to,
version=token_provider.V2)
@controller.protected
def validate_token(self, context, token_id):
@ -479,26 +405,9 @@ class Auth(controller.V2Controller):
"""
belongs_to = context['query_string'].get('belongsTo')
token_ref = self._get_token_ref(token_id, belongs_to)
self._assert_default_domain(token_ref)
# TODO(termie): optimize this call at some point and put it into the
# the return for metadata
# fill out the roles in the metadata
metadata_ref = token_ref['metadata']
roles_ref = []
for role_id in metadata_ref.get('roles', []):
roles_ref.append(self.identity_api.get_role(role_id))
# Get a service catalog if possible
# This is needed for on-behalf-of requests
catalog_ref = None
if token_ref.get('tenant'):
catalog_ref = self.catalog_api.get_catalog(
user_id=token_ref['user']['id'],
tenant_id=token_ref['tenant']['id'],
metadata=metadata_ref)
return Auth.format_token(token_ref, roles_ref, catalog_ref)
return self.token_provider_api.validate_token(
token_id, belongs_to=belongs_to,
version=token_provider.V2)
def delete_token(self, context, token_id):
"""Delete a token, effectively invalidating it for authz."""
@ -537,99 +446,6 @@ class Auth(controller.V2Controller):
return Auth.format_endpoint_list(catalog_ref)
@classmethod
def format_authenticate(cls, token_ref, roles_ref, catalog_ref):
o = Auth.format_token(token_ref, roles_ref)
o['access']['serviceCatalog'] = Auth.format_catalog(catalog_ref)
return o
@classmethod
def format_token(cls, token_ref, roles_ref, catalog_ref=None):
user_ref = token_ref['user']
metadata_ref = token_ref['metadata']
expires = token_ref['expires']
if expires is not None:
if not isinstance(expires, unicode):
expires = timeutils.isotime(expires)
o = {'access': {'token': {'id': token_ref['id'],
'expires': expires,
'issued_at': timeutils.strtime()
},
'user': {'id': user_ref['id'],
'name': user_ref['name'],
'username': user_ref['name'],
'roles': roles_ref,
'roles_links': metadata_ref.get('roles_links',
[])
}
}
}
if 'tenant' in token_ref and token_ref['tenant']:
token_ref['tenant']['enabled'] = True
o['access']['token']['tenant'] = token_ref['tenant']
if catalog_ref is not None:
o['access']['serviceCatalog'] = Auth.format_catalog(catalog_ref)
if metadata_ref:
if 'is_admin' in metadata_ref:
o['access']['metadata'] = {'is_admin':
metadata_ref['is_admin']}
else:
o['access']['metadata'] = {'is_admin': 0}
if 'roles' in metadata_ref:
o['access']['metadata']['roles'] = metadata_ref['roles']
if CONF.trust.enabled and 'trust_id' in metadata_ref:
o['access']['trust'] = {'trustee_user_id':
metadata_ref['trustee_user_id'],
'id': metadata_ref['trust_id']
}
return o
@classmethod
def format_catalog(cls, catalog_ref):
"""Munge catalogs from internal to output format
Internal catalogs look like:
{$REGION: {
{$SERVICE: {
$key1: $value1,
...
}
}
}
The legacy api wants them to look like
[{'name': $SERVICE[name],
'type': $SERVICE,
'endpoints': [{
'tenantId': $tenant_id,
...
'region': $REGION,
}],
'endpoints_links': [],
}]
"""
if not catalog_ref:
return []
services = {}
for region, region_ref in catalog_ref.iteritems():
for service, service_ref in region_ref.iteritems():
new_service_ref = services.get(service, {})
new_service_ref['name'] = service_ref.pop('name')
new_service_ref['type'] = service
new_service_ref['endpoints_links'] = []
service_ref['region'] = region
endpoints_ref = new_service_ref.get('endpoints', [])
endpoints_ref.append(service_ref)
new_service_ref['endpoints'] = endpoints_ref
services[service] = new_service_ref
return services.values()
@classmethod
def format_endpoint_list(cls, catalog_ref):
"""Formats a list of endpoints according to Identity API v2.

View File

@ -81,6 +81,9 @@ class Provider(object):
domain-scoped token; and 'auth_context' from the authentication
plugins.
For V2 tokens, 'token_ref' must be present in kwargs.
Optionally, kwargs may contain 'roles_ref' and 'catalog_ref'.
:param context: request context
:type context: dictionary
:param version: version of the token to be issued

View File

@ -27,7 +27,6 @@ from keystone import config
from keystone import exception
from keystone.openstack.common import timeutils
from keystone import token
from keystone.token import provider as token_provider
from keystone import trust
@ -36,6 +35,108 @@ CONF = config.CONF
DEFAULT_DOMAIN_ID = CONF.identity.default_domain_id
@dependency.requires('catalog_api', 'identity_api')
class V2TokenDataHelper(object):
"""Creates V2 token data."""
@classmethod
def format_token(cls, token_ref, roles_ref, catalog_ref=None):
user_ref = token_ref['user']
metadata_ref = token_ref['metadata']
expires = token_ref.get('expires', token.default_expire_time())
if expires is not None:
if not isinstance(expires, unicode):
expires = timeutils.isotime(expires)
o = {'access': {'token': {'id': token_ref['id'],
'expires': expires,
'issued_at': timeutils.strtime()
},
'user': {'id': user_ref['id'],
'name': user_ref['name'],
'username': user_ref['name'],
'roles': roles_ref,
'roles_links': metadata_ref.get('roles_links',
[])
}
}
}
if 'tenant' in token_ref and token_ref['tenant']:
token_ref['tenant']['enabled'] = True
o['access']['token']['tenant'] = token_ref['tenant']
if catalog_ref is not None:
o['access']['serviceCatalog'] = V2TokenDataHelper.format_catalog(
catalog_ref)
if metadata_ref:
if 'is_admin' in metadata_ref:
o['access']['metadata'] = {'is_admin':
metadata_ref['is_admin']}
else:
o['access']['metadata'] = {'is_admin': 0}
if 'roles' in metadata_ref:
o['access']['metadata']['roles'] = metadata_ref['roles']
if CONF.trust.enabled and 'trust_id' in metadata_ref:
o['access']['trust'] = {'trustee_user_id':
metadata_ref['trustee_user_id'],
'id': metadata_ref['trust_id']
}
return o
@classmethod
def format_catalog(cls, catalog_ref):
"""Munge catalogs from internal to output format
Internal catalogs look like:
{$REGION: {
{$SERVICE: {
$key1: $value1,
...
}
}
}
The legacy api wants them to look like
[{'name': $SERVICE[name],
'type': $SERVICE,
'endpoints': [{
'tenantId': $tenant_id,
...
'region': $REGION,
}],
'endpoints_links': [],
}]
"""
if not catalog_ref:
return []
services = {}
for region, region_ref in catalog_ref.iteritems():
for service, service_ref in region_ref.iteritems():
new_service_ref = services.get(service, {})
new_service_ref['name'] = service_ref.pop('name')
new_service_ref['type'] = service
new_service_ref['endpoints_links'] = []
service_ref['region'] = region
endpoints_ref = new_service_ref.get('endpoints', [])
endpoints_ref.append(service_ref)
new_service_ref['endpoints'] = endpoints_ref
services[service] = new_service_ref
return services.values()
@classmethod
def get_token_data(cls, **kwargs):
if 'token_ref' not in kwargs:
raise ValueError('Require token_ref to create V2 token data')
token_ref = kwargs.get('token_ref')
roles_ref = kwargs.get('roles_ref', [])
catalog_ref = kwargs.get('catalog_ref')
return V2TokenDataHelper.format_token(
token_ref, roles_ref, catalog_ref)
@dependency.requires('catalog_api', 'identity_api')
class V3TokenDataHelper(object):
"""Token data helper."""
@ -207,25 +308,56 @@ class V3TokenDataHelper(object):
return {'token': token_data}
@dependency.requires('token_api', 'identity_api')
class Provider(token_provider.Provider):
@dependency.requires('token_api', 'identity_api', 'catalog_api')
class Provider(token.provider.Provider):
def __init__(self, *args, **kwargs):
super(Provider, self).__init__(*args, **kwargs)
if CONF.trust.enabled:
self.trust_api = trust.Manager()
self.v3_token_data_helper = V3TokenDataHelper()
self.v2_token_data_helper = V2TokenDataHelper()
def get_token_version(self, token_data):
if token_data and isinstance(token_data, dict):
if 'access' in token_data:
return token_provider.V2
return token.provider.V2
if 'token' in token_data and 'methods' in token_data['token']:
return token_provider.V3
raise token_provider.UnsupportedTokenVersionException()
return token.provider.V3
raise token.provider.UnsupportedTokenVersionException()
def _get_token_id(self, token_data):
return uuid.uuid4().hex
def _issue_v2_token(self, **kwargs):
token_data = self.v2_token_data_helper.get_token_data(**kwargs)
token_id = self._get_token_id(token_data)
token_data['access']['token']['id'] = token_id
try:
expiry = token_data['access']['token']['expires']
token_ref = kwargs.get('token_ref')
if isinstance(expiry, basestring):
expiry = timeutils.normalize_time(
timeutils.parse_isotime(expiry))
data = dict(key=token_id,
id=token_id,
expires=expiry,
user=token_ref['user'],
tenant=token_ref['tenant'],
metadata=token_ref['metadata'],
token_data=token_data,
trust_id=token_ref['metadata'].get('trust_id'))
self.token_api.create_token(token_id, data)
except Exception:
exc_info = sys.exc_info()
# an identical token may have been created already.
# if so, return the token_data as it is also identical
try:
self.token_api.get_token(token_id)
except exception.TokenNotFound:
raise exc_info[0], exc_info[1], exc_info[2]
return (token_id, token_data)
def _issue_v3_token(self, **kwargs):
user_id = kwargs.get('user_id')
method_names = kwargs.get('method_names')
@ -287,21 +419,104 @@ class Provider(token_provider.Provider):
return (token_id, token_data)
def issue_token(self, version='v3.0', **kwargs):
if version == token_provider.V3:
if version == token.provider.V3:
return self._issue_v3_token(**kwargs)
raise token_provider.UnsupportedTokenVersionException
elif version == token.provider.V2:
return self._issue_v2_token(**kwargs)
raise token.provider.UnsupportedTokenVersionException
def _verify_token(self, token_id, belongs_to=None):
"""Verify the given token and return the token_ref."""
token_ref = self.token_api.get_token(token_id=token_id)
assert token_ref
if belongs_to:
assert token_ref['tenant']['id'] == belongs_to
assert (token_ref['tenant'] and
token_ref['tenant']['id'] == belongs_to)
return token_ref
def revoke_token(self, token_id):
self.token_api.delete_token(token_id=token_id)
def _assert_default_domain(self, token_ref):
"""Make sure we are operating on default domain only."""
if (token_ref.get('token_data') and
self.get_token_version(token_ref.get('token_data')) ==
token.provider.V3):
# this is a V3 token
msg = _('Non-default domain is not supported')
# user in a non-default is prohibited
if (token_ref['token_data']['token']['user']['domain']['id'] !=
DEFAULT_DOMAIN_ID):
raise exception.Unauthorized(msg)
# domain scoping is prohibited
if token_ref['token_data']['token'].get('domain'):
raise exception.Unauthorized(
_('Domain scoped token is not supported'))
# project in non-default domain is prohibited
if token_ref['token_data']['token'].get('project'):
project = token_ref['token_data']['token']['project']
project_domain_id = project['domain']['id']
# scoped to project in non-default domain is prohibited
if project_domain_id != DEFAULT_DOMAIN_ID:
raise exception.Unauthorized(msg)
# if token is scoped to trust, both trustor and trustee must
# be in the default domain. Furthermore, the delegated project
# must also be in the default domain
metadata_ref = token_ref['metadata']
if CONF.trust.enabled and 'trust_id' in metadata_ref:
trust_ref = self.trust_api.get_trust(metadata_ref['trust_id'])
trustee_user_ref = self.identity_api.get_user(
trust_ref['trustee_user_id'])
if trustee_user_ref['domain_id'] != DEFAULT_DOMAIN_ID:
raise exception.Unauthorized(msg)
trustor_user_ref = self.identity_api.get_user(
trust_ref['trustor_user_id'])
if trustor_user_ref['domain_id'] != DEFAULT_DOMAIN_ID:
raise exception.Unauthorized(msg)
project_ref = self.identity_api.get_project(
trust_ref['project_id'])
if project_ref['domain_id'] != DEFAULT_DOMAIN_ID:
raise exception.Unauthorized(msg)
def _validate_v2_token(self, token_id, belongs_to=None, **kwargs):
try:
token_ref = self._verify_token(token_id, belongs_to=belongs_to)
self._assert_default_domain(token_ref)
# FIXME(gyee): performance or correctness? Should we return the
# cached token or reconstruct it? Obviously if we are going with
# the cached token, any role, project, or domain name changes
# will not be reflected. One may argue that with PKI tokens,
# we are essentially doing cached token validation anyway.
# Lets go with the cached token strategy. Since token
# management layer is now pluggable, one can always provide
# their own implementation to suit their needs.
token_data = token_ref.get('token_data')
if (not token_data or
self.get_token_version(token_data) !=
token.provider.V2):
# token is created by old v2 logic
metadata_ref = token_ref['metadata']
role_refs = []
for role_id in metadata_ref.get('roles', []):
role_refs.append(self.identity_api.get_role(role_id))
# Get a service catalog if possible
# This is needed for on-behalf-of requests
catalog_ref = None
if token_ref.get('tenant'):
catalog_ref = self.catalog_api.get_catalog(
token_ref['user']['id'],
token_ref['tenant']['id'],
metadata=metadata_ref)
token_data = self.v2_token_data_helper.get_token_data(
token_ref=token_ref,
roles_ref=role_refs,
catalog_ref=catalog_ref)
return token_data
except AssertionError as e:
LOG.exception(_('Failed to validate token'))
raise exception.Unauthorized(e)
def _validate_v3_token(self, token_id):
token_ref = self._verify_token(token_id)
# FIXME(gyee): performance or correctness? Should we return the
@ -327,12 +542,14 @@ class Provider(token_provider.Provider):
expires=token_ref['expires'])
return token_data
def validate_token(self, token_id, belongs_to=None,
version='v3.0'):
def validate_token(self, token_id, belongs_to=None, version='v3.0'):
try:
if version == token_provider.V3:
if version == token.provider.V3:
return self._validate_v3_token(token_id)
raise token_provider.UnsupportedTokenVersionException()
elif version == token.provider.V2:
return self._validate_v2_token(token_id,
belongs_to=belongs_to)
raise token.provider.UnsupportedTokenVersionException()
except exception.TokenNotFound as e:
LOG.exception(_('Failed to verify token'))
raise exception.Unauthorized(e)
@ -340,10 +557,9 @@ class Provider(token_provider.Provider):
def check_token(self, token_id, belongs_to=None,
version='v3.0', **kwargs):
try:
if version == token_provider.V3:
self._verify_token(token_id)
else:
raise token_provider.UnsupportedTokenVersionException()
token_ref = self._verify_token(token_id, belongs_to=belongs_to)
if version == token.provider.V2:
self._assert_default_domain(token_ref)
except exception.TokenNotFound as e:
LOG.exception(_('Failed to verify token'))
raise exception.Unauthorized(e)

View File

@ -20,6 +20,7 @@ import webob
import nose.exc
from keystone import test
from keystone import token
from keystone import config
from keystone.openstack.common import jsonutils
@ -42,6 +43,7 @@ class CompatTestCase(test.TestCase):
self.clear_module('keystoneclient')
self.load_backends()
self.token_provider_api = token.provider.Manager()
self.load_fixtures(default_fixtures)
self.public_server = self.serveapp('keystone', name='main')
@ -839,6 +841,28 @@ class KcMasterTestCase(CompatTestCase, KeystoneClientTests):
def get_checkout(self):
return KEYSTONECLIENT_REPO, 'master'
def test_ec2_auth(self):
client = self.get_client()
cred = client.ec2.create(user_id=self.user_foo['id'],
tenant_id=self.tenant_bar['id'])
from keystoneclient.contrib.ec2 import utils as ec2_utils
signer = ec2_utils.Ec2Signer(cred.secret)
credentials = {'params': {'SignatureVersion': '2'},
'access': cred.access,
'verb': 'GET',
'host': 'localhost',
'path': '/thisisgoingtowork'}
signature = signer.generate(credentials)
credentials['signature'] = signature
url = '%s/ec2tokens' % (client.auth_url)
(resp, token) = client.request(url=url,
method='POST',
body={'credentials': credentials})
# make sure we have a v2 token
self.assertEqual(resp.status_code, 200)
self.assertIn('access', token)
def test_tenant_add_and_remove_user(self):
client = self.get_client(admin=True)
client.roles.add_user_role(tenant=self.tenant_bar['id'],