From b99a64c3859d09019593f7acbb71cc82b3c8ea4d Mon Sep 17 00:00:00 2001 From: Zaman Afzal Date: Mon, 1 Feb 2021 11:44:12 +0500 Subject: [PATCH] ENT-3798 Multiple_SSO_Accounts_Association_to_SAML_User (#26170) --- .../third_party_auth/config/__init__.py | 0 .../third_party_auth/config/waffle.py | 25 +++++ .../djangoapps/third_party_auth/pipeline.py | 94 ++++++++++++++++++- .../djangoapps/third_party_auth/settings.py | 1 + .../third_party_auth/tests/test_utils.py | 48 +++++++++- common/djangoapps/third_party_auth/utils.py | 35 +++++++ .../djangoapps/user_api/accounts/utils.py | 11 +++ 7 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 common/djangoapps/third_party_auth/config/__init__.py create mode 100644 common/djangoapps/third_party_auth/config/waffle.py diff --git a/common/djangoapps/third_party_auth/config/__init__.py b/common/djangoapps/third_party_auth/config/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/third_party_auth/config/waffle.py b/common/djangoapps/third_party_auth/config/waffle.py new file mode 100644 index 0000000000..2d3327b748 --- /dev/null +++ b/common/djangoapps/third_party_auth/config/waffle.py @@ -0,0 +1,25 @@ +""" +Waffle flags and switches for third party auth . +""" + + +from edx_toggles.toggles import LegacyWaffleSwitch, LegacyWaffleSwitchNamespace + +_WAFFLE_NAMESPACE = u'third_party_auth' +_WAFFLE_SWITCH_NAMESPACE = LegacyWaffleSwitchNamespace(name=_WAFFLE_NAMESPACE, log_prefix=u'ThirdPartyAuth: ') + +# .. toggle_name: third_party_auth.enable_multiple_sso_accounts_association_to_saml_user +# .. toggle_implementation: WaffleSwitch +# .. toggle_default: False +# .. toggle_description: If enabled than learner should not be prompted for their edX password arriving via SAML +# and already linked to the enterprise customer linked to the same IdP." +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2021-01-29 +# .. toggle_target_removal_date: 2021-04-31 +# .. toggle_warnings: None. +# .. toggle_tickets: ENT-4034 +ENABLE_MULTIPLE_SSO_ACCOUNTS_ASSOCIATION_TO_SAML_USER = LegacyWaffleSwitch( + _WAFFLE_SWITCH_NAMESPACE, + 'enable_multiple_sso_accounts_association_to_saml_user', + __name__ +) diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py index 16dd6af771..83dd93647b 100644 --- a/common/djangoapps/third_party_auth/pipeline.py +++ b/common/djangoapps/third_party_auth/pipeline.py @@ -87,9 +87,15 @@ from lms.djangoapps.verify_student.models import SSOVerification from lms.djangoapps.verify_student.utils import earliest_allowed_verification_date from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.user_api import accounts +from openedx.core.djangoapps.user_api.accounts.utils import is_multiple_sso_accounts_association_to_saml_user_enabled from openedx.core.djangoapps.user_authn import cookies as user_authn_cookies from openedx.core.djangoapps.user_authn.utils import should_redirect_to_authn_microfrontend -from common.djangoapps.third_party_auth.utils import user_exists +from common.djangoapps.third_party_auth.utils import ( + get_user_from_email, + is_enterprise_customer_user, + is_saml_provider, + user_exists, +) from common.djangoapps.track import segment from common.djangoapps.util.json_request import JsonResponse @@ -740,6 +746,92 @@ def associate_by_email_if_login_api(auth_entry, backend, details, user, current_ return association_response +@partial.partial +def associate_by_email_if_saml(auth_entry, backend, details, user, strategy, *args, **kwargs): + """ + This pipeline step associates the current social auth with the user with the + same email address in the database. It defers to the social library's associate_by_email + implementation, which verifies that only a single database user is associated with the email. + + This association is done ONLY if the user entered the pipeline belongs to SAML provider. + """ + + def get_user(): + """ + This is the helper method to get the user from system by matching email. + """ + user_details = {'email': details.get('email')} if details else None + return get_user_from_email(user_details or {}) + + def associate_by_email_if_enterprise_user(): + """ + If the learner arriving via SAML is already linked to the enterprise customer linked to the same IdP, + they should not be prompted for their edX password. + """ + try: + enterprise_customer_user = is_enterprise_customer_user(current_provider.provider_id, current_user) + logger.info( + u'[Multiple_SSO_SAML_Accounts_Association_to_User] Enterprise user verification:' + u'Email: {email}, user_id: {user_id}, Provider: {provider},' + u' enterprise_customer_user: {enterprise_customer_user}'.format( + email=current_user.email, + user_id=current_user.id, + provider=current_provider.provider_id, + enterprise_customer_user=enterprise_customer_user, + ) + ) + + if enterprise_customer_user: + # this is python social auth pipeline default method to automatically associate social accounts + # if the email already matches a user account. + association_response = associate_by_email(backend, details, user, *args, **kwargs) + + if ( + association_response and + association_response.get('user') and + association_response['user'].is_active + ): + # Only return the user matched by email if their email has been activated. + # Otherwise, an illegitimate user can create an account with another user's + # email address and the legitimate user would now login to the illegitimate + # account. + return association_response + elif ( + association_response and + association_response.get('user') and + not association_response['user'].is_active + ): + logger.info( + u'[Multiple_SSO_SAML_Accounts_Association_to_User] User association account is not' + u' active: Email: {email}, user_id: {user_id}, Provider: {provider},' + u' enterprise_customer_user: {enterprise_customer_user}'.format( + email=current_user.email, + user_id=current_user.id, + provider=current_provider.provider_id, + enterprise_customer_user=enterprise_customer_user + ) + ) + return None + + except Exception as ex: # pylint: disable=broad-except + logger.exception('[Multiple_SSO_SAML_Accounts_Association_to_User] Error in' + ' saml multiple accounts association: %s:, %s:', current_user.id, ex) + + # this is waffle switch to enable and disable this functionality from admin panel. + if is_multiple_sso_accounts_association_to_saml_user_enabled(): + + saml_provider, current_provider = is_saml_provider(strategy.request.backend.name, kwargs) + + if saml_provider: + # get the user by matching email if the pipeline user is not available. + current_user = user if user else get_user() + + # Verify that the user linked to enterprise customer of current identity provider and an active user + associate_response = associate_by_email_if_enterprise_user() if current_user else None + if associate_response: + return associate_response + + def user_details_force_sync(auth_entry, strategy, details, user=None, *args, **kwargs): """ Update normally protected user details using data from provider. diff --git a/common/djangoapps/third_party_auth/settings.py b/common/djangoapps/third_party_auth/settings.py index c22da772b7..dc787ba06d 100644 --- a/common/djangoapps/third_party_auth/settings.py +++ b/common/djangoapps/third_party_auth/settings.py @@ -54,6 +54,7 @@ def apply_settings(django_settings): 'social_core.pipeline.social_auth.auth_allowed', 'social_core.pipeline.social_auth.social_user', 'common.djangoapps.third_party_auth.pipeline.associate_by_email_if_login_api', + 'common.djangoapps.third_party_auth.pipeline.associate_by_email_if_saml', 'common.djangoapps.third_party_auth.pipeline.get_username', 'common.djangoapps.third_party_auth.pipeline.set_pipeline_timeout', 'common.djangoapps.third_party_auth.pipeline.ensure_user_information', diff --git a/common/djangoapps/third_party_auth/tests/test_utils.py b/common/djangoapps/third_party_auth/tests/test_utils.py index ada897d320..35bd5ee65c 100644 --- a/common/djangoapps/third_party_auth/tests/test_utils.py +++ b/common/djangoapps/third_party_auth/tests/test_utils.py @@ -9,7 +9,16 @@ from django.conf import settings from common.djangoapps.student.tests.factories import UserFactory from common.djangoapps.third_party_auth.tests.testutil import TestCase -from common.djangoapps.third_party_auth.utils import user_exists, convert_saml_slug_provider_id +from common.djangoapps.third_party_auth.utils import ( + get_user_from_email, + is_enterprise_customer_user, + user_exists, + convert_saml_slug_provider_id, +) +from openedx.features.enterprise_support.tests.factories import ( + EnterpriseCustomerIdentityProviderFactory, + EnterpriseCustomerUserFactory, +) @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @@ -53,3 +62,40 @@ class TestUtils(TestCase): self.assertEqual( convert_saml_slug_provider_id(provider_names[provider_id]), provider_id ) + + def test_get_user(self): + """ + Match the email and return user if exists. + """ + # Create users from factory + UserFactory(username='test_user', email='test_user@example.com') + self.assertTrue( + get_user_from_email({'email': 'test_user@example.com'}), + ) + self.assertFalse( + get_user_from_email({'email': 'invalid@example.com'}), + ) + + def test_is_enterprise_customer_user(self): + """ + Verify that if user is an enterprise learner. + """ + # Create users from factory + + user = UserFactory(username='test_user', email='test_user@example.com') + other_user = UserFactory(username='other_user', email='other_user@example.com') + customer_idp = EnterpriseCustomerIdentityProviderFactory.create( + provider_id='the-provider', + ) + customer = customer_idp.enterprise_customer + EnterpriseCustomerUserFactory.create( + enterprise_customer=customer, + user_id=user.id, + ) + + self.assertTrue( + is_enterprise_customer_user('the-provider', user), + ) + self.assertFalse( + is_enterprise_customer_user('the-provider', other_user), + ) diff --git a/common/djangoapps/third_party_auth/utils.py b/common/djangoapps/third_party_auth/utils.py index 457ff47726..8f2948623f 100644 --- a/common/djangoapps/third_party_auth/utils.py +++ b/common/djangoapps/third_party_auth/utils.py @@ -4,6 +4,8 @@ Utility functions for third_party_auth from uuid import UUID from django.contrib.auth.models import User +from enterprise.models import EnterpriseCustomerUser, EnterpriseCustomerIdentityProvider +from . import provider def user_exists(details): @@ -30,6 +32,23 @@ def user_exists(details): return False +def get_user_from_email(details): + """ + Return user with given details exist in the system.∂i + + Arguments: + details (dict): dictionary containing user email. + + Returns: + User: if user with given details exists, None otherwise. + """ + email = details.get('email') + if email: + return User.objects.filter(email=email).first() + + return None + + def convert_saml_slug_provider_id(provider): """ Provider id is stored with the backend type prefixed to it (ie "saml-") @@ -57,3 +76,19 @@ def validate_uuid4_string(uuid_string): except ValueError: return False return True + + +def is_saml_provider(backend, kwargs): + """ Verify that the third party provider uses SAML """ + current_provider = provider.Registry.get_from_pipeline({'backend': backend, 'kwargs': kwargs}) + saml_providers_list = list(provider.Registry.get_enabled_by_backend_name('tpa-saml')) + return (current_provider and + current_provider.slug in [saml_provider.slug for saml_provider in saml_providers_list]), current_provider + + +def is_enterprise_customer_user(provider_id, user): + """ Verify that the user linked to enterprise customer of current identity provider""" + enterprise_idp = EnterpriseCustomerIdentityProvider.objects.get(provider_id=provider_id) + + return EnterpriseCustomerUser.objects.filter(enterprise_customer=enterprise_idp.enterprise_customer, + user_id=user.id).exists() diff --git a/openedx/core/djangoapps/user_api/accounts/utils.py b/openedx/core/djangoapps/user_api/accounts/utils.py index 3e6b0260de..f29c1f5280 100644 --- a/openedx/core/djangoapps/user_api/accounts/utils.py +++ b/openedx/core/djangoapps/user_api/accounts/utils.py @@ -14,6 +14,7 @@ from six import text_type from six.moves import range from six.moves.urllib.parse import urlparse # pylint: disable=import-error +from common.djangoapps.third_party_auth.config.waffle import ENABLE_MULTIPLE_SSO_ACCOUNTS_ASSOCIATION_TO_SAML_USER from openedx.core.djangoapps.site_configuration.models import SiteConfiguration from openedx.core.djangoapps.theming.helpers import get_config_value_from_site_or_settings, get_current_site from openedx.core.djangoapps.user_api.config.waffle import ( @@ -200,3 +201,13 @@ def is_multiple_user_enterprises_feature_enabled(): Boolean value representing switch status """ return user_api_waffle().is_enabled(ENABLE_MULTIPLE_USER_ENTERPRISES_FEATURE) + + +def is_multiple_sso_accounts_association_to_saml_user_enabled(): + """ + Checks to see if the django-waffle switch for enabling the multiple sso accounts association to saml user is active + + Returns: + Boolean value representing switch status + """ + return ENABLE_MULTIPLE_SSO_ACCOUNTS_ASSOCIATION_TO_SAML_USER.is_enabled()