From b96a7bd0c50890c76d5fed512dacf7175889cf70 Mon Sep 17 00:00:00 2001 From: Kristy Siu Date: Thu, 31 Jul 2014 15:58:13 +0100 Subject: [PATCH] Standardizing the Federation Process Split functionality for attribute extraction and mapping layer for saml2 auth plugin. Renamed to mapped.py, leaving original file for backwards compatibility. Co-Authored-By: Guang Yee Implements: bp generic-mapping-federation Change-Id: If9a8cbf62334c796a70e939bf00e7b194e5749c8 --- keystone/auth/controllers.py | 4 + keystone/auth/plugins/mapped.py | 97 +++++++++++++++++++ keystone/auth/plugins/saml2.py | 79 ++------------- keystone/contrib/federation/controllers.py | 4 +- .../tests/config_files/test_auth_plugin.conf | 6 +- keystone/tests/test_auth_plugin.py | 66 +++++++++++++ 6 files changed, 184 insertions(+), 72 deletions(-) create mode 100644 keystone/auth/plugins/mapped.py diff --git a/keystone/auth/controllers.py b/keystone/auth/controllers.py index a46a66e2c1..03633e5295 100644 --- a/keystone/auth/controllers.py +++ b/keystone/auth/controllers.py @@ -473,6 +473,10 @@ class Auth(controller.V3Controller): # 'external' plugin; if there is both an 'external' and a # 'kerberos' plugin, it would run the check on identity twice. pass + except exception.Unauthorized: + # If external fails then continue and attempt to determine + # user identity using remaining auth methods + pass # need to aggregate the results in case two or more methods # are specified diff --git a/keystone/auth/plugins/mapped.py b/keystone/auth/plugins/mapped.py new file mode 100644 index 0000000000..f3ac23f785 --- /dev/null +++ b/keystone/auth/plugins/mapped.py @@ -0,0 +1,97 @@ +# 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 six.moves.urllib import parse + +from keystone import auth +from keystone.common import dependency +from keystone.contrib import federation +from keystone.contrib.federation import utils +from keystone.openstack.common import jsonutils + + +@dependency.requires('federation_api', 'identity_api', 'token_api') +class Mapped(auth.AuthMethodHandler): + + def authenticate(self, context, auth_payload, auth_context): + """Authenticate mapped user and return an authentication context. + + :param context: keystone's request context + :param auth_payload: the content of the authentication for a + given method + :param auth_context: user authentication context, a dictionary + shared by all plugins. + + In addition to ``user_id`` in ``auth_context``, this plugin sets + ``group_ids``, ``OS-FEDERATION:identity_provider`` and + ``OS-FEDERATION:protocol`` + """ + + if 'id' in auth_payload: + fields = self._handle_scoped_token(auth_payload) + else: + fields = self._handle_unscoped_token(context, auth_payload) + + auth_context.update(fields) + + def _handle_scoped_token(self, auth_payload): + token_ref = self.token_api.get_token(auth_payload['id']) + utils.validate_expiration(token_ref) + _federation = token_ref['user'][federation.FEDERATION] + identity_provider = _federation['identity_provider']['id'] + protocol = _federation['protocol']['id'] + group_ids = [group['id'] for group in _federation['groups']] + mapping = self.federation_api.get_mapping_from_idp_and_protocol( + identity_provider, protocol) + utils.validate_groups(group_ids, mapping['id'], self.identity_api) + return { + 'user_id': token_ref['user_id'], + 'group_ids': group_ids, + federation.IDENTITY_PROVIDER: identity_provider, + federation.PROTOCOL: protocol + } + + def _handle_unscoped_token(self, context, auth_payload): + user_id, assertion = self._extract_assertion_data(context) + if user_id: + assertion['user_id'] = user_id + identity_provider = auth_payload['identity_provider'] + protocol = auth_payload['protocol'] + + mapped_properties = self._apply_mapping_filter(identity_provider, + protocol, + assertion) + + if not user_id: + user_id = parse.quote(mapped_properties['name']) + + return { + 'user_id': user_id, + 'group_ids': mapped_properties['group_ids'], + federation.IDENTITY_PROVIDER: identity_provider, + federation.PROTOCOL: protocol + } + + def _extract_assertion_data(self, context): + assertion = dict(utils.get_assertion_params_from_env(context)) + user_id = context['environment'].get('REMOTE_USER') + return user_id, assertion + + def _apply_mapping_filter(self, identity_provider, protocol, assertion): + mapping = self.federation_api.get_mapping_from_idp_and_protocol( + identity_provider, protocol) + rules = jsonutils.loads(mapping['rules']) + rule_processor = utils.RuleProcessor(rules) + mapped_properties = rule_processor.process(assertion) + utils.validate_groups(mapped_properties['group_ids'], + mapping['id'], self.identity_api) + return mapped_properties diff --git a/keystone/auth/plugins/saml2.py b/keystone/auth/plugins/saml2.py index 473c2a3295..744f26a980 100644 --- a/keystone/auth/plugins/saml2.py +++ b/keystone/auth/plugins/saml2.py @@ -10,77 +10,18 @@ # License for the specific language governing permissions and limitations # under the License. -from six.moves.urllib import parse +from keystone.auth.plugins import mapped -from keystone import auth -from keystone.common import dependency -from keystone.contrib import federation -from keystone.contrib.federation import utils -from keystone.openstack.common import jsonutils +""" Provide an entry point to authenticate with SAML2 + +This plugin subclasses mapped.Mapped, and may be specified in keystone.conf: + + [auth] + methods = external,password,token,saml2 + saml2 = keystone.auth.plugins.mapped.Mapped +""" -@dependency.requires('federation_api', 'identity_api', 'token_api') -class Saml2(auth.AuthMethodHandler): +class Saml2(mapped.Mapped): method = 'saml2' - - def authenticate(self, context, auth_payload, auth_context): - """Authenticate federated user and return an authentication context. - - :param context: keystone's request context - :param auth_payload: the content of the authentication for a - given method - :param auth_context: user authentication context, a dictionary - shared by all plugins. - - In addition to ``user_id`` in ``auth_context``, the ``saml2`` - plugin sets ``group_ids``. When handling unscoped tokens, - ``OS-FEDERATION:identity_provider`` and ``OS-FEDERATION:protocol`` - are set as well. - - """ - - if 'id' in auth_payload: - fields = self._handle_scoped_token(auth_payload) - else: - fields = self._handle_unscoped_token(context, auth_payload) - - auth_context.update(fields) - - def _handle_scoped_token(self, auth_payload): - token_ref = self.token_api.get_token(auth_payload['id']) - utils.validate_expiration(token_ref) - _federation = token_ref['user'][federation.FEDERATION] - identity_provider = _federation['identity_provider']['id'] - protocol = _federation['protocol']['id'] - group_ids = [group['id'] for group in _federation['groups']] - mapping = self.federation_api.get_mapping_from_idp_and_protocol( - identity_provider, protocol) - utils.validate_groups(group_ids, mapping['id'], self.identity_api) - return { - 'user_id': token_ref['user_id'], - 'group_ids': group_ids, - federation.IDENTITY_PROVIDER: identity_provider, - federation.PROTOCOL: protocol - } - - def _handle_unscoped_token(self, context, auth_payload): - assertion = dict(utils.get_assertion_params_from_env(context)) - - identity_provider = auth_payload['identity_provider'] - protocol = auth_payload['protocol'] - - mapping = self.federation_api.get_mapping_from_idp_and_protocol( - identity_provider, protocol) - rules = jsonutils.loads(mapping['rules']) - rule_processor = utils.RuleProcessor(rules) - mapped_properties = rule_processor.process(assertion) - utils.validate_groups(mapped_properties['group_ids'], - mapping['id'], self.identity_api) - - return { - 'user_id': parse.quote(mapped_properties['name']), - 'group_ids': mapped_properties['group_ids'], - federation.IDENTITY_PROVIDER: identity_provider, - federation.PROTOCOL: protocol - } diff --git a/keystone/contrib/federation/controllers.py b/keystone/contrib/federation/controllers.py index af714ae1ee..4e7b82e53a 100644 --- a/keystone/contrib/federation/controllers.py +++ b/keystone/contrib/federation/controllers.py @@ -235,8 +235,8 @@ class Auth(auth_controllers.Auth): """ auth = { 'identity': { - 'methods': ['saml2'], - 'saml2': { + 'methods': [protocol], + protocol: { 'identity_provider': identity_provider, 'protocol': protocol } diff --git a/keystone/tests/config_files/test_auth_plugin.conf b/keystone/tests/config_files/test_auth_plugin.conf index 34a20b0748..4b3117805d 100644 --- a/keystone/tests/config_files/test_auth_plugin.conf +++ b/keystone/tests/config_files/test_auth_plugin.conf @@ -1,3 +1,7 @@ [auth] -methods = external,password,token,simple_challenge_response +methods = external,password,token,simple_challenge_response,saml2,openid,x509 simple_challenge_response = keystone.tests.test_auth_plugin.SimpleChallengeResponse +saml2 = keystone.auth.plugins.mapped.Mapped +openid = keystone.auth.plugins.mapped.Mapped +x509 = keystone.auth.plugins.mapped.Mapped + diff --git a/keystone/tests/test_auth_plugin.py b/keystone/tests/test_auth_plugin.py index c040ee863f..7b3a22f653 100644 --- a/keystone/tests/test_auth_plugin.py +++ b/keystone/tests/test_auth_plugin.py @@ -14,6 +14,8 @@ import uuid +import mock + from keystone import auth from keystone import exception from keystone import tests @@ -152,3 +154,67 @@ class TestInvalidAuthMethodRegistration(tests.TestCase): methods=['keystone.tests.test_auth_plugin.NoMethodAuthPlugin']) self.clear_auth_plugin_registry() self.assertRaises(ValueError, auth.controllers.load_auth_methods) + + +class TestMapped(tests.TestCase): + def setUp(self): + super(TestMapped, self).setUp() + self.load_backends() + + self.api = auth.controllers.Auth() + + def config_files(self): + config_files = super(TestMapped, self).config_files() + config_files.append(tests.dirs.tests_conf('test_auth_plugin.conf')) + return config_files + + def config_overrides(self): + # don't override configs so we can use test_auth_plugin.conf only + pass + + def _test_mapped_invocation_with_method_name(self, method_name): + with mock.patch.object(auth.plugins.mapped.Mapped, + 'authenticate', + return_value=None) as authenticate: + context = {'environment': {}} + auth_data = { + 'identity': { + 'methods': [method_name], + method_name: {'protocol': method_name}, + } + } + auth_info = auth.controllers.AuthInfo.create(context, auth_data) + auth_context = {'extras': {}, + 'method_names': [], + 'user_id': uuid.uuid4().hex} + self.api.authenticate(context, auth_info, auth_context) + # make sure Mapped plugin got invoked with the correct payload + ((context, auth_payload, auth_context), + kwargs) = authenticate.call_args + self.assertEqual(method_name, auth_payload['protocol']) + + def test_mapped_with_remote_user(self): + with mock.patch.object(auth.plugins.mapped.Mapped, + 'authenticate', + return_value=None) as authenticate: + # external plugin should fail and pass to mapped plugin + method_name = 'saml2' + auth_data = {'methods': [method_name]} + # put the method name in the payload so its easier to correlate + # method name with payload + auth_data[method_name] = {'protocol': method_name} + auth_data = {'identity': auth_data} + auth_info = auth.controllers.AuthInfo.create(None, auth_data) + auth_context = {'extras': {}, + 'method_names': [], + 'user_id': uuid.uuid4().hex} + environment = {'environment': {'REMOTE_USER': 'foo@idp.com'}} + self.api.authenticate(environment, auth_info, auth_context) + # make sure Mapped plugin got invoked with the correct payload + ((context, auth_payload, auth_context), + kwargs) = authenticate.call_args + self.assertEqual(auth_payload['protocol'], method_name) + + def test_supporting_multiple_methods(self): + for method_name in ['saml2', 'openid', 'x509']: + self._test_mapped_invocation_with_method_name(method_name)