diff --git a/common/djangoapps/third_party_auth/apps.py b/common/djangoapps/third_party_auth/apps.py index 7f6b82cfde1f..e9d129962d84 100644 --- a/common/djangoapps/third_party_auth/apps.py +++ b/common/djangoapps/third_party_auth/apps.py @@ -12,15 +12,11 @@ def ready(self): # Import signal handlers to register them from .signals import handlers # noqa: F401 pylint: disable=unused-import - # To override the settings before loading social_django. - if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH', False): - self._enable_third_party_auth() - - def _enable_third_party_auth(self): - """ - Enable the use of third_party_auth, which allows users to sign in to edX - using other identity providers. For configuration details, see - common/djangoapps/third_party_auth/settings.py. - """ - from common.djangoapps.third_party_auth import settings as auth_settings - auth_settings.apply_settings(settings) + # 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) diff --git a/common/djangoapps/third_party_auth/settings.py b/common/djangoapps/third_party_auth/settings.py deleted file mode 100644 index 0aeb94fbd498..000000000000 --- a/common/djangoapps/third_party_auth/settings.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Settings for the third-party auth module. - -The flow for settings registration is: - -The base settings file contains a boolean, ENABLE_THIRD_PARTY_AUTH, indicating -whether this module is enabled. startup.py probes the ENABLE_THIRD_PARTY_AUTH. -If true, it: - - a) loads this module. - b) calls apply_settings(), passing in the Django settings -""" - - -from django.conf import settings -from openedx.features.enterprise_support.api import insert_enterprise_pipeline_elements - - -def apply_settings(django_settings): - """Set provider-independent settings.""" - - # Whitelisted URL query parameters retrained in the pipeline session. - # Params not in this whitelist will be silently dropped. - django_settings.FIELDS_STORED_IN_SESSION = ['auth_entry', 'next'] - - # Inject exception middleware to make redirects fire. - django_settings.MIDDLEWARE.extend( - ['common.djangoapps.third_party_auth.middleware.ExceptionMiddleware'] - ) - - # Where to send the user if there's an error during social authentication - # and we cannot send them to a more specific URL - # (see middleware.ExceptionMiddleware). - django_settings.SOCIAL_AUTH_LOGIN_ERROR_URL = '/' - - # Where to send the user once social authentication is successful. - django_settings.SOCIAL_AUTH_LOGIN_REDIRECT_URL = '/dashboard' - - # Disable sanitizing of redirect urls in social-auth since the platform - # already does its own sanitization via the LOGIN_REDIRECT_WHITELIST setting. - django_settings.SOCIAL_AUTH_SANITIZE_REDIRECTS = False - - # Adding extra key value pair in the url query string for microsoft as per request - django_settings.SOCIAL_AUTH_AZUREAD_OAUTH2_AUTH_EXTRA_ARGUMENTS = {'msafed': 0} - - # Avoid default username check to allow non-ascii characters - django_settings.SOCIAL_AUTH_CLEAN_USERNAMES = not settings.FEATURES.get("ENABLE_UNICODE_USERNAME") - - # Inject our customized auth pipeline. All auth backends must work with - # this pipeline. - django_settings.SOCIAL_AUTH_PIPELINE = [ - 'common.djangoapps.third_party_auth.pipeline.parse_query_params', - 'social_core.pipeline.social_auth.social_details', - 'social_core.pipeline.social_auth.social_uid', - '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.associate_by_email_if_oauth', - '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', - 'social_core.pipeline.user.create_user', - 'social_core.pipeline.social_auth.associate_user', - 'social_core.pipeline.social_auth.load_extra_data', - 'social_core.pipeline.user.user_details', - 'common.djangoapps.third_party_auth.pipeline.user_details_force_sync', - 'common.djangoapps.third_party_auth.pipeline.set_id_verification_status', - 'common.djangoapps.third_party_auth.pipeline.set_logged_in_cookies', - 'common.djangoapps.third_party_auth.pipeline.login_analytics', - 'common.djangoapps.third_party_auth.pipeline.ensure_redirect_url_is_safe', - ] - - # Add enterprise pipeline elements if the enterprise app is installed - insert_enterprise_pipeline_elements(django_settings.SOCIAL_AUTH_PIPELINE) - - # Required so that we can use unmodified PSA OAuth2 backends: - django_settings.SOCIAL_AUTH_STRATEGY = 'common.djangoapps.third_party_auth.strategy.ConfigurationModelStrategy' - - # We let the user specify their email address during signup. - django_settings.SOCIAL_AUTH_PROTECTED_USER_FIELDS = ['email'] - - # Disable exceptions by default for prod so you get redirect behavior - # instead of a Django error page. During development you may want to - # enable this when you want to get stack traces rather than redirections. - django_settings.SOCIAL_AUTH_RAISE_EXCEPTIONS = False - - # Clean username to make sure username is compatible with our system requirements - django_settings.SOCIAL_AUTH_CLEAN_USERNAME_FUNCTION = 'common.djangoapps.third_party_auth.models.clean_username' - - # Allow users to login using social auth even if their account is not verified yet - # This is required since we [ab]use django's 'is_active' flag to indicate verified - # accounts; without this set to True, python-social-auth won't allow us to link the - # user's account to the third party account during registration (since the user is - # not verified at that point). - # We also generally allow unverified third party auth users to login (see the logic - # in ensure_user_information in pipeline.py) because otherwise users who use social - # auth to register with an invalid email address can become "stuck". - # TODO: Remove the following if/when email validation is separated from the is_active flag. - django_settings.SOCIAL_AUTH_INACTIVE_USER_LOGIN = True - django_settings.SOCIAL_AUTH_INACTIVE_USER_URL = '/auth/inactive' - - # Context processors required under Django. - django_settings.SOCIAL_AUTH_UUID_LENGTH = 10 - django_settings.DEFAULT_TEMPLATE_ENGINE['OPTIONS']['context_processors'] += ( - 'social_django.context_processors.backends', - 'social_django.context_processors.login_redirect', - ) diff --git a/common/djangoapps/third_party_auth/tests/test_settings.py b/common/djangoapps/third_party_auth/tests/test_settings.py index accecca09ec8..b0e9f0a8b3ec 100644 --- a/common/djangoapps/third_party_auth/tests/test_settings.py +++ b/common/djangoapps/third_party_auth/tests/test_settings.py @@ -1,67 +1,104 @@ -"""Unit tests for settings.py.""" +"""Unit tests for third-party auth settings in lms/envs/common.py.""" -from unittest.mock import patch -from common.djangoapps.third_party_auth import provider, settings -from common.djangoapps.third_party_auth.tests import testutil +from django.conf import settings +from django.test import TestCase, override_settings + +from common.djangoapps.third_party_auth import provider from common.djangoapps.third_party_auth.tests.utils import skip_unless_thirdpartyauth -_ORIGINAL_AUTHENTICATION_BACKENDS = ['first_authentication_backend'] -_ORIGINAL_INSTALLED_APPS = ['first_installed_app'] -_ORIGINAL_MIDDLEWARE_CLASSES = ['first_middleware_class'] -_ORIGINAL_TEMPLATE_CONTEXT_PROCESSORS = ['first_template_context_preprocessor'] -_SETTINGS_MAP = { - 'AUTHENTICATION_BACKENDS': _ORIGINAL_AUTHENTICATION_BACKENDS, - 'INSTALLED_APPS': _ORIGINAL_INSTALLED_APPS, - 'MIDDLEWARE': _ORIGINAL_MIDDLEWARE_CLASSES, - 'TEMPLATES': [{ - 'OPTIONS': { - 'context_processors': _ORIGINAL_TEMPLATE_CONTEXT_PROCESSORS - } - }], - 'FEATURES': {}, -} -_SETTINGS_MAP['DEFAULT_TEMPLATE_ENGINE'] = _SETTINGS_MAP['TEMPLATES'][0] - - -class SettingsUnitTest(testutil.TestCase): - """Unit tests for settings management code.""" - - # Suppress spurious no-member warning on fakes. - # pylint: disable=no-member - - def setUp(self): - super().setUp() - self.settings = testutil.FakeDjangoSettings(_SETTINGS_MAP) - - def test_apply_settings_adds_exception_middleware(self): - settings.apply_settings(self.settings) - assert 'common.djangoapps.third_party_auth.middleware.ExceptionMiddleware' in self.settings.MIDDLEWARE - - def test_apply_settings_adds_fields_stored_in_session(self): - settings.apply_settings(self.settings) - assert ['auth_entry', 'next'] == self.settings.FIELDS_STORED_IN_SESSION +from openedx.core.djangolib.testing.utils import skip_unless_lms + + +@skip_unless_lms +class SettingsUnitTest(TestCase): + """Unit tests for third-party auth settings defined in lms/envs/common.py.""" + + def test_exception_middleware_in_middleware_list(self): + """Verify ExceptionMiddleware is included in MIDDLEWARE.""" + assert 'common.djangoapps.third_party_auth.middleware.ExceptionMiddleware' in settings.MIDDLEWARE + + def test_fields_stored_in_session_defined(self): + """Verify FIELDS_STORED_IN_SESSION is defined with expected values.""" + assert settings.FIELDS_STORED_IN_SESSION == ['auth_entry', 'next'] @skip_unless_thirdpartyauth() - def test_apply_settings_enables_no_providers_by_default(self): - # Providers are only enabled via ConfigurationModels in the database - settings.apply_settings(self.settings) - assert [] == provider.Registry.enabled() - - def test_apply_settings_turns_off_raising_social_exceptions(self): - # Guard against submitting a conf change that's convenient in dev but - # bad in prod. - settings.apply_settings(self.settings) - assert not self.settings.SOCIAL_AUTH_RAISE_EXCEPTIONS - - def test_apply_settings_turns_off_redirect_sanitization(self): - settings.apply_settings(self.settings) - assert not self.settings.SOCIAL_AUTH_SANITIZE_REDIRECTS - - def test_apply_settings_avoids_default_username_check(self): - # Avoid the default username check where non-ascii characters are not - # allowed when unicode username is enabled - settings.apply_settings(self.settings) - assert self.settings.SOCIAL_AUTH_CLEAN_USERNAMES - # verify default behavior - with patch.dict('django.conf.settings.FEATURES', {'ENABLE_UNICODE_USERNAME': True}): - settings.apply_settings(self.settings) - assert not self.settings.SOCIAL_AUTH_CLEAN_USERNAMES + def test_no_providers_enabled_by_default(self): + """Providers are only enabled via ConfigurationModels in the database.""" + assert provider.Registry.enabled() == [] + + def test_social_auth_raise_exceptions_is_false(self): + """Guard against submitting a conf change that's convenient in dev but bad in prod.""" + assert settings.SOCIAL_AUTH_RAISE_EXCEPTIONS is False + + def test_social_auth_sanitize_redirects_is_false(self): + """Verify redirect sanitization is disabled (platform does its own).""" + assert settings.SOCIAL_AUTH_SANITIZE_REDIRECTS is False + + def test_social_auth_login_error_url(self): + """Verify SOCIAL_AUTH_LOGIN_ERROR_URL is set.""" + assert settings.SOCIAL_AUTH_LOGIN_ERROR_URL == '/' + + def test_social_auth_login_redirect_url(self): + """Verify SOCIAL_AUTH_LOGIN_REDIRECT_URL is set.""" + assert settings.SOCIAL_AUTH_LOGIN_REDIRECT_URL == '/dashboard' + + def test_social_auth_strategy(self): + """Verify SOCIAL_AUTH_STRATEGY is set to use ConfigurationModelStrategy.""" + assert settings.SOCIAL_AUTH_STRATEGY == 'common.djangoapps.third_party_auth.strategy.ConfigurationModelStrategy' + + def test_social_auth_pipeline_defined(self): + """Verify SOCIAL_AUTH_PIPELINE is defined and includes expected steps.""" + pipeline = settings.SOCIAL_AUTH_PIPELINE + assert isinstance(pipeline, list) + assert len(pipeline) > 0 + # Verify some key pipeline steps are present + assert 'common.djangoapps.third_party_auth.pipeline.parse_query_params' in pipeline + assert 'social_core.pipeline.user.create_user' in pipeline + assert 'common.djangoapps.third_party_auth.pipeline.ensure_redirect_url_is_safe' in pipeline + + def test_social_auth_context_processors(self): + """Verify social_django context processors are included.""" + # CONTEXT_PROCESSORS is used to build TEMPLATES, so check there + context_processors = settings.TEMPLATES[0]['OPTIONS']['context_processors'] + assert 'social_django.context_processors.backends' in context_processors + assert 'social_django.context_processors.login_redirect' in context_processors + + @override_settings(FEATURES={'ENABLE_UNICODE_USERNAME': False}) + def test_social_auth_clean_usernames_default(self): + """Verify SOCIAL_AUTH_CLEAN_USERNAMES is True when unicode usernames disabled.""" + # Note: SOCIAL_AUTH_CLEAN_USERNAMES is a Derived setting, computed at settings load time. + # This test verifies the default behavior (unicode usernames disabled). + assert settings.SOCIAL_AUTH_CLEAN_USERNAMES is True + + def test_social_auth_clean_usernames_computation(self): + """ + Verify the SOCIAL_AUTH_CLEAN_USERNAMES computation logic. + + SOCIAL_AUTH_CLEAN_USERNAMES is a Derived setting that is computed at settings load time, + so we can't use @override_settings to test both cases. Instead, we test the computation + logic directly to ensure it correctly inverts the ENABLE_UNICODE_USERNAME feature flag. + """ + # The logic in lms/envs/common.py is: + # SOCIAL_AUTH_CLEAN_USERNAMES = Derived( + # lambda settings: not settings.FEATURES.get('ENABLE_UNICODE_USERNAME', False) + # ) + # We replicate and test that logic here. + + class FakeSettings: + """Fake settings object for testing the Derived computation.""" + def __init__(self, features): + self.FEATURES = features + + # When ENABLE_UNICODE_USERNAME is False (default), SOCIAL_AUTH_CLEAN_USERNAMES should be True + fake_settings = FakeSettings({'ENABLE_UNICODE_USERNAME': False}) + result = not fake_settings.FEATURES.get('ENABLE_UNICODE_USERNAME', False) + assert result is True + + # When ENABLE_UNICODE_USERNAME is True, SOCIAL_AUTH_CLEAN_USERNAMES should be False + fake_settings = FakeSettings({'ENABLE_UNICODE_USERNAME': True}) + result = not fake_settings.FEATURES.get('ENABLE_UNICODE_USERNAME', False) + assert result is False + + # When ENABLE_UNICODE_USERNAME is not set, should default to False, so result is True + fake_settings = FakeSettings({}) + result = not fake_settings.FEATURES.get('ENABLE_UNICODE_USERNAME', False) + assert result is True diff --git a/lms/envs/common.py b/lms/envs/common.py index 0419633f583e..c395d6423bb9 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -300,6 +300,69 @@ # .. toggle_creation_date: 2014-09-15 ENABLE_THIRD_PARTY_AUTH = False +# Third-party auth settings for python-social-auth +# These are defined unconditionally; they only take effect when +# AUTHENTICATION_BACKENDS includes social auth backends. + +# Where to send the user if there's an error during social authentication +SOCIAL_AUTH_LOGIN_ERROR_URL = '/' +# Where to send the user once social authentication is successful +SOCIAL_AUTH_LOGIN_REDIRECT_URL = '/dashboard' +# Disable sanitizing of redirect urls in social-auth since the platform +# already does its own sanitization via the LOGIN_REDIRECT_WHITELIST setting. +SOCIAL_AUTH_SANITIZE_REDIRECTS = False +# Adding extra key value pair in the url query string for microsoft as per request +SOCIAL_AUTH_AZUREAD_OAUTH2_AUTH_EXTRA_ARGUMENTS = {'msafed': 0} +# Required so that we can use unmodified PSA OAuth2 backends: +SOCIAL_AUTH_STRATEGY = 'common.djangoapps.third_party_auth.strategy.ConfigurationModelStrategy' +# We let the user specify their email address during signup. +SOCIAL_AUTH_PROTECTED_USER_FIELDS = ['email'] +# Disable exceptions by default for prod so you get redirect behavior +# instead of a Django error page. +SOCIAL_AUTH_RAISE_EXCEPTIONS = False +# Clean username to make sure username is compatible with our system requirements +SOCIAL_AUTH_CLEAN_USERNAME_FUNCTION = 'common.djangoapps.third_party_auth.models.clean_username' +# Allow users to login using social auth even if their account is not verified yet +SOCIAL_AUTH_INACTIVE_USER_LOGIN = True +SOCIAL_AUTH_INACTIVE_USER_URL = '/auth/inactive' +SOCIAL_AUTH_UUID_LENGTH = 10 +# Whitelisted URL query parameters retained in the pipeline session. +FIELDS_STORED_IN_SESSION = ['auth_entry', 'next'] + +# Computed setting: disable clean usernames check when unicode usernames are enabled +SOCIAL_AUTH_CLEAN_USERNAMES = Derived( + lambda settings: not settings.FEATURES.get('ENABLE_UNICODE_USERNAME', False) +) + +# Social auth pipeline for third-party authentication. +# Operators can override SOCIAL_AUTH_PIPELINE directly in their settings +# to customize the pipeline. +# Note: The enterprise step (handle_enterprise_logistration) is inserted dynamically +# during app initialization by third_party_auth's AppConfig.ready() if enterprise +# is enabled. It cannot be included statically because it requires runtime checks. +SOCIAL_AUTH_PIPELINE = [ + 'common.djangoapps.third_party_auth.pipeline.parse_query_params', + 'social_core.pipeline.social_auth.social_details', + 'social_core.pipeline.social_auth.social_uid', + '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.associate_by_email_if_oauth', + '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', + 'social_core.pipeline.user.create_user', + 'social_core.pipeline.social_auth.associate_user', + 'social_core.pipeline.social_auth.load_extra_data', + 'social_core.pipeline.user.user_details', + 'common.djangoapps.third_party_auth.pipeline.user_details_force_sync', + 'common.djangoapps.third_party_auth.pipeline.set_id_verification_status', + 'common.djangoapps.third_party_auth.pipeline.set_logged_in_cookies', + 'common.djangoapps.third_party_auth.pipeline.login_analytics', + 'common.djangoapps.third_party_auth.pipeline.ensure_redirect_url_is_safe', +] + # Prevent concurrent logins per user PREVENT_CONCURRENT_LOGINS = True @@ -799,6 +862,10 @@ # Context processor necessary for the survey report message appear on the admin site 'openedx.features.survey_report.context_processors.admin_extra_context', + + # Third-party auth context processors for social_django + 'social_django.context_processors.backends', + 'social_django.context_processors.login_redirect', ] DEFAULT_TEMPLATE_ENGINE_DIRS = Derived(lambda settings: settings.TEMPLATES[0]['DIRS'][:]) @@ -1211,6 +1278,9 @@ # Handles automatically storing user ids in django-simple-history tables when possible. 'simple_history.middleware.HistoryRequestMiddleware', + # Third-party auth exception handling for social auth redirects + 'common.djangoapps.third_party_auth.middleware.ExceptionMiddleware', + # This must be last 'openedx.core.djangoapps.site_configuration.middleware.SessionCookieDomainOverrideMiddleware', ]