diff options
author | Guang Yee <guang.yee@hp.com> | 2013-01-08 08:46:20 -0800 |
---|---|---|
committer | Guang Yee <guang.yee@hp.com> | 2013-02-20 13:18:38 -0800 |
commit | 9f812939d4b05384b0a7d48e6b916baeca0477dc (patch) | |
tree | dda2e10abea730ab99955b3d595e60735b273a1f /keystone/auth | |
parent | d036db145d51f8b134ffa36165065a8986e4f8a1 (diff) | |
download | keystone-9f812939d4b05384b0a7d48e6b916baeca0477dc.tar.gz keystone-9f812939d4b05384b0a7d48e6b916baeca0477dc.tar.xz keystone-9f812939d4b05384b0a7d48e6b916baeca0477dc.zip |
v3 token API
Also implemented the following:
blueprint pluggable-identity-authentication-handlers
blueprint stop-ids-in-uris
blueprint multi-factor-authn (just the plumbing)
What's missing?
* domain scoping (will be implemented by Henry?)
Change-Id: I191c0b2cb3367b2a5f8a2dc674c284bb13ea97e3
Diffstat (limited to 'keystone/auth')
-rw-r--r-- | keystone/auth/__init__.py | 19 | ||||
-rw-r--r-- | keystone/auth/controllers.py | 388 | ||||
-rw-r--r-- | keystone/auth/core.py | 83 | ||||
-rw-r--r-- | keystone/auth/methods/__init__.py | 0 | ||||
-rw-r--r-- | keystone/auth/methods/password.py | 114 | ||||
-rw-r--r-- | keystone/auth/methods/token.py | 49 | ||||
-rw-r--r-- | keystone/auth/routers.py | 43 | ||||
-rw-r--r-- | keystone/auth/token_factory.py | 238 |
8 files changed, 934 insertions, 0 deletions
diff --git a/keystone/auth/__init__.py b/keystone/auth/__init__.py new file mode 100644 index 00000000..614f3634 --- /dev/null +++ b/keystone/auth/__init__.py @@ -0,0 +1,19 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2013 OpenStack LLC +# +# 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. + +from keystone.auth.core import AuthMethodHandler +from keystone.auth import controllers +from keystone.auth import routers diff --git a/keystone/auth/controllers.py b/keystone/auth/controllers.py new file mode 100644 index 00000000..2ef4f8d1 --- /dev/null +++ b/keystone/auth/controllers.py @@ -0,0 +1,388 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2013 OpenStack LLC +# +# 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 json + +from keystone.auth import token_factory +from keystone.common import controller +from keystone.common import cms +from keystone.common import logging +from keystone import config +from keystone import exception +from keystone import identity +from keystone import token +from keystone.openstack.common import importutils + + +LOG = logging.getLogger(__name__) + +CONF = config.CONF + +# registry of authentication methods +AUTH_METHODS = {} + + +# register method drivers +for method_name in CONF.auth.methods: + try: + config.register_str(method_name, group='auth') + except Exception as e: + # don't care about duplicate error + LOG.warn(e) + + +def load_auth_method(method_name): + if method_name not in CONF.auth.methods: + raise exception.AuthMethodNotSupported() + driver = CONF.auth.get(method_name) + return importutils.import_object(driver) + + +def get_auth_method(method_name): + global AUTH_METHODS + if method_name not in AUTH_METHODS: + AUTH_METHODS[method_name] = load_auth_method(method_name) + return AUTH_METHODS[method_name] + + +class AuthInfo(object): + """ Encapsulation of "auth" request. """ + + def __init__(self, context, auth=None): + self.identity_api = identity.Manager() + self.context = context + self.auth = auth + self._scope_data = (None, None) + # self._scope_data is (domain_id, project_id) + # project scope: (None, project_id) + # domain scope: (domain_id, None) + # unscoped: (None, None) + self._validate_and_normalize_auth_data() + + def _assert_project_is_enabled(self, project_ref): + # ensure the project is enabled + if not project_ref.get('enabled', True): + msg = _('Project is disabled: %s') % project_ref['id'] + LOG.warning(msg) + raise exception.Unauthorized(msg) + + def _assert_domain_is_enabled(self, domain_ref): + if not domain_ref.get('enabled'): + msg = _('Domain is disabled: %s') % (domain_ref['id']) + LOG.warning(msg) + raise exception.Unauthorized(msg) + + def _assert_user_is_enabled(self, user_ref): + if not user_ref.get('enabled', True): + msg = _('User is disabled: %s') % (user_ref['id']) + LOG.warning(msg) + raise exception.Unauthorized(msg) + + def _lookup_domain(self, domain_info): + domain_id = domain_info.get('id') + domain_name = domain_info.get('name') + domain_ref = None + if not domain_id and not domain_name: + raise exception.ValidationError(attribute='id or name', + target='domain') + try: + if domain_name: + domain_ref = self.identity_api.get_domain_by_name( + context=self.context, domain_name=domain_name) + else: + domain_ref = self.identity_api.get_domain( + context=self.context, domain_id=domain_id) + except exception.DomainNotFound as e: + LOG.exception(e) + raise exception.Unauthorized(e) + self._assert_domain_is_enabled(domain_ref) + return domain_ref + + def _lookup_project(self, project_info): + project_id = project_info.get('id') + project_name = project_info.get('name') + project_ref = None + if not project_id and not project_name: + raise exception.ValidationError(attribute='id or name', + target='project') + try: + if project_name: + if 'domain' not in project_info: + raise exception.ValidationError(attribute='domain', + target='project') + domain_ref = self._lookup_domain(project_info['domain']) + project_ref = self.identity_api.get_project_by_name( + context=self.context, tenant_name=project_name, + domain_id=domain_ref['id']) + else: + project_ref = self.identity_api.get_project( + context=self.context, tenant_id=project_id) + except exception.ProjectNotFound as e: + LOG.exception(e) + raise exception.Unauthorized(e) + self._assert_project_is_enabled(project_ref) + return project_ref + + def lookup_user(self, user_info): + user_id = user_info.get('id') + user_name = user_info.get('name') + user_ref = None + if not user_id and not user_name: + raise exception.ValidationError(attribute='id or name', + target='user') + try: + if user_name: + if 'domain' not in user_info: + raise exception.ValidationError(attribute='domain', + target='user') + domain_ref = self._lookup_domain(user_info['domain']) + user_ref = self.identity_api.get_user_by_name( + context=self.context, user_name=user_name, + domain_id=domain_ref['id']) + else: + user_ref = self.identity_api.get_user( + context=self.context, user_id=user_id) + except exception.UserNotFound as e: + LOG.exception(e) + raise exception.Unauthorized(e) + self._assert_user_is_enabled(user_ref) + return user_ref + + def _validate_and_normalize_scope_data(self): + """ Validate and normalize scope data """ + if 'scope' not in self.auth: + return + + # if scoped, only to a project or domain, but not both + if ('project' not in self.auth['scope'] and + 'domain' not in self.auth['scope']): + # neither domain or project provided + raise exception.ValidationError(attribute='project or domain', + target='scope') + if ('project' in self.auth['scope'] and + 'domain' in self.auth['scope']): + # both domain and project provided + raise exception.ValidationError(attribute='project or domain', + target='scope') + + if 'project' in self.auth['scope']: + project_ref = self._lookup_project(self.auth['scope']['project']) + self._scope_data = (None, project_ref['id']) + else: + domain_ref = self._lookup_domain(self.auth['scope']['domain']) + self._scope_data = (domain_ref['id'], None) + + def _validate_auth_methods(self): + # make sure auth methods are provided + if 'methods' not in self.auth['authentication']: + raise exception.ValidationError(attribute='methods', + target='authentication') + + # make sure all the method data/payload are provided + for method_name in self.get_method_names(): + if method_name not in self.auth['authentication']: + raise exception.ValidationError(attribute=method_name, + target='authentication') + + # make sure auth method is supported + for method_name in self.get_method_names(): + if method_name not in CONF.auth.methods: + raise exception.AuthMethodNotSupported() + + def _validate_and_normalize_auth_data(self): + """ Make sure "auth" is valid. """ + # make sure "auth" exist + if not self.auth: + raise exception.ValidationError(attribute='auth', + target='request body') + + self._validate_auth_methods() + self._validate_and_normalize_scope_data() + + def get_method_names(self): + """ Returns the authentication method names. + + :returns: list of auth method names + + """ + return self.auth['authentication']['methods'] + + def get_method_data(self, method): + """ Get the auth method payload. + + :returns: auth method payload + + """ + if method not in self.auth['authentication']['methods']: + raise exception.ValidationError(attribute=method_name, + target='authentication') + return self.auth['authentication'][method] + + def get_scope(self): + """ Get scope information. + + Verify and return the scoping information. + + :returns: (domain_id, project_id). If scope to a project, + (None, project_id) will be returned. If scope to a domain, + (domain_id, None) will be returned. If unscope, + (None, None) will be returned. + + """ + return self._scope_data + + def set_scope(self, domain_id=None, project_id=None): + """ Set scope information. """ + if domain_id and project_id: + msg = _('Scoping to both domain and project is not allowed') + raise ValueError(msg) + self._scope_data = (domain_id, project_id) + + +class Auth(controller.V3Controller): + def __init__(self, *args, **kw): + super(Auth, self).__init__(*args, **kw) + self.token_controllers_ref = token.controllers.Auth() + + def authenticate_for_token(self, context, authentication, scope=None): + """ Authenticate user and issue a token. """ + try: + auth = None + auth = {'authentication': authentication} + if scope: + auth['scope'] = scope + auth_info = AuthInfo(context, auth=auth) + auth_context = {'extras': {}, 'method_names': []} + self.authenticate(context, auth_info, auth_context) + self._check_and_set_default_scoping(context, auth_info, + auth_context) + (token_id, token_data) = token_factory.create_token( + context, auth_context, auth_info) + return token_factory.render_token_data_response(token_id, + token_data) + except (exception.Unauthorized, + exception.AuthMethodNotSupported, + exception.AdditionalAuthRequired) as e: + raise e + except Exception as e: + LOG.exception(e) + raise exception.Unauthorized(e) + + def _check_and_set_default_scoping(self, context, auth_info, auth_context): + (domain_id, project_id) = auth_info.get_scope() + if domain_id or project_id: + # scope is specified + return + + # fill in default_project_id if it is available + try: + user_ref = self.identity_api.get_user( + context=context, user_id=auth_context['user_id']) + default_project_id = user_ref.get('default_project_id') + if default_project_id: + auth_info.set_scope(domain_id=None, + project_id=default_project_id) + except exception.UserNotFound as e: + LOG.exception(e) + raise exception.Unauthorized(e) + + def _build_remote_user_auth_context(self, context, auth_info, + auth_context): + username = context['REMOTE_USER'] + # FIXME(gyee): REMOTE_USER is not good enough since we are + # requiring domain_id to do user lookup now. Try to get + # the user_id from auth_info for now, assuming external auth + # has check to make sure user is the same as the one specify + # in "authentication". + if 'password' in auth_info.get_method_names(): + user_info = auth_info.get_method_data('password') + user_ref = auth_info.lookup_user(user_info['user']) + auth_context['user_id'] = user_ref['id'] + else: + msg = _('Unable to lookup user %s') % (username) + raise exception.Unauthorized(msg) + + def authenticate(self, context, auth_info, auth_context): + """ Authenticate user. """ + + # user have been authenticated externally + if 'REMOTE_USER' in context: + self._build_remote_user_auth_context(context, + auth_info, + auth_context) + return + + # need to aggregate the results in case two or more methods + # are specified + auth_response = {'methods': []} + for method_name in auth_info.get_method_names(): + method = get_auth_method(method_name) + resp = method.authenticate(context, + auth_info.get_method_data(method_name), + auth_context) + if resp: + auth_response['methods'].append(method_name) + auth_response[method_name] = resp + + if len(auth_response["methods"]) > 0: + # authentication continuation required + raise exception.AdditionalAuthRequired(auth_response) + + if 'user_id' not in auth_context: + msg = _('User not found') + raise exception.Unauthorized(msg) + + def _get_token_ref(self, context, token_id, belongs_to=None): + token_ref = self.token_api.get_token(context=context, + token_id=token_id) + if cms.is_ans1_token(token_id): + verified_token = cms.cms_verify(cms.token_to_cms(token_id), + CONF.signing.certfile, + CONF.signing.ca_certs) + token_ref = json.loads(verified_token) + if belongs_to: + assert token_ref['project']['id'] == belongs_to + return token_ref + + @controller.protected + def check_token(self, context): + try: + token_id = context.get('subject_token_id') + belongs_to = context['query_string'].get('belongsTo') + assert self._get_token_ref(context, token_id, belongs_to) + except Exception as e: + LOG.error(e) + raise exception.Unauthorized(e) + + @controller.protected + def revoke_token(self, context): + token_id = context.get('subject_token_id') + return self.token_controllers_ref.delete_token(context, token_id) + + @controller.protected + def validate_token(self, context): + token_id = context.get('subject_token_id') + self.check_token(context) + token_ref = self.token_api.get_token(context, token_id) + return token_factory.recreate_token_data(context, + token_ref.get('token_data'), + token_ref['expires'], + token_ref.get('user'), + token_ref.get('tenant')) + + @controller.protected + def revocation_list(self, context, auth=None): + return self.token_controllers_ref.revocation_list(context, auth) diff --git a/keystone/auth/core.py b/keystone/auth/core.py new file mode 100644 index 00000000..40f7d040 --- /dev/null +++ b/keystone/auth/core.py @@ -0,0 +1,83 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2013 OpenStack LLC +# +# 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. + +from keystone import exception +from keystone.common import dependency + + +@dependency.requires('identity_api') +class AuthMethodHandler(object): + """Abstract base class for an authentication plugin.""" + + def __init__(self): + pass + + def authenticate(self, context, auth_payload, auth_context): + """Authenticate user and return an authentication context. + + :param context: keystone's request context + :auth_payload: the content of the authentication for a given method + :auth_context: user authentication context, a dictionary shared + by all plugins. It contains "method_names" and "extras" + by default. "method_names" is a list and "extras" is + a dictionary. + + If successful, plugin must set "user_id" in "auth_context". + "method_name" is used to convey any additional authentication methods + in case authentication is for re-scoping. For example, + if the authentication is for re-scoping, plugin must append the + previous method names into "method_names". Also, plugin may add + any additional information into "extras". Anything in "extras" + will be conveyed in the token's "extras" field. Here's an example of + "auth_context" on successful authentication. + + {"user_id": "abc123", + "methods": ["password", "token"], + "extras": {}} + + Plugins are invoked in the order in which they are specified in the + "methods" attribute of the "authentication" request body. + For example, with the following authentication request, + + {"authentication": { + "methods": ["custom-plugin", "password", "token"], + "token": { + "id": "sdfafasdfsfasfasdfds" + }, + "custom-plugin": { + "custom-data": "sdfdfsfsfsdfsf" + }, + "password": { + "user": { + "id": "s23sfad1", + "password": "secrete" + } + } + }} + + plugins will be invoked in this order: + + 1. custom-plugin + 2. password + 3. token + + :returns: None if authentication is successful. + Authentication payload in the form of a dictionary for the + next authentication step if this is a multi step + authentication. + :raises: exception.Unauthorized for authentication failure + """ + raise exception.Unauthorized() diff --git a/keystone/auth/methods/__init__.py b/keystone/auth/methods/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/keystone/auth/methods/__init__.py diff --git a/keystone/auth/methods/password.py b/keystone/auth/methods/password.py new file mode 100644 index 00000000..46ae6cb9 --- /dev/null +++ b/keystone/auth/methods/password.py @@ -0,0 +1,114 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2013 OpenStack LLC +# +# 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. + + +from keystone.common import logging +from keystone import auth +from keystone import exception +from keystone import identity + + +METHOD_NAME = 'password' + +LOG = logging.getLogger(__name__) + + +class UserAuthInfo(object): + def __init__(self, context, auth_payload): + self.identity_api = identity.Manager() + self.context = context + self.user_id = None + self.password = None + self.user_ref = None + self._validate_and_normalize_auth_data(auth_payload) + + def _assert_domain_is_enabled(self, domain_ref): + if not domain_ref.get('enabled'): + msg = _('Domain is disabled: %s') % (domain_ref['id']) + LOG.warning(msg) + raise exception.Unauthorized(msg) + + def _assert_user_is_enabled(self, user_ref): + if not user_ref.get('enabled', True): + msg = _('User is disabled: %s') % (user_ref['id']) + LOG.warning(msg) + raise exception.Unauthorized(msg) + + def _lookup_domain(self, domain_info): + domain_id = domain_info.get('id') + domain_name = domain_info.get('name') + domain_ref = None + if not domain_id and not domain_name: + raise exception.ValidationError(attribute='id or name', + target='domain') + try: + if domain_name: + domain_ref = self.identity_api.get_domain_by_name( + context=self.context, domain_name=domain_name) + else: + domain_ref = self.identity_api.get_domain( + context=self.context, domain_id=domain_id) + except exception.DomainNotFound as e: + LOG.exception(e) + raise exception.Unauthorized(e) + self._assert_domain_is_enabled(domain_ref) + return domain_ref + + def _validate_and_normalize_auth_data(self, auth_payload): + if 'user' not in auth_payload: + raise exception.ValidationError(attribute='user', + target=METHOD_NAME) + user_info = auth_payload['user'] + user_id = user_info.get('id') + user_name = user_info.get('name') + user_ref = None + if not user_id and not user_name: + raise exception.ValidationError(attribute='id or name', + target='user') + self.password = user_info.get('password', None) + try: + if user_name: + if 'domain' not in user_info: + raise exception.ValidationError(attribute='domain', + target='user') + domain_ref = self._lookup_domain(user_info['domain']) + user_ref = self.identity_api.get_user_by_name( + context=self.context, user_name=user_name, + domain_id=domain_ref['id']) + else: + user_ref = self.identity_api.get_user( + context=self.context, user_id=user_id) + except exception.UserNotFound as e: + LOG.exception(e) + raise exception.Unauthorized(e) + self._assert_user_is_enabled(user_ref) + self.user_ref = user_ref + self.user_id = user_ref['id'] + + +class Password(auth.AuthMethodHandler): + def authenticate(self, context, auth_payload, user_context): + """ Try to authenticate against the identity backend. """ + user_info = UserAuthInfo(context, auth_payload) + + # FIXME: identity.authenticate() can use some refactoring since + # all we care is password matches + user_auth_data = self.identity_api.authenticate( + context=context, + user_id=user_info.user_id, + password=user_info.password) + if 'user_id' not in user_context: + user_context['user_id'] = user_info.user_id diff --git a/keystone/auth/methods/token.py b/keystone/auth/methods/token.py new file mode 100644 index 00000000..72006130 --- /dev/null +++ b/keystone/auth/methods/token.py @@ -0,0 +1,49 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2013 OpenStack LLC +# +# 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. + + +from keystone.common import dependency +from keystone.common import logging +from keystone import auth +from keystone import exception +from keystone import token + + +METHOD_NAME = 'token' + +LOG = logging.getLogger(__name__) + + +class Token(auth.AuthMethodHandler): + def __init__(self): + self.token_api = token.Manager() + + def authenticate(self, context, auth_payload, user_context): + try: + if 'id' not in auth_payload: + raise exception.ValidationError(attribute='id', + target=METHOD_NAME) + token_id = auth_payload['id'] + token_ref = self.token_api.get_token(context, token_id) + user_context.setdefault('user_id', + token_ref['token_data']['user']['id']) + user_context.setdefault('expires', + token_ref['expires']) + user_context['extras'].update(token_ref['token_data']['extras']) + user_context['method_names'] += token_ref['token_data']['methods'] + except AssertionError as e: + LOG.error(e) + raise exception.Unauthorized(e) diff --git a/keystone/auth/routers.py b/keystone/auth/routers.py new file mode 100644 index 00000000..29b4fdcf --- /dev/null +++ b/keystone/auth/routers.py @@ -0,0 +1,43 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2012 OpenStack LLC +# +# 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. + +from keystone.auth import controllers +from keystone.common import router + + +def append_v3_routers(mapper, routers): + auth_controller = controllers.Auth() + + mapper.connect('/auth/tokens', + controller=auth_controller, + action='authenticate_for_token', + conditions=dict(method=['POST'])) + mapper.connect('/auth/tokens', + controller=auth_controller, + action='check_token', + conditions=dict(method=['HEAD'])) + mapper.connect('/auth/tokens', + controller=auth_controller, + action='revoke_token', + conditions=dict(method=['DELETE'])) + mapper.connect('/auth/tokens', + controller=auth_controller, + action='validate_token', + conditions=dict(method=['GET'])) + mapper.connect('/auth/tokens/OS-PKI/revoked', + controller=auth_controller, + action='revocation_list', + conditions=dict(method=['GET'])) diff --git a/keystone/auth/token_factory.py b/keystone/auth/token_factory.py new file mode 100644 index 00000000..e803ae79 --- /dev/null +++ b/keystone/auth/token_factory.py @@ -0,0 +1,238 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2013 OpenStack LLC +# +# 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. + +"""Token Factory""" + +import json +import uuid +import webob + +from keystone.common import cms +from keystone.common import logging +from keystone.common import utils +from keystone import catalog +from keystone import config +from keystone import exception +from keystone import identity +from keystone import token as token_module +from keystone.openstack.common import jsonutils +from keystone.openstack.common import timeutils + + +CONF = config.CONF + +LOG = logging.getLogger(__name__) + + +class TokenDataHelper(object): + """Token data helper.""" + def __init__(self, context): + self.identity_api = identity.Manager() + self.catalog_api = catalog.Manager() + self.context = context + + def _get_filtered_domain(self, domain_id): + domain_ref = self.identity_api.get_domain(self.context, + domain_id) + return {'id': domain_ref['id'], 'name': domain_ref['name']} + + def _populate_scope(self, token_data, domain_id, project_id): + if domain_id: + token_data['domain'] = self._get_filtered_domain(domain_id) + if project_id: + project_ref = self.identity_api.get_project( + self.context, project_id) + filtered_project = { + 'id': project_ref['id'], + 'name': project_ref['name']} + filtered_project['domain'] = self._get_filtered_domain( + project_ref['domain_id']) + token_data['project'] = filtered_project + + def _get_project_roles_for_user(self, user_id, project_id): + roles = self.identity_api.get_roles_for_user_and_project( + self.context, user_id, project_id) + roles_ref = [] + for role_id in roles: + role_ref = self.identity_api.get_role(self.context, role_id) + role_ref.setdefault('project_id', project_id) + roles_ref.append(role_ref) + # user have no project roles, therefore access denied + if len(roles_ref) == 0: + msg = _('User have no access to project') + LOG.debug(msg) + raise exception.Unauthorized(msg) + return roles_ref + + def _get_roles_for_user(self, user_id, domain_id, project_id): + roles = [] + if domain_id: + # TODO(gyee): get domain roles + pass + if project_id: + roles = self._get_project_roles_for_user(user_id, project_id) + return roles + + def _populate_user(self, token_data, user_id, domain_id, project_id): + user_ref = self.identity_api.get_user(self.context, + user_id) + filtered_user = { + 'id': user_ref['id'], + 'name': user_ref['name'], + 'domain': self._get_filtered_domain(user_ref['domain_id'])} + token_data['user'] = filtered_user + + def _populate_roles(self, token_data, user_id, domain_id, project_id): + if domain_id or project_id: + roles = self._get_roles_for_user(user_id, domain_id, project_id) + # we only care about id and name + filtered_roles = [] + for role in roles: + filtered_roles.append({'id': role['id'], 'name': role['name']}) + token_data['roles'] = filtered_roles + + def _populate_service_catalog(self, token_data, user_id, + domain_id, project_id): + service_catalog = self.catalog_api.get_v3_catalog(self.context, + user_id, + project_id) + # TODO(gyee): v3 service catalog is not quite completed yet + token_data['catalog'] = service_catalog + + def _populate_token(self, token_data, expires=None): + if not expires: + expires = token_module.default_expire_time() + if not isinstance(expires, unicode): + expires = timeutils.isotime(expires) + token_data['expires'] = expires + token_data['issued_at'] = timeutils.strtime() + + def get_token_data(self, user_id, method_names, extras, + domain_id=None, project_id=None, expires=None): + token_data = {'methods': method_names, + 'extras': extras} + self._populate_scope(token_data, domain_id, project_id) + self._populate_user(token_data, user_id, domain_id, project_id) + self._populate_roles(token_data, user_id, domain_id, project_id) + self._populate_service_catalog(token_data, user_id, domain_id, + project_id) + self._populate_token(token_data, expires) + return token_data + + +def recreate_token_data(context, token_data=None, expires=None, + user_ref=None, project_ref=None): + """ Recreate token from an existing token. + + Repopulate the ephemeral data and return the new token data. + + """ + new_expires = expires + project_id = None + user_id = None + domain_id = None + methods = ['password', 'token'] + extras = {} + if token_data: + domain_id = (token_data['domain']['id'] if 'domain' in token_data + else None) + project_id = (token_data['project']['id'] if 'project' in token_data + else None) + if not new_expires: + new_expires = token_data['expires'] + user_id = token_data['user']['id'] + methods = token_data['methods'] + extras = token_data['extras'] + else: + project_id = project_ref['id'] + user_id = user_ref['id'] + token_data_helper = TokenDataHelper(context) + return token_data_helper.get_token_data(user_id, + methods, + extras, + domain_id, + project_id, + new_expires) + + +def create_token(context, auth_context, auth_info): + token_data_helper = TokenDataHelper(context) + (domain_id, project_id) = auth_info.get_scope() + method_names = list(set(auth_info.get_method_names() + + auth_context.get('method_names', []))) + token_data = token_data_helper.get_token_data(auth_context['user_id'], + method_names, + auth_context['extras'], + domain_id, + project_id, + auth_context.get('expires', + None)) + if CONF.signing.token_format == 'UUID': + token_id = uuid.uuid4().hex + elif CONF.signing.token_format == 'PKI': + token_id = cms.cms_sign_token(json.dumps(token_data), + CONF.signing.certfile, + CONF.signing.keyfile) + else: + raise exception.UnexpectedError( + 'Invalid value for token_format: %s.' + ' Allowed values are PKI or UUID.' % + CONF.signing.token_format) + token_api = token_module.Manager() + try: + expiry = token_data['expires'] + if isinstance(expiry, basestring): + expiry = timeutils.parse_isotime(expiry) + role_ids = [] + if 'project' in token_data: + # project-scoped token, fill in the v2 token data + # all we care are the role IDs + role_ids = [role['id'] for role in token_data['roles']] + metadata_ref = {'roles': role_ids} + data = dict(key=token_id, + id=token_id, + expires=expiry, + user=token_data['user'], + tenant=token_data.get('project'), + metadata=metadata_ref, + token_data=token_data) + token_api.create_token(context, token_id, data) + except Exception as e: + # an identical token may have been created already. + # if so, return the token_data as it is also identical + try: + token_api.get_token(context=context, + token_id=token_id) + except exception.TokenNotFound: + raise e + + return (token_id, token_data) + + +def render_token_data_response(token_id, token_data): + """ Render token data HTTP response. + + Stash token ID into the X-Auth-Token header. + + """ + headers = [('X-Subject-Token', token_id)] + headers.append(('Vary', 'X-Auth-Token')) + headers.append(('Content-Type', 'application/json')) + status = (200, 'OK') + body = jsonutils.dumps(token_data, cls=utils.SmarterEncoder) + return webob.Response(body=body, + status='%s %s' % status, + headerlist=headers) |