ENT-3798 Multiple_SSO_Accounts_Association_to_SAML_User (#26170)

This commit is contained in:
Zaman Afzal
2021-02-01 11:44:12 +05:00
committed by GitHub
parent ec5b78c625
commit b99a64c385
7 changed files with 212 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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