From 517de5f6649a5f5233777460fbb031164ed7be74 Mon Sep 17 00:00:00 2001 From: David Lyle Date: Wed, 10 Dec 2014 11:16:38 -0700 Subject: [PATCH] Add domain scoped token to session in multidomain In order to perform identity operations in keystone v3 when the v3 policy file is used, a domain scoped token is required. Adding the domain scoped token to the session as it remains valid until the user logs out. The domain scoped token is sizeable, so a check to make sure the session backend used is not signed cookies, as this will overflow the cookie. Additionally, errors around getting and storing the domain scoped token are logged, but doesn't block authentication, as it only blocks identity operations. A call to delete the domain token is made on logout. Support for the case of a user with a domain role but no project roles is now supported as well. That is a user can log in with only scoping to a domain. This allows domain admins to be able to configure identity without requiring a project role. Implements: blueprint domain-scoped-tokens Change-Id: I0ed1737cdd80dc143f1df94700e311351d5d3b24 --- openstack_auth/backend.py | 60 ++++++++++++++++++++++++++++-- openstack_auth/tests/data_v3.py | 25 +++++++++++++ openstack_auth/tests/tests.py | 66 +++++++++++++++++++++++---------- openstack_auth/utils.py | 22 ++++++++--- openstack_auth/views.py | 8 ++++ 5 files changed, 152 insertions(+), 29 deletions(-) diff --git a/openstack_auth/backend.py b/openstack_auth/backend.py index c630fb0a..29ec87e0 100644 --- a/openstack_auth/backend.py +++ b/openstack_auth/backend.py @@ -129,15 +129,47 @@ class KeystoneBackend(object): # Check expiry for our unscoped auth ref. self.check_auth_expiry(unscoped_auth_ref) + # domain support can require domain scoped tokens to perform + # identity operations depending on the policy files being used + # for keystone. + domain_auth = None + domain_auth_ref = None + if utils.get_keystone_version() >= 3 and 'user_domain_name' in kwargs: + try: + token = unscoped_auth_ref.auth_token + domain_auth = utils.get_token_auth_plugin( + auth_url, + token, + domain_name=kwargs['user_domain_name']) + domain_auth_ref = domain_auth.get_access(session) + except Exception: + LOG.debug('Error getting domain scoped token.', exc_info=True) + projects = plugin.list_projects(session, unscoped_auth, unscoped_auth_ref) # Attempt to scope only to enabled projects projects = [project for project in projects if project.enabled] - # Abort if there are no projects for this user - if not projects: + # Abort if there are no projects for this user and a valid domain + # token has not been obtained + # + # The valid use cases for a user login are: + # Keystone v2: user must have a role on a project and be able + # to obtain a project scoped token + # Keystone v3: 1) user can obtain a domain scoped token (user + # has a role on the domain they authenticated to), + # only, no roles on a project + # 2) user can obtain a domain scoped token and has + # a role on a project in the domain they + # authenticated to (and can obtain a project scoped + # token) + # 3) user cannot obtain a domain scoped token, but can + # obtain a project scoped token + if not projects and not domain_auth_ref: msg = _('You are not authorized for any projects.') + if utils.get_keystone_version() >= 3: + msg = _('You are not authorized for any projects or domains.') raise exceptions.KeystoneAuthException(msg) # the recent project id a user might have set in a cookie @@ -172,8 +204,15 @@ class KeystoneBackend(object): else: break else: - msg = _("Unable to authenticate to any available projects.") - raise exceptions.KeystoneAuthException(msg) + # if the user can't obtain a project scoped token, set the scoped + # token to be the domain token, if valid + if domain_auth_ref: + scoped_auth = domain_auth + scoped_auth_ref = domain_auth_ref + else: + # if no domain or project token for user, abort + msg = _("Unable to authenticate to any available projects.") + raise exceptions.KeystoneAuthException(msg) # Check expiry for our new scoped token. self.check_auth_expiry(scoped_auth_ref) @@ -189,6 +228,19 @@ class KeystoneBackend(object): if request is not None: request.session['unscoped_token'] = unscoped_token + if domain_auth_ref: + # check django session engine, if using cookies, this will not + # work, as it will overflow the cookie so don't add domain + # scoped token to the session and put error in the log + if utils.using_cookie_backed_sessions(): + LOG.error('Using signed cookies as SESSION_ENGINE with ' + 'OPENSTACK_KEYSTONE_MULTIDOMAIN_SUPPORT is ' + 'enabled. This disables the ability to ' + 'perform identity operations due to cookie size ' + 'constraints.') + else: + request.session['domain_token'] = domain_auth_ref + request.user = user timeout = getattr(settings, "SESSION_TIMEOUT", 3600) token_life = user.token.expires - datetime.datetime.now(pytz.utc) diff --git a/openstack_auth/tests/data_v3.py b/openstack_auth/tests/data_v3.py index 9018561c..73d16cc6 100644 --- a/openstack_auth/tests/data_v3.py +++ b/openstack_auth/tests/data_v3.py @@ -216,6 +216,31 @@ def generate_test_data(): body=scoped_token_dict ) + domain_token_dict = { + 'token': { + 'methods': ['password'], + 'expires_at': expiration, + 'domain': { + 'id': domain_dict['id'], + 'name': domain_dict['name'], + }, + 'user': { + 'id': user_dict['id'], + 'name': user_dict['name'], + 'domain': { + 'id': domain_dict['id'], + 'name': domain_dict['name'] + } + }, + 'roles': [role_dict], + 'catalog': [keystone_service, nova_service] + } + } + test_data.domain_scoped_access_info = access.AccessInfo.factory( + resp=auth_response, + body=domain_token_dict + ) + unscoped_token_dict = { 'token': { 'methods': ['password'], diff --git a/openstack_auth/tests/tests.py b/openstack_auth/tests/tests.py index 09014291..95928f88 100644 --- a/openstack_auth/tests/tests.py +++ b/openstack_auth/tests/tests.py @@ -485,6 +485,32 @@ class OpenStackAuthTestsV3(OpenStackAuthTestsMixin, test.TestCase): client.projects.list(user=user.id).AndRaise( keystone_exceptions.AuthorizationFailure) + def _mock_unscoped_and_domain_list_projects(self, user, projects): + client = self._mock_unscoped_client(user) + self._mock_scoped_for_domain(projects) + self._mock_unscoped_list_projects(client, user, projects) + + def _mock_scoped_for_domain(self, projects): + url = settings.OPENSTACK_KEYSTONE_URL + + plugin = self._create_token_auth( + project_id=None, + domain_name=DEFAULT_DOMAIN, + token=self.data.unscoped_access_info.auth_token, + url=url) + + plugin.get_access(mox.IsA(session.Session)).AndReturn( + self.data.domain_scoped_access_info) + + # if no projects or no enabled projects for user, but domain scoped + # token client auth gets set to domain scoped auth otherwise it's set + # to the project scoped auth and that happens in a different mock + enabled_projects = [project for project in projects if project.enabled] + if not projects or not enabled_projects: + return self.ks_client_module.Client( + session=mox.IsA(session.Session), + auth=plugin) + def _create_password_auth(self, username=None, password=None, url=None): if not username: username = self.data.user.name @@ -501,17 +527,24 @@ class OpenStackAuthTestsV3(OpenStackAuthTestsMixin, test.TestCase): user_domain_name=DEFAULT_DOMAIN, unscoped=True) - def _create_token_auth(self, project_id, token=None, url=None): + def _create_token_auth(self, project_id, token=None, url=None, + domain_name=None): if not token: token = self.data.unscoped_access_info.auth_token if not url: url = settings.OPENSTACK_KEYSTONE_URL - return auth_v3.Token(auth_url=url, - token=token, - project_id=project_id, - reauthenticate=False) + if domain_name: + return auth_v3.Token(auth_url=url, + token=token, + domain_name=domain_name, + reauthenticate=False) + else: + return auth_v3.Token(auth_url=url, + token=token, + project_id=project_id, + reauthenticate=False) def setUp(self): super(OpenStackAuthTestsV3, self).setUp() @@ -527,7 +560,6 @@ class OpenStackAuthTestsV3(OpenStackAuthTestsMixin, test.TestCase): self.data = data_v3.generate_test_data() self.ks_client_module = client_v3 - settings.OPENSTACK_API_VERSIONS['identity'] = 3 settings.OPENSTACK_KEYSTONE_URL = "http://localhost:5000/v3" @@ -542,7 +574,7 @@ class OpenStackAuthTestsV3(OpenStackAuthTestsMixin, test.TestCase): unscoped = self.data.unscoped_access_info form_data = self.get_form_data(user) - self._mock_unscoped_client_list_projects(user, projects) + self._mock_unscoped_and_domain_list_projects(user, projects) self._mock_scoped_client_for_tenant(unscoped, self.data.project_one.id) self.mox.ReplayAll() @@ -565,7 +597,7 @@ class OpenStackAuthTestsV3(OpenStackAuthTestsMixin, test.TestCase): unscoped = self.data.unscoped_access_info form_data = self.get_form_data(user) - self._mock_unscoped_client_list_projects(user, projects) + self._mock_unscoped_and_domain_list_projects(user, projects) self._mock_scoped_client_for_tenant(unscoped, self.data.project_one.id) self.mox.ReplayAll() @@ -585,7 +617,7 @@ class OpenStackAuthTestsV3(OpenStackAuthTestsMixin, test.TestCase): form_data = self.get_form_data(user) - self._mock_unscoped_client_list_projects(user, projects) + self._mock_unscoped_and_domain_list_projects(user, projects) self.mox.ReplayAll() url = reverse('login') @@ -596,15 +628,13 @@ class OpenStackAuthTestsV3(OpenStackAuthTestsMixin, test.TestCase): # POST to the page to log in. response = self.client.post(url, form_data) - self.assertTemplateUsed(response, 'auth/login.html') - self.assertContains(response, - 'You are not authorized for any projects.') + self.assertRedirects(response, settings.LOGIN_REDIRECT_URL) def test_no_projects(self): user = self.data.user - form_data = self.get_form_data(user) - self._mock_unscoped_client_list_projects(user, []) + + self._mock_unscoped_and_domain_list_projects(user, []) self.mox.ReplayAll() url = reverse('login') @@ -615,9 +645,7 @@ class OpenStackAuthTestsV3(OpenStackAuthTestsMixin, test.TestCase): # POST to the page to log in. response = self.client.post(url, form_data) - self.assertTemplateUsed(response, 'auth/login.html') - self.assertContains(response, - 'You are not authorized for any projects.') + self.assertRedirects(response, settings.LOGIN_REDIRECT_URL) def test_fail_projects(self): user = self.data.user @@ -692,7 +720,7 @@ class OpenStackAuthTestsV3(OpenStackAuthTestsMixin, test.TestCase): form_data = self.get_form_data(user) - self._mock_unscoped_client_list_projects(user, projects) + self._mock_unscoped_and_domain_list_projects(user, projects) self._mock_scoped_client_for_tenant(scoped, self.data.project_one.id) self._mock_scoped_client_for_tenant( scoped, @@ -738,7 +766,7 @@ class OpenStackAuthTestsV3(OpenStackAuthTestsMixin, test.TestCase): sc = self.data.service_catalog form_data = self.get_form_data(user) - self._mock_unscoped_client_list_projects(user, projects) + self._mock_unscoped_and_domain_list_projects(user, projects) self._mock_scoped_client_for_tenant(scoped, self.data.project_one.id) self.mox.ReplayAll() diff --git a/openstack_auth/utils.py b/openstack_auth/utils.py index 1bc212ed..6f3e8d9f 100644 --- a/openstack_auth/utils.py +++ b/openstack_auth/utils.py @@ -298,13 +298,18 @@ def fix_auth_url_version(auth_url): return auth_url -def get_token_auth_plugin(auth_url, token, project_id=None): +def get_token_auth_plugin(auth_url, token, project_id=None, domain_name=None): if get_keystone_version() >= 3: - return v3_auth.Token(auth_url=auth_url, - token=token, - project_id=project_id, - reauthenticate=False) - + if domain_name: + return v3_auth.Token(auth_url=auth_url, + token=token, + domain_name=domain_name, + reauthenticate=False) + else: + return v3_auth.Token(auth_url=auth_url, + token=token, + project_id=project_id, + reauthenticate=False) else: return v2_auth.Token(auth_url=auth_url, token=token, @@ -388,3 +393,8 @@ def get_endpoint_region(endpoint): Keystone V2 and V3. """ return endpoint.get('region_id') or endpoint.get('region') + + +def using_cookie_backed_sessions(): + engine = getattr(settings, 'SESSION_ENGINE', '') + return "signed_cookies" in engine diff --git a/openstack_auth/views.py b/openstack_auth/views.py index 52f2d482..65feccba 100644 --- a/openstack_auth/views.py +++ b/openstack_auth/views.py @@ -165,9 +165,17 @@ def logout(request, login_url=None, **kwargs): {'username': request.user.username} LOG.info(msg) endpoint = request.session.get('region_endpoint') + + # delete the project scoped token token = request.session.get('token') if token and endpoint: delete_token(endpoint=endpoint, token_id=token.id) + + # delete the domain scoped token if set + domain_token = request.session.get('domain_token') + if domain_token and endpoint: + delete_token(endpoint=endpoint, token_id=domain_token.auth_token) + """ Securely logs a user out. """ return django_auth_views.logout_then_login(request, login_url=login_url, **kwargs)