diff options
author | Thai Tran <tqtran@us.ibm.com> | 2015-03-16 12:37:47 -0700 |
---|---|---|
committer | lin-hua-cheng <os.lcheng@gmail.com> | 2015-03-31 11:10:21 -0700 |
commit | 302f422568a32b513ffbb3089ba799a4416df108 (patch) | |
tree | 9beb70f4db790c0be8910430b126a101a1c00b27 | |
parent | 4e8b06452216fe9e74d589f063290754546ef8b9 (diff) | |
download | django_openstack_auth-302f422568a32b513ffbb3089ba799a4416df108.tar.gz |
Add authentication using openID and SAML
To enable websso, make sure you have your environment configured.
Then add following to Horizon settings:
WEBSSO_ENABLED=True
Also make sure your KEYSTONE is version 3+
Depends on:
https://review.openstack.org/#/c/136177/
https://review.openstack.org/#/c/151842/
Co-Authored-By: Thai Tran <tqtran@us.ibm.com>
Co-Authored-By: Jose Castro Leon <jose.castro.leon@cern.ch>
Co-Authored-By: Marek Denis <marek.denis@cern.ch>
Co-Authored-By: Lin Hua Cheng <os.lcheng@gmail.com>
implements bp federated-identity
Change-Id: Ief74bece750ffe633d4323238cad89bad61496ed
-rw-r--r-- | openstack_auth/backend.py | 9 | ||||
-rw-r--r-- | openstack_auth/forms.py | 18 | ||||
-rw-r--r-- | openstack_auth/plugin/base.py | 5 | ||||
-rw-r--r-- | openstack_auth/tests/data_v3.py | 68 | ||||
-rw-r--r-- | openstack_auth/tests/tests.py | 83 | ||||
-rw-r--r-- | openstack_auth/tests/urls.py | 1 | ||||
-rw-r--r-- | openstack_auth/urls.py | 6 | ||||
-rw-r--r-- | openstack_auth/user.py | 33 | ||||
-rw-r--r-- | openstack_auth/utils.py | 12 | ||||
-rw-r--r-- | openstack_auth/views.py | 50 |
10 files changed, 273 insertions, 12 deletions
diff --git a/openstack_auth/backend.py b/openstack_auth/backend.py index f2d08fa..362855c 100644 --- a/openstack_auth/backend.py +++ b/openstack_auth/backend.py @@ -95,10 +95,12 @@ class KeystoneBackend(object): if unscoped_auth: break else: + msg = _('No authentication backend could be determined to ' + 'handle the provided credentials.') LOG.warn('No authentication backend could be determined to ' 'handle the provided credentials. This is likely a ' 'configuration error that should be addressed.') - return None + raise exceptions.KeystoneAuthException(msg) session = utils.get_session() keystone_client_class = utils.get_keystone_client().Client @@ -174,13 +176,14 @@ class KeystoneBackend(object): interface = getattr(settings, 'OPENSTACK_ENDPOINT_TYPE', 'public') # If we made it here we succeeded. Create our User! + unscoped_token = unscoped_auth_ref.auth_token user = auth_user.create_user_from_token( request, - auth_user.Token(scoped_auth_ref), + auth_user.Token(scoped_auth_ref, unscoped_token=unscoped_token), scoped_auth_ref.service_catalog.url_for(endpoint_type=interface)) if request is not None: - request.session['unscoped_token'] = unscoped_auth_ref.auth_token + request.session['unscoped_token'] = unscoped_token request.user = user scoped_client = keystone_client_class(session=session, auth=scoped_auth) diff --git a/openstack_auth/forms.py b/openstack_auth/forms.py index f5afaba..be3fdc7 100644 --- a/openstack_auth/forms.py +++ b/openstack_auth/forms.py @@ -21,6 +21,7 @@ from django.utils.translation import ugettext_lazy as _ from django.views.decorators.debug import sensitive_variables # noqa from openstack_auth import exceptions +from openstack_auth import utils LOG = logging.getLogger(__name__) @@ -72,6 +73,23 @@ class Login(django_auth_forms.AuthenticationForm): self.fields['region'].initial = self.request.COOKIES.get( 'login_region') + # if websso is enabled and keystone version supported + # prepend the websso_choices select input to the form + if utils.is_websso_enabled(): + initial = getattr(settings, 'WEBSSO_INITIAL_CHOICE', 'credentials') + choicefield = forms.ChoiceField( + label=_("Authenticate using"), + choices=getattr(settings, 'WEBSSO_CHOICES', ()), + required=False, + initial=initial) + self.fields.insert(0, 'auth_type', choicefield) + + # websso is enabled, but keystone version is not supported + elif getattr(settings, 'WEBSSO_ENABLED', False): + msg = ("Websso is enabled but horizon is not configured to work " + + "with keystone version 3 or above.") + LOG.warning(msg) + @staticmethod def get_region_choices(): default_region = (settings.OPENSTACK_KEYSTONE_URL, "Default Region") diff --git a/openstack_auth/plugin/base.py b/openstack_auth/plugin/base.py index bd0b200..a2fc755 100644 --- a/openstack_auth/plugin/base.py +++ b/openstack_auth/plugin/base.py @@ -81,7 +81,10 @@ class BasePlugin(object): try: if self.keystone_version >= 3: client = v3_client.Client(session=session, auth=auth_plugin) - return client.projects.list(user=auth_ref.user_id) + if auth_ref.is_federated: + return client.federation.projects.list() + else: + return client.projects.list(user=auth_ref.user_id) else: client = v2_client.Client(session=session, auth=auth_plugin) diff --git a/openstack_auth/tests/data_v3.py b/openstack_auth/tests/data_v3.py index be95b15..9018561 100644 --- a/openstack_auth/tests/data_v3.py +++ b/openstack_auth/tests/data_v3.py @@ -244,4 +244,72 @@ def generate_test_data(): 'catalog': [keystone_service, nova_service], }, token=auth_token) + # federated user + federated_scoped_token_dict = { + 'token': { + 'methods': ['password'], + 'expires_at': expiration, + 'project': { + 'id': project_dict_1['id'], + 'name': project_dict_1['name'], + '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'] + }, + 'OS-FEDERATION': { + 'identity_provider': 'ACME', + 'protocol': 'OIDC', + 'groups': [ + {'id': uuid.uuid4().hex}, + {'id': uuid.uuid4().hex} + ] + } + }, + 'roles': [role_dict], + 'catalog': [keystone_service, nova_service] + } + } + + test_data.federated_scoped_access_info = access.AccessInfo.factory( + resp=auth_response, + body=federated_scoped_token_dict + ) + + federated_unscoped_token_dict = { + 'token': { + 'methods': ['password'], + 'expires_at': expiration, + 'user': { + 'id': user_dict['id'], + 'name': user_dict['name'], + 'domain': { + 'id': domain_dict['id'], + 'name': domain_dict['name'] + }, + 'OS-FEDERATION': { + 'identity_provider': 'ACME', + 'protocol': 'OIDC', + 'groups': [ + {'id': uuid.uuid4().hex}, + {'id': uuid.uuid4().hex} + ] + } + }, + 'catalog': [keystone_service] + } + } + + test_data.federated_unscoped_access_info = access.AccessInfo.factory( + resp=auth_response, + body=federated_unscoped_token_dict + ) + return test_data diff --git a/openstack_auth/tests/tests.py b/openstack_auth/tests/tests.py index dbfa345..76e6519 100644 --- a/openstack_auth/tests/tests.py +++ b/openstack_auth/tests/tests.py @@ -791,6 +791,89 @@ class OpenStackAuthTestsV3(OpenStackAuthTestsMixin, test.TestCase): self.assertIsNone(utils._PROJECT_CACHE.get(unscoped.auth_token)) +class OpenStackAuthTestsWebSSO(OpenStackAuthTestsMixin, test.TestCase): + + def _create_token_auth(self, project_id=None, token=None, url=None): + if not token: + token = self.data.federated_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) + + def _mock_unscoped_client(self, unscoped): + plugin = self._create_token_auth( + None, + token=unscoped.auth_token, + url=settings.OPENSTACK_KEYSTONE_URL) + plugin.get_access(mox.IsA(session.Session)).AndReturn(unscoped) + + return self.ks_client_module.Client(session=mox.IsA(session.Session), + auth=plugin) + + def _mock_unscoped_federated_list_projects(self, client, projects): + client.federation = self.mox.CreateMockAnything() + client.federation.projects = self.mox.CreateMockAnything() + client.federation.projects.list().AndReturn(projects) + + def _mock_unscoped_client_list_projects(self, unscoped, projects): + client = self._mock_unscoped_client(unscoped) + self._mock_unscoped_federated_list_projects(client, projects) + + def setUp(self): + super(OpenStackAuthTestsWebSSO, self).setUp() + + self.mox = mox.Mox() + self.addCleanup(self.mox.VerifyAll) + self.addCleanup(self.mox.UnsetStubs) + + 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' + settings.WEBSSO_ENABLED = True + settings.WEBSSO_CHOICES = ( + ('credentials', 'Keystone Credentials'), + ('oidc', 'OpenID Connect'), + ('saml2', 'Security Assertion Markup Language') + ) + + self.mox.StubOutClassWithMocks(token_endpoint, 'Token') + self.mox.StubOutClassWithMocks(auth_v3, 'Token') + self.mox.StubOutClassWithMocks(auth_v3, 'Password') + self.mox.StubOutClassWithMocks(client_v3, 'Client') + + def test_login_form(self): + url = reverse('login') + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'credentials') + self.assertContains(response, 'oidc') + self.assertContains(response, 'saml2') + + def test_web_sso_login(self): + projects = [self.data.project_one, self.data.project_two] + unscoped = self.data.federated_unscoped_access_info + token = unscoped.auth_token + + form_data = {'token': token} + self._mock_unscoped_client_list_projects(unscoped, projects) + self._mock_scoped_client_for_tenant(unscoped, self.data.project_one.id) + + self.mox.ReplayAll() + + url = reverse('websso') + + # POST to the page to log in. + response = self.client.post(url, form_data) + self.assertRedirects(response, settings.LOGIN_REDIRECT_URL) + load_tests = load_tests_apply_scenarios diff --git a/openstack_auth/tests/urls.py b/openstack_auth/tests/urls.py index be27296..4c9045d 100644 --- a/openstack_auth/tests/urls.py +++ b/openstack_auth/tests/urls.py @@ -25,5 +25,6 @@ utils.patch_middleware_get_user() urlpatterns = patterns( '', url(r"", include('openstack_auth.urls')), + url(r"^websso/$", "openstack_auth.views.websso", name='websso'), url(r"^$", generic.TemplateView.as_view(template_name="auth/blank.html")) ) diff --git a/openstack_auth/urls.py b/openstack_auth/urls.py index d661805..db11f47 100644 --- a/openstack_auth/urls.py +++ b/openstack_auth/urls.py @@ -27,3 +27,9 @@ urlpatterns = patterns( url(r'^switch_services_region/(?P<region_name>[^/]+)/$', 'switch_region', name='switch_services_region') ) + +if utils.is_websso_enabled(): + urlpatterns += patterns( + 'openstack_auth.views', + url(r"^websso/$", "websso", name='websso') + ) diff --git a/openstack_auth/user.py b/openstack_auth/user.py index 947f725..57c6d67 100644 --- a/openstack_auth/user.py +++ b/openstack_auth/user.py @@ -53,7 +53,9 @@ def create_user_from_token(request, token, endpoint, services_region=None): service_catalog=token.serviceCatalog, roles=token.roles, endpoint=endpoint, - services_region=svc_region) + services_region=svc_region, + is_federated=token.is_federated, + unscoped_token=token.unscoped_token) class Token(object): @@ -65,7 +67,7 @@ class Token(object): Added for maintaining backward compatibility with horizon that expects Token object in the user object. """ - def __init__(self, auth_ref): + def __init__(self, auth_ref, unscoped_token=None): # User-related attributes user = {} user['id'] = auth_ref.user_id @@ -76,12 +78,16 @@ class Token(object): # Token-related attributes self.id = auth_ref.auth_token + self.unscoped_token = unscoped_token if len(self.id) > 64: algorithm = getattr(settings, 'OPENSTACK_TOKEN_HASH_ALGORITHM', 'md5') hasher = hashlib.new(algorithm) hasher.update(self.id) self.id = hasher.hexdigest() + # If the scoped_token is long, then unscoped_token must be too. + hasher.update(self.unscoped_token) + self.unscoped_token = hasher.hexdigest() self.expires = auth_ref.expires # Project-related attributes @@ -97,6 +103,9 @@ class Token(object): domain['name'] = auth_ref.domain_name self.domain = domain + # Federation-related attributes + self.is_federated = auth_ref.is_federated + if auth_ref.version == 'v2.0': self.roles = auth_ref['user'].get('roles', []) else: @@ -167,13 +176,22 @@ class User(models.AnonymousUser): The id of the Keystone domain scoped for the current user/token. + .. attribute:: is_federated + + Whether user is federated Keystone user. (Boolean) + + .. attribute:: unscoped_token + + Unscoped Keystone token. + """ def __init__(self, id=None, token=None, user=None, tenant_id=None, service_catalog=None, tenant_name=None, roles=None, authorized_tenants=None, endpoint=None, enabled=False, services_region=None, user_domain_id=None, user_domain_name=None, domain_id=None, domain_name=None, - project_id=None, project_name=None): + project_id=None, project_name=None, + is_federated=False, unscoped_token=None): self.id = id self.pk = id self.token = token @@ -193,6 +211,11 @@ class User(models.AnonymousUser): self.endpoint = endpoint self.enabled = enabled self._authorized_tenants = authorized_tenants + self.is_federated = is_federated + + # Unscoped token is used for listing user's project that works + # for both federated and keystone user. + self.unscoped_token = unscoped_token # List of variables to be deprecated. self.tenant_id = self.project_id @@ -277,12 +300,12 @@ class User(models.AnonymousUser): """Returns a memoized list of tenants this user may access.""" if self.is_authenticated() and self._authorized_tenants is None: endpoint = self.endpoint - token = self.token try: self._authorized_tenants = utils.get_project_list( user_id=self.id, auth_url=endpoint, - token=token.id) + token=self.unscoped_token, + is_federated=self.is_federated) except (keystone_exceptions.ClientException, keystone_exceptions.AuthorizationFailure): LOG.exception('Unable to retrieve project list.') diff --git a/openstack_auth/utils.py b/openstack_auth/utils.py index 034d5c8..198c8a0 100644 --- a/openstack_auth/utils.py +++ b/openstack_auth/utils.py @@ -176,6 +176,13 @@ def get_keystone_client(): return client_v3 +def is_websso_enabled(): + """Websso is supported in Keystone version 3.""" + websso_enabled = getattr(settings, 'WEBSSO_ENABLED', False) + keystonev3_plus = (get_keystone_version() >= 3) + return websso_enabled and keystonev3_plus + + def has_in_url_path(url, sub): """Test if the `sub` string is in the `url` path.""" scheme, netloc, path, query, fragment = urlparse.urlsplit(url) @@ -214,7 +221,7 @@ def fix_auth_url_version(auth_url): return auth_url -def get_token_auth_plugin(auth_url, token, project_id): +def get_token_auth_plugin(auth_url, token, project_id=None): if get_keystone_version() >= 3: return v3_auth.Token(auth_url=auth_url, token=token, @@ -230,6 +237,7 @@ def get_token_auth_plugin(auth_url, token, project_id): @memoize_by_keyword_arg(_PROJECT_CACHE, ('token', )) def get_project_list(*args, **kwargs): + is_federated = kwargs.get('is_federated', False) sess = kwargs.get('session') or get_session() auth_url = fix_auth_url_version(kwargs['auth_url']) auth = token_endpoint.Token(auth_url, kwargs['token']) @@ -237,6 +245,8 @@ def get_project_list(*args, **kwargs): if get_keystone_version() < 3: projects = client.tenants.list() + elif is_federated: + projects = client.federation.projects.list() else: projects = client.projects.list(user=kwargs.get('user_id')) diff --git a/openstack_auth/views.py b/openstack_auth/views.py index 314afd7..8d5a0f5 100644 --- a/openstack_auth/views.py +++ b/openstack_auth/views.py @@ -11,6 +11,7 @@ # See the License for the specific language governing permissions and # limitations under the License. import logging +import re import time import django @@ -18,15 +19,18 @@ from django.conf import settings from django.contrib import auth from django.contrib.auth.decorators import login_required # noqa from django.contrib.auth import views as django_auth_views +from django import http as django_http from django import shortcuts from django.utils import functional from django.utils import http from django.views.decorators.cache import never_cache # noqa +from django.views.decorators.csrf import csrf_exempt # noqa from django.views.decorators.csrf import csrf_protect # noqa from django.views.decorators.debug import sensitive_post_parameters # noqa from keystoneclient.auth import token_endpoint from keystoneclient import exceptions as keystone_exceptions +from openstack_auth import exceptions from openstack_auth import forms # This is historic and is added back in to not break older versions of # Horizon, fix to Horizon to remove this requirement was committed in @@ -49,6 +53,18 @@ LOG = logging.getLogger(__name__) @never_cache def login(request, template_name=None, extra_context=None, **kwargs): """Logs a user in using the :class:`~openstack_auth.forms.Login` form.""" + + # If the user enabled websso and selects default protocol + # from the dropdown, We need to redirect user to the websso url + if request.method == 'POST': + protocol = request.POST.get('auth_type', 'credentials') + if utils.is_websso_enabled() and protocol != 'credentials': + region = request.POST.get('region') + origin = request.build_absolute_uri('/auth/websso/') + url = ('%s/auth/OS-FEDERATION/websso/%s?origin=%s' % + (region, protocol, origin)) + return shortcuts.redirect(url) + if not request.is_ajax(): # If the user is already authenticated, redirect them to the # dashboard straight away, unless the 'next' parameter is set as it @@ -112,6 +128,30 @@ def login(request, template_name=None, extra_context=None, **kwargs): return res +@sensitive_post_parameters() +@csrf_exempt +@never_cache +def websso(request): + """Logs a user in using a token from Keystone's POST.""" + referer = request.META.get('HTTP_REFERER', settings.OPENSTACK_KEYSTONE_URL) + auth_url = re.sub(r'/auth.*', '', referer) + token = request.POST.get('token') + try: + request.user = auth.authenticate(request=request, auth_url=auth_url, + token=token) + except exceptions.KeystoneAuthException as exc: + msg = 'Login failed: %s' % unicode(exc) + res = django_http.HttpResponseRedirect(settings.LOGIN_URL) + res.set_cookie('logout_reason', msg, max_age=10) + return res + + auth_user.set_session_from_user(request, request.user) + auth.login(request, request.user) + if request.session.test_cookie_worked(): + request.session.delete_test_cookie() + return django_http.HttpResponseRedirect(settings.LOGIN_REDIRECT_URL) + + def logout(request, login_url=None, **kwargs): """Logs out the user if he is logged in. Then redirects to the log-in page. @@ -165,8 +205,12 @@ def switch(request, tenant_id, redirect_field_name=auth.REDIRECT_FIELD_NAME): endpoint = utils.fix_auth_url_version(request.user.endpoint) session = utils.get_session() + # Keystone can be configured to prevent exchanging a scoped token for + # another token. Always use the unscoped token for requesting a + # scoped token. + unscoped_token = request.user.unscoped_token auth = utils.get_token_auth_plugin(auth_url=endpoint, - token=request.user.token.id, + token=unscoped_token, project_id=tenant_id) try: @@ -193,7 +237,9 @@ def switch(request, tenant_id, redirect_field_name=auth.REDIRECT_FIELD_NAME): if old_token and old_endpoint and old_token.id != auth_ref.auth_token: delete_token(endpoint=old_endpoint, token_id=old_token.id) user = auth_user.create_user_from_token( - request, auth_user.Token(auth_ref), endpoint) + request, + auth_user.Token(auth_ref, unscoped_token=unscoped_token), + endpoint) auth_user.set_session_from_user(request, user) response = shortcuts.redirect(redirect_to) utils.set_response_cookie(response, 'recent_project', |