VAN-311: Add multiple enterprise support for Authn MFE (#26526)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user