From c260f72c2e50f85dbfef03a3ecd42a26bc3a6ea4 Mon Sep 17 00:00:00 2001 From: Zainab Amir Date: Thu, 18 Feb 2021 17:24:44 +0500 Subject: [PATCH] VAN-311: Add multiple enterprise support for Authn MFE (#26526) --- common/djangoapps/student/helpers.py | 22 ++-- .../courseware_api/tests/test_views.py | 4 +- .../core/djangoapps/user_authn/views/login.py | 52 ++++++++- .../djangoapps/user_authn/views/register.py | 5 +- .../user_authn/views/tests/test_login.py | 107 ++++++++++++++++++ .../core/djangoapps/user_authn/views/utils.py | 4 + openedx/features/enterprise_support/api.py | 31 ++++- .../enterprise_support/tests/test_api.py | 12 ++ 8 files changed, 219 insertions(+), 18 deletions(-) diff --git a/common/djangoapps/student/helpers.py b/common/djangoapps/student/helpers.py index 59be8c57ec..a610ab6bc9 100644 --- a/common/djangoapps/student/helpers.py +++ b/common/djangoapps/student/helpers.py @@ -225,6 +225,18 @@ def check_verify_status_by_course(user, course_enrollments): POST_AUTH_PARAMS = ('course_id', 'enrollment_action', 'course_mode', 'email_opt_in', 'purchase_workflow') +def get_redirect_url_with_host(root_url, redirect_to): + """ + Adds host to the redirect url + """ + (_, netloc, path, query, fragment) = list(urllib.parse.urlsplit(redirect_to)) + if not netloc: + parse_root_url = urllib.parse.urlsplit(root_url) + redirect_to = urllib.parse.urlunsplit((parse_root_url.scheme, parse_root_url.netloc, path, query, fragment)) + + return redirect_to + + def get_next_url_for_login_page(request, include_host=False): """ Determine the URL to redirect to following login/registration/third_party_auth @@ -283,13 +295,6 @@ def get_next_url_for_login_page(request, include_host=False): # be saved in the session as part of the pipeline state. That URL will take priority # over this one. - if include_host: - (scheme, netloc, path, query, fragment) = list(urllib.parse.urlsplit(redirect_to)) - if not netloc: - parse_root_url = urllib.parse.urlsplit(root_url) - redirect_to = urllib.parse.urlunsplit((parse_root_url.scheme, parse_root_url.netloc, - path, query, fragment)) - # Append a tpa_hint query parameter, if one is configured tpa_hint = configuration_helpers.get_value( "THIRD_PARTY_AUTH_HINT", @@ -306,6 +311,9 @@ def get_next_url_for_login_page(request, include_host=False): query = urllib.parse.urlencode(params, doseq=True) redirect_to = urllib.parse.urlunsplit((scheme, netloc, path, query, fragment)) + if include_host: + return redirect_to, root_url + return redirect_to diff --git a/openedx/core/djangoapps/courseware_api/tests/test_views.py b/openedx/core/djangoapps/courseware_api/tests/test_views.py index 16a06fc1eb..02b00d6fba 100644 --- a/openedx/core/djangoapps/courseware_api/tests/test_views.py +++ b/openedx/core/djangoapps/courseware_api/tests/test_views.py @@ -131,7 +131,7 @@ class CourseApiTestViews(BaseCoursewareTests): 'The audit track does not include a certificate.') assert response.data['certificate_data']['msg'] == expected_audit_message assert response.data['verify_identity_url'] is None - assert response.data['verification_status'] is 'none' # lint-amnesty, pylint: disable=literal-comparison + assert response.data['verification_status'] == 'none' # lint-amnesty, pylint: disable=literal-comparison assert response.data['linkedin_add_to_profile_url'] is None else: assert response.data['certificate_data']['cert_status'] == 'earned_but_not_available' @@ -140,7 +140,7 @@ class CourseApiTestViews(BaseCoursewareTests): ) # The response contains an absolute URL so this is only checking the path of the final assert expected_verify_identity_url in response.data['verify_identity_url'] - assert response.data['verification_status'] is 'none' # lint-amnesty, pylint: disable=literal-comparison + assert response.data['verification_status'] == 'none' # lint-amnesty, pylint: disable=literal-comparison request = RequestFactory().request() cert_url = get_certificate_url(course_id=self.course.id, uuid=cert.verify_uuid) diff --git a/openedx/core/djangoapps/user_authn/views/login.py b/openedx/core/djangoapps/user_authn/views/login.py index 5f0a95cf7c..8917d5f400 100644 --- a/openedx/core/djangoapps/user_authn/views/login.py +++ b/openedx/core/djangoapps/user_authn/views/login.py @@ -7,6 +7,7 @@ Much of this file was broken out from views.py, previous history can be found th import json import logging import hashlib +import re import six from django.conf import settings @@ -36,11 +37,13 @@ from openedx.core.djangoapps.user_authn.exceptions import AuthFailedError from openedx.core.djangoapps.user_authn.toggles import should_redirect_to_authn_microfrontend from openedx.core.djangoapps.util.user_messages import PageLevelMessages from openedx.core.djangoapps.user_authn.views.password_reset import send_password_reset_email_for_user +from openedx.core.djangoapps.user_authn.views.utils import ENTERPRISE_ENROLLMENT_URL_REGEX, UUID4_REGEX from openedx.core.djangoapps.user_authn.toggles import is_require_third_party_auth_enabled from openedx.core.djangoapps.user_authn.config.waffle import ENABLE_LOGIN_USING_THIRDPARTY_AUTH_ONLY from openedx.core.djangolib.markup import HTML, Text from openedx.core.lib.api.view_utils import require_post_params -from common.djangoapps.student.helpers import get_next_url_for_login_page +from openedx.features.enterprise_support.api import activate_learner_enterprise, get_enterprise_learner_data_from_api +from common.djangoapps.student.helpers import get_next_url_for_login_page, get_redirect_url_with_host from common.djangoapps.student.models import LoginFailures, AllowedAuthUser, UserProfile from common.djangoapps.student.views import compose_and_send_activation_email from common.djangoapps.third_party_auth import pipeline, provider @@ -384,6 +387,35 @@ def finish_auth(request): }) +def enterprise_selection_page(request, user, next_url): + """ + Updates redirect url to enterprise selection page if user is associated + with multiple enterprises otherwise return the next url. + + param: + next_url(string): The URL to redirect to after multiple enterprise selection or in case + the selection page is bypassed e.g when dealing with direct enrolment urls. + """ + redirect_url = next_url + + response = get_enterprise_learner_data_from_api(user) + if response and len(response) > 1: + redirect_url = reverse('enterprise_select_active') + '/?success_url=' + next_url + + # Check to see if next url has an enterprise in it. In this case if user is associated with + # that enterprise, activate that enterprise and bypass the selection page. + if re.match(ENTERPRISE_ENROLLMENT_URL_REGEX, six.moves.urllib.parse.unquote(next_url)): + enterprise_in_url = re.search(UUID4_REGEX, next_url).group(0) + for enterprise in response: + if enterprise_in_url == str(enterprise['enterprise_customer']['uuid']): + is_activated_successfully = activate_learner_enterprise(request, user, enterprise_in_url) + if is_activated_successfully: + redirect_url = next_url + break + + return redirect_url + + @ensure_csrf_cookie @require_http_methods(['POST']) @ratelimit( @@ -479,13 +511,21 @@ def login_user(request): _handle_successful_authentication_and_login(possibly_authenticated_user, request) - redirect_url = None # The AJAX method calling should know the default destination upon success - if is_user_third_party_authenticated: - running_pipeline = pipeline.get(request) - redirect_url = pipeline.get_complete_url(backend_name=running_pipeline['backend']) + # The AJAX method calling should know the default destination upon success + redirect_url, finish_auth_url = None, '' + if third_party_auth_requested: + running_pipeline = pipeline.get(request) + finish_auth_url = pipeline.get_complete_url(backend_name=running_pipeline['backend']) + + if is_user_third_party_authenticated: + redirect_url = finish_auth_url elif should_redirect_to_authn_microfrontend(): - redirect_url = get_next_url_for_login_page(request, include_host=True) + next_url, root_url = get_next_url_for_login_page(request, include_host=True) + redirect_url = get_redirect_url_with_host( + root_url, + enterprise_selection_page(request, possibly_authenticated_user, finish_auth_url or next_url) + ) response = JsonResponse({ 'success': True, diff --git a/openedx/core/djangoapps/user_authn/views/register.py b/openedx/core/djangoapps/user_authn/views/register.py index e535abeb40..97a51f0492 100644 --- a/openedx/core/djangoapps/user_authn/views/register.py +++ b/openedx/core/djangoapps/user_authn/views/register.py @@ -35,7 +35,7 @@ from social_django import utils as social_utils from common.djangoapps import third_party_auth # Note that this lives in LMS, so this dependency should be refactored. # TODO Have the discussions code subscribe to the REGISTER_USER signal instead. -from common.djangoapps.student.helpers import get_next_url_for_login_page +from common.djangoapps.student.helpers import get_next_url_for_login_page, get_redirect_url_with_host from lms.djangoapps.discussion.notification_prefs.views import enable_notifications from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers @@ -506,7 +506,8 @@ class RegistrationView(APIView): if response: return response - redirect_url = get_next_url_for_login_page(request, include_host=True) + redirect_to, root_url = get_next_url_for_login_page(request, include_host=True) + redirect_url = get_redirect_url_with_host(root_url, redirect_to) response = self._create_response(request, {}, status_code=200, redirect_url=redirect_url) set_logged_in_cookies(request, response, user) return response diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_login.py b/openedx/core/djangoapps/user_authn/views/tests/test_login.py index b311628cf4..19b7af3d2e 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_login.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_login.py @@ -40,6 +40,7 @@ from openedx.core.djangoapps.user_authn.views.login import ( from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin from openedx.core.lib.api.test_utils import ApiTestCase +from openedx.features.enterprise_support.tests.factories import EnterpriseCustomerUserFactory from common.djangoapps.util.password_policy_validators import DEFAULT_MAX_PASSWORD_LENGTH @@ -180,6 +181,112 @@ class LoginTest(SiteMixin, CacheIsolationTestCase): self._assert_response(response, success=True) self._assert_redirect_url(response, expected_redirect) + @ddt.data(('/dashboard', False), ('/enterprise/select/active/?success_url=/dashboard', True)) + @ddt.unpack + @patch.dict(settings.FEATURES, {'ENABLE_AUTHN_MICROFRONTEND': True, 'ENABLE_ENTERPRISE_INTEGRATION': True}) + @override_settings(LOGIN_REDIRECT_WHITELIST=['openedx.service']) + @override_waffle_flag(REDIRECT_TO_AUTHN_MICROFRONTEND, active=True) + @patch('openedx.features.enterprise_support.api.EnterpriseApiClient') + @patch('openedx.core.djangoapps.user_authn.views.login.reverse') + @skip_unless_lms + def test_login_success_for_multiple_enterprises( + self, expected_redirect, user_has_multiple_enterprises, reverse_mock, mock_api_client_class + ): + """ + Test that if multiple enterprise feature is enabled, user is redirected + to correct page + """ + api_response = {'results': []} + enterprise = EnterpriseCustomerUserFactory(user_id=self.user.id).enterprise_customer + api_response['results'].append( + { + "enterprise_customer": { + "uuid": enterprise.uuid, + "name": enterprise.name, + "active": enterprise.active, + } + } + ) + + if user_has_multiple_enterprises: + enterprise = EnterpriseCustomerUserFactory(user_id=self.user.id).enterprise_customer + api_response['results'].append( + { + "enterprise_customer": { + "uuid": enterprise.uuid, + "name": enterprise.name, + "active": enterprise.active, + } + } + ) + + mock_client = mock_api_client_class.return_value + mock_client.fetch_enterprise_learner_data.return_value = api_response + reverse_mock.return_value = '/enterprise/select/active' + + response, _ = self._login_response( + self.user.email, + self.password, + HTTP_ACCEPT='*/*', + ) + self._assert_response(response, success=True) + self._assert_redirect_url(response, settings.LMS_ROOT_URL + expected_redirect) + + @ddt.data(('', True), ('/enterprise/select/active/?success_url=', False)) + @ddt.unpack + @patch.dict(settings.FEATURES, {'ENABLE_AUTHN_MICROFRONTEND': True, 'ENABLE_ENTERPRISE_INTEGRATION': True}) + @override_waffle_flag(REDIRECT_TO_AUTHN_MICROFRONTEND, active=True) + @patch('openedx.features.enterprise_support.api.EnterpriseApiClient') + @patch('openedx.core.djangoapps.user_authn.views.login.activate_learner_enterprise') + @patch('openedx.core.djangoapps.user_authn.views.login.reverse') + @skip_unless_lms + def test_enterprise_in_url( + self, expected_redirect, is_activated, reverse_mock, mock_activate_learner_enterprise, mock_api_client_class + ): + """ + If user has multiple enterprises and the enterprise is present in url, + activate that url + """ + api_response = {} + enterprise_1 = EnterpriseCustomerUserFactory(user_id=self.user.id).enterprise_customer + enterprise_2 = EnterpriseCustomerUserFactory(user_id=self.user.id).enterprise_customer + api_response['results'] = [ + { + "enterprise_customer": { + "uuid": enterprise_1.uuid, + "name": enterprise_1.name, + "active": enterprise_1.active, + } + }, + { + "enterprise_customer": { + "uuid": enterprise_2.uuid, + "name": enterprise_2.name, + "active": enterprise_2.active, + } + } + ] + + next_url = '/enterprise/{}/course/{}/enroll/?catalog=catalog_uuid&utm_medium=enterprise'.format( + enterprise_1.uuid, + 'course-v1:testX+test101+2T2020' + ) + + mock_client = mock_api_client_class.return_value + mock_client.fetch_enterprise_learner_data.return_value = api_response + mock_activate_learner_enterprise.return_value = is_activated + reverse_mock.return_value = '/enterprise/select/active' + + response, _ = self._login_response( + self.user.email, + self.password, + extra_post_params={'next': next_url}, + HTTP_ACCEPT='*/*', + ) + + self._assert_response(response, success=True) + self._assert_redirect_url(response, settings.LMS_ROOT_URL + expected_redirect + next_url) + @patch.dict("django.conf.settings.FEATURES", {'SQUELCH_PII_IN_LOGS': True}) def test_login_success_no_pii(self): response, mock_audit_log = self._login_response( diff --git a/openedx/core/djangoapps/user_authn/views/utils.py b/openedx/core/djangoapps/user_authn/views/utils.py index 275dd997b0..1329612d5d 100644 --- a/openedx/core/djangoapps/user_authn/views/utils.py +++ b/openedx/core/djangoapps/user_authn/views/utils.py @@ -11,6 +11,10 @@ from common.djangoapps.third_party_auth import pipeline from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +UUID4_REGEX = '[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}' +ENTERPRISE_ENROLLMENT_URL_REGEX = r'/enterprise/{}/course/{}/enroll'.format(UUID4_REGEX, settings.COURSE_KEY_REGEX) + + def third_party_auth_context(request, redirect_to, tpa_hint=None): """ Context for third party auth providers and the currently running pipeline. diff --git a/openedx/features/enterprise_support/api.py b/openedx/features/enterprise_support/api.py index f7bfd43fb0..815b2a767b 100644 --- a/openedx/features/enterprise_support/api.py +++ b/openedx/features/enterprise_support/api.py @@ -28,6 +28,7 @@ from openedx.features.enterprise_support.utils import get_data_consent_share_cac from common.djangoapps.third_party_auth.pipeline import get as get_partial_pipeline from common.djangoapps.third_party_auth.provider import Registry + try: from enterprise.models import ( EnterpriseCustomer, @@ -35,7 +36,9 @@ try: EnterpriseCustomerUser, PendingEnterpriseCustomerUser ) - from enterprise.api.v1.serializers import EnterpriseCustomerUserReadOnlySerializer + from enterprise.api.v1.serializers import ( + EnterpriseCustomerUserReadOnlySerializer, EnterpriseCustomerUserWriteSerializer + ) from consent.models import DataSharingConsent, DataSharingConsentTextOverrides except ImportError: # pragma: no cover pass @@ -301,6 +304,32 @@ class EnterpriseApiServiceClient(EnterpriseServiceClientMixin, EnterpriseApiClie return enterprise_customer +def activate_learner_enterprise(request, user, enterprise_customer): + """ + Allow an enterprise learner to activate one of learner's linked enterprises. + """ + serializer = EnterpriseCustomerUserWriteSerializer(data={ + 'enterprise_customer': enterprise_customer, + 'username': user.username, + 'active': True + }) + if serializer.is_valid(): + serializer.save() + enterprise_customer_user = EnterpriseCustomerUser.objects.get( + user_id=user.id, + enterprise_customer=enterprise_customer + ) + enterprise_customer_user.update_session(request) + LOGGER.info( + '[Enterprise Selection Page] Learner activated an enterprise. User: %s, EnterpriseCustomer: %s', + user.username, + enterprise_customer, + ) + return True + + return False + + def data_sharing_consent_required(view_func): """ Decorator which makes a view method redirect to the Data Sharing Consent form if: diff --git a/openedx/features/enterprise_support/tests/test_api.py b/openedx/features/enterprise_support/tests/test_api.py index 39248bb0d9..8249a322ca 100644 --- a/openedx/features/enterprise_support/tests/test_api.py +++ b/openedx/features/enterprise_support/tests/test_api.py @@ -20,6 +20,7 @@ from slumber.exceptions import HttpClientError from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms from openedx.features.enterprise_support.api import ( + activate_learner_enterprise, _CACHE_MISS, ENTERPRISE_CUSTOMER_KEY_NAME, EnterpriseApiException, @@ -382,6 +383,17 @@ class TestEnterpriseApi(EnterpriseServiceMockMixin, CacheIsolationTestCase): mock_api_client_class.assert_called_once_with(user=user) mock_client.fetch_enterprise_learner_data.assert_called_once_with(user) + def test_activate_learner_enterprise(self): + """ + Test enterprise is activated successfully for user + """ + request_mock = mock.MagicMock(session={}, user=self.user) + enterprise_customer_user = EnterpriseCustomerUserFactory(user_id=self.user.id) + enterprise_customer_uuid = enterprise_customer_user.enterprise_customer.uuid + + activate_learner_enterprise(request_mock, self.user, enterprise_customer_uuid) + assert request_mock.session['enterprise_customer']['uuid'] == str(enterprise_customer_uuid) + def test_get_enterprise_learner_data_from_db_no_data(self): assert [] == get_enterprise_learner_data_from_db(self.user)