Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 0 additions & 10 deletions common/djangoapps/third_party_auth/apps.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# lint-amnesty, pylint: disable=missing-module-docstring

from django.apps import AppConfig
from django.conf import settings


class ThirdPartyAuthConfig(AppConfig): # lint-amnesty, pylint: disable=missing-class-docstring
Expand All @@ -11,12 +10,3 @@ class ThirdPartyAuthConfig(AppConfig): # lint-amnesty, pylint: disable=missing-
def ready(self):
# Import signal handlers to register them
from .signals import handlers # noqa: F401 pylint: disable=unused-import

# Note: Third-party auth settings are now defined statically in lms/envs/common.py
# However, the enterprise pipeline step must be inserted dynamically because
# it requires checking if enterprise is enabled, which can't be done at
# settings load time.
# Only insert enterprise elements if SOCIAL_AUTH_PIPELINE exists (LMS only, not CMS).
if hasattr(settings, 'SOCIAL_AUTH_PIPELINE'):
from openedx.features.enterprise_support.api import insert_enterprise_pipeline_elements
insert_enterprise_pipeline_elements(settings.SOCIAL_AUTH_PIPELINE)
4 changes: 4 additions & 0 deletions common/djangoapps/third_party_auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,10 @@ class ProviderConfig(ConfigurationModel):
help_text="Use the presence of a profile from a trusted third party as proof of identity verification.",
)

# Enterprise-only field: excludes this provider from the EnterpriseCustomer Django admin IDP
# dropdown. Added in ENT-1366 after social auth providers (Facebook, Google, etc.) were linked
# as enterprise IDPs, incorrectly associating all their users with an enterprise. Should ideally
# be migrated into the enterprise plugin.
disable_for_enterprise_sso = models.BooleanField(
default=False,
verbose_name='Disabled for Enterprise TPA',
Expand Down
81 changes: 8 additions & 73 deletions common/djangoapps/third_party_auth/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,7 @@ def B(*args, **kwargs):
from common.djangoapps.edxmako.shortcuts import render_to_string
from common.djangoapps.third_party_auth.utils import (
get_associated_user_by_email_response,
get_user_from_email,
is_enterprise_customer_user,
is_oauth_provider,
is_saml_provider,
user_exists,
)
from common.djangoapps.track import segment
Expand Down Expand Up @@ -781,80 +778,18 @@ def associate_by_email_if_oauth(auth_entry, backend, details, user, strategy, *a
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.
Deprecated — enterprise SAML email association moved to
enterprise.tpa_pipeline.enterprise_associate_by_email.

This association is done ONLY if the user entered the pipeline belongs to SAML provider.
Retained as a no-op for backwards compatibility with custom pipeline configs.
"""
from openedx.features.enterprise_support.api import enterprise_is_enabled

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 {})

@enterprise_is_enabled()
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(
'[Multiple_SSO_SAML_Accounts_Association_to_User] Enterprise user verification:' # noqa: UP032
'User Email: {email}, User ID: {user_id}, Provider ID: {provider_id},'
' is_enterprise_customer_user: {enterprise_customer_user}'.format(
email=current_user.email,
user_id=current_user.id,
provider_id=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, user_is_active = get_associated_user_by_email_response(
backend, details, user, *args, **kwargs)

if not user_is_active:
logger.info(
'[Multiple_SSO_SAML_Accounts_Association_to_User] User association account is not' # noqa: UP032 # pylint: disable=line-too-long
' active: User Email: {email}, User ID: {user_id}, Provider ID: {provider_id},'
' is_enterprise_customer_user: {enterprise_customer_user}'.format(
email=current_user.email,
user_id=current_user.id,
provider_id=current_provider.provider_id,
enterprise_customer_user=enterprise_customer_user
)
)
return None

return association_response

except Exception as ex: # pylint: disable=broad-except
logger.exception('[Multiple_SSO_SAML_Accounts_Association_to_User] Error in'
' saml multiple accounts association: User ID: %s, User Email: %s:,'
'Provider ID: %s, Exception: %s', current_user.id, current_user.email,
current_provider.provider_id, ex)

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
logger.warning(
"associate_by_email_if_saml is deprecated and is now a no-op. "
"Enterprise SAML email association has moved to "
"enterprise.tpa_pipeline.enterprise_associate_by_email."
)


def user_details_force_sync(auth_entry, strategy, details, user=None, *args, **kwargs): # lint-amnesty, pylint: disable=keyword-arg-before-vararg
Expand Down
16 changes: 11 additions & 5 deletions common/djangoapps/third_party_auth/saml.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,17 @@ def auth_url(self):

def disconnect(self, *args, **kwargs):
"""
Override of SAMLAuth.disconnect to unlink the learner from enterprise customer if associated.
"""
from openedx.features.enterprise_support.api import unlink_enterprise_user_from_idp
user = kwargs.get('user', None)
unlink_enterprise_user_from_idp(self.strategy.request, user, self.name)
Override of SAMLAuth.disconnect to emit a signal when a user disconnects their SAML account.
"""
from common.djangoapps.third_party_auth.signals import SAMLAccountDisconnected
# Emit the signal before super().disconnect() so that handlers (e.g. enterprise
# user unlinking) run while the social auth record still exists.
SAMLAccountDisconnected.send(
sender=self.__class__,
request=self.strategy.request,
user=kwargs.get('user', None),
saml_backend=self,
)
return super().disconnect(*args, **kwargs)

def _check_entitlements(self, idp, attributes):
Expand Down
6 changes: 5 additions & 1 deletion common/djangoapps/third_party_auth/signals/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
# Signal handlers for third_party_auth app
"""
Signals and signal handlers for third_party_auth app
"""

from common.djangoapps.third_party_auth.signals.signals import SAMLAccountDisconnected # noqa: F401
12 changes: 12 additions & 0 deletions common/djangoapps/third_party_auth/signals/signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""
Signals defined by the third_party_auth app.
"""
from django.dispatch import Signal

# Signal fired when a user disconnects a SAML account.
#
# Keyword arguments sent with this signal:
# request (HttpRequest): The HTTP request during which the disconnect occurred.
# user (User): The Django User disconnecting the social auth account.
# saml_backend: The SAMLAuth backend instance (has a ``name`` attribute).
SAMLAccountDisconnected = Signal()
68 changes: 20 additions & 48 deletions common/djangoapps/third_party_auth/tests/specs/test_testshib.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@

import ddt
import httpretty
from django.conf import settings
from django.contrib import auth
from enterprise.models import EnterpriseCustomerIdentityProvider, EnterpriseCustomerUser
from freezegun import freeze_time
from social_core import actions
from social_django import views as social_views
Expand All @@ -25,11 +23,11 @@
from common.djangoapps.third_party_auth.exceptions import IncorrectConfigurationException
from common.djangoapps.third_party_auth.saml import SapSuccessFactorsIdentityProvider
from common.djangoapps.third_party_auth.saml import log as saml_log
from common.djangoapps.third_party_auth.signals import SAMLAccountDisconnected
from common.djangoapps.third_party_auth.tasks import fetch_saml_metadata
from common.djangoapps.third_party_auth.tests import testutil, utils
from common.test.utils import assert_dict_contains_subset
from openedx.core.djangoapps.user_authn.views.login import login_user
from openedx.features.enterprise_support.tests.factories import EnterpriseCustomerFactory

from .base import IntegrationTestMixin

Expand Down Expand Up @@ -220,13 +218,7 @@ class TestShibIntegrationTest(SamlIntegrationTestUtilities, IntegrationTestMixin
"session_index": "1",
}

@patch("openedx.features.enterprise_support.api.enterprise_customer_for_request")
@patch("openedx.features.enterprise_support.utils.third_party_auth.provider.Registry.get")
def test_full_pipeline_succeeds_for_unlinking_testshib_account(
self,
mock_auth_provider,
mock_enterprise_customer_for_request,
):
def test_full_pipeline_succeeds_for_unlinking_testshib_account(self):

# First, create, the request and strategy that store pipeline state,
# configure the backend, and mock out wire traffic.
Expand All @@ -245,30 +237,6 @@ def test_full_pipeline_succeeds_for_unlinking_testshib_account(
# We're already logged in, so simulate that the cookie is set correctly
self.set_logged_in_cookies(request)

# linking a learner with enterprise customer.
enterprise_customer = EnterpriseCustomerFactory()
assert EnterpriseCustomerUser.objects.count() == 0, "Precondition check: no link records should exist"
EnterpriseCustomerUser.objects.link_user(enterprise_customer, user.email)
assert (
EnterpriseCustomerUser.objects.filter(enterprise_customer=enterprise_customer, user_id=user.id).count() == 1
)
EnterpriseCustomerIdentityProvider.objects.get_or_create(
enterprise_customer=enterprise_customer, provider_id=self.provider.provider_id
)

enterprise_customer_data = {
"uuid": enterprise_customer.uuid,
"name": enterprise_customer.name,
"identity_provider": "saml-default",
"identity_providers": [
{
"provider_id": "saml-default",
}
],
}
mock_auth_provider.return_value.backend_name = "tpa-saml"
mock_enterprise_customer_for_request.return_value = enterprise_customer_data

# Instrument the pipeline to get to the dashboard with the full expected state.
self.client.get(pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN))

Expand All @@ -285,31 +253,35 @@ def test_full_pipeline_succeeds_for_unlinking_testshib_account(
# First we expect that we're in the linked state, with a backend entry.
self.assert_social_auth_exists_for_user(request.user, strategy)

FEATURES_WITH_ENTERPRISE_ENABLED = settings.FEATURES.copy()
FEATURES_WITH_ENTERPRISE_ENABLED["ENABLE_ENTERPRISE_INTEGRATION"] = True
with patch.dict("django.conf.settings.FEATURES", FEATURES_WITH_ENTERPRISE_ENABLED):
# Track signal emissions to verify the disconnect signal is sent.
signal_calls = []

def signal_receiver(sender, **kwargs):
signal_calls.append(kwargs)

SAMLAccountDisconnected.connect(signal_receiver)
try:
# Fire off the disconnect pipeline without the user information.
actions.do_disconnect(
request.backend, None, None, redirect_field_name=auth.REDIRECT_FIELD_NAME, request=request
)
assert (
EnterpriseCustomerUser.objects.filter(enterprise_customer=enterprise_customer, user_id=user.id).count()
!= 0
)

# Fire off the disconnect pipeline to unlink.
self.assert_redirect_after_pipeline_completes(
actions.do_disconnect(
request.backend, user, None, redirect_field_name=auth.REDIRECT_FIELD_NAME, request=request
)
)
# Now we expect to be in the unlinked state, with no backend entry.
self.assert_third_party_accounts_state(request, linked=False)
self.assert_social_auth_does_not_exist_for_user(user, strategy)
assert (
EnterpriseCustomerUser.objects.filter(enterprise_customer=enterprise_customer, user_id=user.id).count()
== 0
)
finally:
SAMLAccountDisconnected.disconnect(signal_receiver)

# Now we expect to be in the unlinked state, with no backend entry.
self.assert_third_party_accounts_state(request, linked=False)
self.assert_social_auth_does_not_exist_for_user(user, strategy)
# Verify that the SAMLAccountDisconnected signal was emitted.
# The actual enterprise user unlinking is handled by edx-enterprise's
# signal handler, not by openedx-platform.
assert len(signal_calls) == 2, f"Expected 2 signal emissions, got {len(signal_calls)}"

def get_response_data(self):
"""Gets dict (string -> object) of merged data about the user."""
Expand Down
25 changes: 0 additions & 25 deletions common/djangoapps/third_party_auth/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,11 @@
create_or_update_bulk_saml_provider_data,
get_associated_user_by_email_response,
get_user_from_email,
is_enterprise_customer_user,
is_oauth_provider,
parse_metadata_xml,
user_exists,
)
from openedx.core.djangolib.testing.utils import skip_unless_lms
from openedx.features.enterprise_support.tests.factories import (
EnterpriseCustomerIdentityProviderFactory,
EnterpriseCustomerUserFactory,
)


@ddt.ddt
Expand Down Expand Up @@ -55,26 +50,6 @@ def test_get_user(self):
assert get_user_from_email({'email': '[email protected]'})
assert not get_user_from_email({'email': '[email protected]'})

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='[email protected]')
other_user = UserFactory(username='other_user', email='[email protected]')
customer_idp = EnterpriseCustomerIdentityProviderFactory.create(
provider_id='the-provider',
)
customer = customer_idp.enterprise_customer
EnterpriseCustomerUserFactory.create(
enterprise_customer=customer,
user_id=user.id,
)

assert is_enterprise_customer_user('the-provider', user)
assert not is_enterprise_customer_user('the-provider', other_user)

@ddt.data(
('saml-farkle', False),
('oa2-fergus', True),
Expand Down
9 changes: 0 additions & 9 deletions common/djangoapps/third_party_auth/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import dateutil.parser
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.utils.timezone import now
from enterprise.models import EnterpriseCustomerIdentityProvider, EnterpriseCustomerUser
from lxml import etree
from onelogin.saml2.utils import OneLogin_Saml2_Utils
from social_core.pipeline.social_auth import associate_by_email
Expand Down Expand Up @@ -164,14 +163,6 @@ def is_saml_provider(backend, kwargs):
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()


def is_oauth_provider(backend_name, **kwargs):
"""
Verify that the third party provider uses oauth
Expand Down
20 changes: 9 additions & 11 deletions docs/decisions/0025-saml-admin-views-in-enterprise-plugin.rst
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,11 @@ edx-enterprise repository:
* ``enterprise/api/v1/views/saml_provider_config.py``
* ``enterprise/api/v1/views/saml_provider_data.py``

They will be registered in ``enterprise/api/v1/urls.py`` under the same
``auth/saml/v0/`` prefix, so the API contract is preserved for existing clients.
They will be registered under the same ``auth/saml/v0/`` prefix, so the API
contract is preserved for existing clients.

The underlying ``SAMLProviderConfig``, ``SAMLProviderData``, and
``SAMLConfiguration`` models remain in
``common/djangoapps/third_party_auth/models.py`` because they are
``SAMLConfiguration`` models remain in openedx-platform because they are
general-purpose SAML models used by the platform's authentication layer
regardless of whether the enterprise plugin is installed.

Expand All @@ -58,10 +57,9 @@ Consequences

* **Dependency direction**: edx-enterprise now imports ``SAMLProviderConfig``,
``SAMLProviderData``, and several utility functions from
``common.djangoapps.third_party_auth``. This is the correct dependency
direction (plugin depends on platform, not the reverse).

* **Deployments without edx-enterprise**: The SAML admin API endpoints will not
be available in deployments that do not install edx-enterprise. This is
acceptable because the endpoints ONLY serve frontend-app-admin-portal
which doesn't work without edx-enterprise installed anyway.
``common.djangoapps.third_party_auth``. This makes the plugin more sensitive to
platform code changes in these models/functions. But this isn't worse than
before, which was just the same problem in the opposite direction. Ideally
there'd be a cleaner and more stable set of hooks into TPA that enterprise
can leverage, but that's a major enhancement which is not in scope for this
phase of the migration work.
Loading
Loading