VAN-311: Add multiple enterprise support for Authn MFE (#26526)

This commit is contained in:
Zainab Amir
2021-02-18 17:24:44 +05:00
committed by GitHub
parent a5aa069115
commit c260f72c2e
8 changed files with 219 additions and 18 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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,

View File

@@ -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

View File

@@ -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(

View File

@@ -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.

View File

@@ -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:

View File

@@ -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)