From d7502acc5b3f29ab1bfe81117ecb1c4399da79d7 Mon Sep 17 00:00:00 2001 From: zubair-arbi Date: Fri, 15 Dec 2017 15:09:47 +0500 Subject: [PATCH] ENT-768 display friendly error message for saml rejection --- .../third_party_auth/tests/testutil.py | 3 +- .../student_account/test/test_views.py | 136 ++++++++++++++++++ lms/djangoapps/student_account/views.py | 3 + openedx/features/enterprise_support/utils.py | 41 ++++++ 4 files changed, 182 insertions(+), 1 deletion(-) diff --git a/common/djangoapps/third_party_auth/tests/testutil.py b/common/djangoapps/third_party_auth/tests/testutil.py index 1befeb0d29..8479aa6346 100644 --- a/common/djangoapps/third_party_auth/tests/testutil.py +++ b/common/djangoapps/third_party_auth/tests/testutil.py @@ -279,7 +279,8 @@ def simulate_running_pipeline(pipeline_target, backend, email=None, fullname=Non pipeline_data = { "backend": backend, "kwargs": { - "details": kwargs + "details": kwargs, + "response": kwargs.get("response", {}) } } diff --git a/lms/djangoapps/student_account/test/test_views.py b/lms/djangoapps/student_account/test/test_views.py index e73abb227b..f88a362953 100644 --- a/lms/djangoapps/student_account/test/test_views.py +++ b/lms/djangoapps/student_account/test/test_views.py @@ -12,13 +12,17 @@ import pytest from django.conf import settings from django.contrib import messages from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser from django.contrib.messages.middleware import MessageMiddleware +from django.contrib.sessions.middleware import SessionMiddleware from django.core import mail from django.core.files.uploadedfile import SimpleUploadedFile from django.core.urlresolvers import reverse from django.http import HttpRequest from django.test import TestCase +from django.test.client import RequestFactory from django.test.utils import override_settings +from django.utils.translation import ugettext as _ from edx_oauth2_provider.tests.factories import AccessTokenFactory, ClientFactory, RefreshTokenFactory from edx_rest_api_client import exceptions from http.cookies import SimpleCookie @@ -33,6 +37,7 @@ from course_modes.models import CourseMode from lms.djangoapps.commerce.models import CommerceConfiguration from lms.djangoapps.commerce.tests import factories from lms.djangoapps.commerce.tests.mocks import mock_get_orders +from lms.djangoapps.student_account.views import login_and_registration_form from openedx.core.djangoapps.oauth_dispatch.tests import factories as dot_factories from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory @@ -40,6 +45,7 @@ from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme_context from openedx.core.djangoapps.user_api.accounts.api import activate_account, create_account from openedx.core.djangolib.js_utils import dump_js_escaped_json +from openedx.core.djangolib.markup import HTML, Text from openedx.core.djangolib.testing.utils import CacheIsolationTestCase from student.tests.factories import UserFactory from student_account.views import account_settings_context, get_user_orders @@ -452,6 +458,111 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi expected_ec ) + def _configure_testshib_provider(self, provider_name, idp_slug): + """ + Enable and configure the TestShib SAML IdP as a third_party_auth provider. + """ + kwargs = {} + kwargs.setdefault('name', provider_name) + kwargs.setdefault('enabled', True) + kwargs.setdefault('visible', True) + kwargs.setdefault('idp_slug', idp_slug) + kwargs.setdefault('entity_id', 'https://idp.testshib.org/idp/shibboleth') + kwargs.setdefault('metadata_source', 'https://mock.testshib.org/metadata/testshib-providers.xml') + kwargs.setdefault('icon_class', 'fa-university') + kwargs.setdefault('attr_email', 'dummy-email-attr') + kwargs.setdefault('max_session_length', None) + self.configure_saml_provider(**kwargs) + + @mock.patch('django.conf.settings.MESSAGE_STORAGE', 'django.contrib.messages.storage.cookie.CookieStorage') + @mock.patch('lms.djangoapps.student_account.views.enterprise_customer_for_request') + @ddt.data( + ( + 'signin_user', + 'tpa-saml', + 'TestShib', + { + 'name': 'FakeName', + 'logo': 'https://host.com/logo.jpg', + 'welcome_msg': 'No message' + } + ) + ) + @ddt.unpack + def test_saml_auth_with_error( + self, + url_name, + current_backend, + current_provider, + expected_enterprise_customer_mock_attrs, + enterprise_customer_mock, + ): + params = [] + request = RequestFactory().get(reverse(url_name), params, HTTP_ACCEPT='text/html') + SessionMiddleware().process_request(request) + request.user = AnonymousUser() + + self.enable_saml() + dummy_idp = 'testshib' + self._configure_testshib_provider(current_provider, dummy_idp) + expected_ec = mock.MagicMock( + branding_configuration=mock.MagicMock( + logo=mock.MagicMock( + url=expected_enterprise_customer_mock_attrs['logo'] + ), + welcome_message=expected_enterprise_customer_mock_attrs['welcome_msg'] + ) + ) + expected_ec.name = expected_enterprise_customer_mock_attrs['name'] + enterprise_customer_data = { + 'uuid': '72416e52-8c77-4860-9584-15e5b06220fb', + 'name': 'Dummy Enterprise', + 'identity_provider': dummy_idp, + } + enterprise_customer_mock.return_value = enterprise_customer_data + dummy_error_message = 'Authentication failed: SAML login failed ' \ + '["invalid_response"] [SAML Response must contain 1 assertion]' + + # Add error message for error in auth pipeline + MessageMiddleware().process_request(request) + messages.error(request, dummy_error_message, extra_tags='social-auth') + + # Simulate a running pipeline + pipeline_response = { + 'response': { + 'idp_name': dummy_idp + } + } + pipeline_target = 'student_account.views.third_party_auth.pipeline' + with simulate_running_pipeline(pipeline_target, current_backend, **pipeline_response): + with mock.patch('edxmako.request_context.get_current_request', return_value=request): + response = login_and_registration_form(request) + + expected_error_message = Text(_( + u'We are sorry, you are not authorized to access {platform_name} via this channel. ' + u'Please contact your {enterprise} administrator in order to access {platform_name} ' + u'or contact {edx_support_link}.{line_break}' + u'{line_break}' + u'Error Details:{line_break}{error_message}') + ).format( + platform_name=settings.PLATFORM_NAME, + enterprise=enterprise_customer_data['name'], + error_message=dummy_error_message, + edx_support_link=HTML( + '{support_url_name}' + ).format( + edx_support_url=settings.SUPPORT_SITE_LINK, + support_url_name=_('edX Support'), + ), + line_break=HTML('
') + ) + self._assert_saml_auth_data_with_error( + response, + current_backend, + current_provider, + expected_error_message + ) + def test_hinted_login(self): params = [("next", "/courses/something/?tpa_hint=oa2-google-oauth2")] response = self.client.get(reverse('signin_user'), params, HTTP_ACCEPT="text/html") @@ -650,7 +761,32 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi expected_data = '"third_party_auth": {auth_info}'.format( auth_info=auth_info ) + self.assertContains(response, expected_data) + def _assert_saml_auth_data_with_error( + self, response, current_backend, current_provider, expected_error_message + ): + """ + Verify that third party auth info is rendered correctly in a DOM data attribute. + """ + finish_auth_url = None + if current_backend: + finish_auth_url = reverse('social:complete', kwargs={'backend': current_backend}) + '?' + + auth_info = { + 'currentProvider': current_provider, + 'providers': [], + 'secondaryProviders': [], + 'finishAuthUrl': finish_auth_url, + 'errorMessage': expected_error_message, + 'registerFormSubmitButtonText': 'Create Account', + 'syncLearnerProfileData': False, + } + auth_info = dump_js_escaped_json(auth_info) + + expected_data = '"third_party_auth": {auth_info}'.format( + auth_info=auth_info + ) self.assertContains(response, expected_data) def _third_party_login_url(self, backend_name, auth_entry, login_params): diff --git a/lms/djangoapps/student_account/views.py b/lms/djangoapps/student_account/views.py index a03c8ec834..3a6fcc62b7 100644 --- a/lms/djangoapps/student_account/views.py +++ b/lms/djangoapps/student_account/views.py @@ -41,6 +41,7 @@ from openedx.core.lib.edx_api_utils import get_edx_api_data from openedx.core.lib.time_zone_utils import TIME_ZONE_CHOICES from openedx.features.enterprise_support.api import enterprise_customer_for_request, get_enterprise_learner_data from student.cookies import set_experiments_is_enterprise_cookie +from openedx.features.enterprise_support.utils import update_third_party_auth_context_for_enterprise from student.helpers import destroy_oauth_tokens, get_next_url_for_login_page from student.models import UserProfile from student.views import register_user as old_register_view @@ -388,6 +389,8 @@ def _third_party_auth_context(request, redirect_to, tpa_hint=None): context['errorMessage'] = _(unicode(msg)) # pylint: disable=translation-of-non-string break + context = update_third_party_auth_context_for_enterprise(context, enterprise_customer) + return context diff --git a/openedx/features/enterprise_support/utils.py b/openedx/features/enterprise_support/utils.py index 614f027d9a..8878f2da04 100644 --- a/openedx/features/enterprise_support/utils.py +++ b/openedx/features/enterprise_support/utils.py @@ -3,6 +3,10 @@ from __future__ import unicode_literals import hashlib import six +from django.conf import settings +from django.utils.translation import ugettext as _ +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.djangolib.markup import HTML, Text def get_cache_key(**kwargs): @@ -27,3 +31,40 @@ def get_cache_key(**kwargs): key = '__'.join(['{}:{}'.format(item, value) for item, value in six.iteritems(kwargs)]) return hashlib.md5(key).hexdigest() + + +def update_third_party_auth_context_for_enterprise(context, enterprise_customer=None): + """ + Return updated context of third party auth with modified for enterprise. + + Arguments: + context (dict): Context for third party auth providers and auth pipeline. + enterprise_customer (dict): data for enterprise customer + + Returns: + context (dict): Updated context of third party auth with modified + `errorMessage`. + """ + if enterprise_customer and context['errorMessage']: + context['errorMessage'] = Text(_( + u'We are sorry, you are not authorized to access {platform_name} via this channel. ' + u'Please contact your {enterprise} administrator in order to access {platform_name} ' + u'or contact {edx_support_link}.{line_break}' + u'{line_break}' + u'Error Details:{line_break}{error_message}') + ).format( + platform_name=configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), + enterprise=enterprise_customer['name'], + error_message=context['errorMessage'], + edx_support_link=HTML( + '{support_url_name}' + ).format( + edx_support_url=configuration_helpers.get_value( + 'SUPPORT_SITE_LINK', settings.SUPPORT_SITE_LINK + ), + support_url_name=_('edX Support'), + ), + line_break=HTML('
') + ) + + return context