diff --git a/lms/envs/production.py b/lms/envs/production.py index 7a60ef949bf33f73805168f028a3a1910f64dbf7..a5f87a95f4a07f27bec45c9991bf74cae49e97d2 100644 --- a/lms/envs/production.py +++ b/lms/envs/production.py @@ -622,6 +622,8 @@ TIME_ZONE_DISPLAYED_FOR_DEADLINES = ENV_TOKENS.get("TIME_ZONE_DISPLAYED_FOR_DEAD TIME_ZONE_DISPLAYED_FOR_DEADLINES) ##### Third-party auth options ################################################ +ENABLE_REQUIRE_THIRD_PARTY_AUTH = ENV_TOKENS.get('ENABLE_REQUIRE_THIRD_PARTY_AUTH', False) + if FEATURES.get('ENABLE_THIRD_PARTY_AUTH'): tmp_backends = ENV_TOKENS.get('THIRD_PARTY_AUTH_BACKENDS', [ 'social_core.backends.google.GoogleOAuth2', diff --git a/lms/static/js/spec/student_account/login_spec.js b/lms/static/js/spec/student_account/login_spec.js index a2aa534c8ff0afa4bb143c203827dce0e9ee1acf..86d703130c88c37d67036332dae6fbb85fe1bae9 100644 --- a/lms/static/js/spec/student_account/login_spec.js +++ b/lms/static/js/spec/student_account/login_spec.js @@ -193,6 +193,21 @@ expect($('.button-oa2-facebook')).toBeVisible(); }); + it('does not display the login form', function() { + var thirdPartyAuthView = new LoginView({ + fields: FORM_DESCRIPTION.fields, + model: model, + resetModel: resetModel, + thirdPartyAuth: THIRD_PARTY_AUTH, + platformName: PLATFORM_NAME, + enterpriseSlugLoginURL: ENTERPRISE_SLUG_LOGIN_URL, + is_require_third_party_auth_enabled: true + }); + + expect(thirdPartyAuthView).not.toContain(view.$submitButton); + expect(thirdPartyAuthView).not.toContain($('form-field')); + }); + it('displays a link to the signin help', function() { createLoginView(this); diff --git a/lms/static/js/spec/student_account/register_spec.js b/lms/static/js/spec/student_account/register_spec.js index 78c517eeb521455fdb251f027d362ba712143412..4c59665f5120bc06c15ba8d86174e5506d6c30f9 100644 --- a/lms/static/js/spec/student_account/register_spec.js +++ b/lms/static/js/spec/student_account/register_spec.js @@ -372,6 +372,20 @@ expect($('.button-oa2-facebook')).toBeVisible(); }); + it('does not display the registration form', function() { + var thirdPartyAuthView = new RegisterView({ + fields: FORM_DESCRIPTION.fields, + model: model, + thirdPartyAuth: THIRD_PARTY_AUTH, + platformName: PLATFORM_NAME, + is_require_third_party_auth_enabled: true + }); + + expect(thirdPartyAuthView).not.toContain(view.$submitButton); + expect(thirdPartyAuthView).not.toContain($('form-field')); + }); + + it('validates registration form fields on form submission', function() { createRegisterView(this); diff --git a/lms/static/js/student_account/views/AccessView.js b/lms/static/js/student_account/views/AccessView.js index 9d547d363995f3e3dbbaf3eed961698a9219ff15..7991be104dbf70a99b1eff57fec95a31c875d5f5 100644 --- a/lms/static/js/student_account/views/AccessView.js +++ b/lms/static/js/student_account/views/AccessView.js @@ -82,6 +82,7 @@ this.isAccountRecoveryFeatureEnabled = options.is_account_recovery_feature_enabled || false; this.isMultipleUserEnterprisesFeatureEnabled = options.is_multiple_user_enterprises_feature_enabled || false; + this.is_require_third_party_auth_enabled = options.is_require_third_party_auth_enabled || false; // The login view listens for 'sync' events from the reset model this.resetModel = new PasswordResetModel({}, { @@ -162,7 +163,8 @@ hideAuthWarnings: this.hideAuthWarnings, pipelineUserDetails: this.pipelineUserDetails, enterpriseName: this.enterpriseName, - enterpriseSlugLoginURL: this.enterpriseSlugLoginURL + enterpriseSlugLoginURL: this.enterpriseSlugLoginURL, + is_require_third_party_auth_enabled: this.is_require_third_party_auth_enabled }); // Listen for 'password-help' event to toggle sub-views @@ -203,7 +205,8 @@ model: model, thirdPartyAuth: this.thirdPartyAuth, platformName: this.platformName, - hideAuthWarnings: this.hideAuthWarnings + hideAuthWarnings: this.hideAuthWarnings, + is_require_third_party_auth_enabled: this.is_require_third_party_auth_enabled }); // Listen for 'auth-complete' event so we can enroll/redirect the user appropriately. diff --git a/lms/static/js/student_account/views/LoginView.js b/lms/static/js/student_account/views/LoginView.js index 466c133535cff186326830265856853b0f48a0c6..30a61e461058d053cd1fab3c43b524ce5010656e 100644 --- a/lms/static/js/student_account/views/LoginView.js +++ b/lms/static/js/student_account/views/LoginView.js @@ -57,6 +57,7 @@ this.pipelineUserDetails = data.pipelineUserDetails; this.enterpriseName = data.enterpriseName; this.enterpriseSlugLoginURL = data.enterpriseSlugLoginURL; + this.is_require_third_party_auth_enabled = data.is_require_third_party_auth_enabled || false; this.listenTo(this.model, 'sync', this.saveSuccess); this.listenTo(this.resetModel, 'sync', this.resetEmail); @@ -82,7 +83,8 @@ platformName: this.platformName, createAccountOption: this.createAccountOption, pipelineUserDetails: this.pipelineUserDetails, - enterpriseName: this.enterpriseName + enterpriseName: this.enterpriseName, + is_require_third_party_auth_enabled: this.is_require_third_party_auth_enabled } }) ) diff --git a/lms/static/js/student_account/views/RegisterView.js b/lms/static/js/student_account/views/RegisterView.js index 7bd7e5aef396e99d9a46886e6cc9aa5bb988907f..3953f273932fcd3b5c120dee530f01d1ab20375a 100644 --- a/lms/static/js/student_account/views/RegisterView.js +++ b/lms/static/js/student_account/views/RegisterView.js @@ -38,6 +38,7 @@ 'terms_of_service' ], formType: 'register', + formFields: '.form-fields', formStatusTpl: formStatusTpl, authWarningJsHook: 'js-auth-warning', defaultFormErrorsTitle: gettext('We couldn\'t create your account.'), @@ -63,6 +64,7 @@ this.autoRegisterWelcomeMessage = data.thirdPartyAuth.autoRegisterWelcomeMessage || ''; this.registerFormSubmitButtonText = data.thirdPartyAuth.registerFormSubmitButtonText || _('Create Account'); + this.is_require_third_party_auth_enabled = data.is_require_third_party_auth_enabled || false; this.listenTo(this.model, 'sync', this.saveSuccess); this.listenTo(this.model, 'validation', this.renderLiveValidations); @@ -146,7 +148,8 @@ hasSecondaryProviders: this.hasSecondaryProviders, platformName: this.platformName, autoRegisterWelcomeMessage: this.autoRegisterWelcomeMessage, - registerFormSubmitButtonText: this.registerFormSubmitButtonText + registerFormSubmitButtonText: this.registerFormSubmitButtonText, + is_require_third_party_auth_enabled: this.is_require_third_party_auth_enabled } }); @@ -491,6 +494,7 @@ jsHook: this.authWarningJsHook, message: fullMsg }); + $(this.formFields).removeClass('hidden'); }, submitForm: function(event) { // eslint-disable-line no-unused-vars diff --git a/lms/templates/student_account/login.underscore b/lms/templates/student_account/login.underscore index eccdfc80001856e4d2d5c3ba0976df93435aef20..c2f9055419e8baac0e565031b2a9d99426b0eba0 100644 --- a/lms/templates/student_account/login.underscore +++ b/lms/templates/student_account/login.underscore @@ -23,7 +23,7 @@ <%- gettext("You can view your information or unlink from {enterprise_name} anytime in your Account Settings.").replace(/{enterprise_name}/g, context.enterpriseName) %> </p> <p><%- gettext("To continue learning with this account, sign in below.") %></p> -<% } else { %> +<% } else if (!context.is_require_third_party_auth_enabled) { %> <h1 class="section-title"><%- gettext("Sign In") %></h1> <% } %> @@ -50,9 +50,11 @@ </div> <% } %> - <%= HtmlUtils.HTML(context.fields) %> + <% if (!context.is_require_third_party_auth_enabled) { %> + <%= HtmlUtils.HTML(context.fields) %> - <button type="submit" class="action action-primary action-update js-login login-button"><%- gettext("Sign in") %></button> + <button type="submit" class="action action-primary action-update js-login login-button"><%- gettext("Sign in") %></button> + <% } %> <% if ( context.providers.length > 0 && !context.currentProvider) { %> <div class="login-providers"> diff --git a/lms/templates/student_account/register.underscore b/lms/templates/student_account/register.underscore index aa992e08a9ad3e199c072602e3f5a4a75c4a3468..84cdb09d5ce12e749a50b482ef775ada2d1d0b7f 100644 --- a/lms/templates/student_account/register.underscore +++ b/lms/templates/student_account/register.underscore @@ -39,31 +39,34 @@ </button> <% } %> </div> - <div class="section-title lines"> - <h3> - <span class="text"><%- gettext("or create a new one here") %></span> - </h3> - </div> - <% } else { %> + <% if (!context.is_require_third_party_auth_enabled) { %> + <div class="section-title lines"> + <h3> + <span class="text"><%- gettext("or create a new one here") %></span> + </h3> + </div> + <% } %> + <% } else if (!context.is_require_third_party_auth_enabled) { %> <h1 class="section-title"><%- gettext('Create an Account')%></h1> <% } %> <% } else if (context.autoRegisterWelcomeMessage) { %> <span class="auto-register-message"><%- context.autoRegisterWelcomeMessage %></span> <% } %> - <%= context.fields /* xss-lint: disable=underscore-not-escaped */ %> - - <div class="form-field checkbox-optional_fields_toggle"> - <input type="checkbox" id="toggle_optional_fields" class="input-block checkbox""> - <label for="toggle_optional_fields"> - <span class="label-text-small"> - <%- gettext("Support education research by providing additional information") %> - </span> - </label> - </div> + <div class="form-fields <% if (context.is_require_third_party_auth_enabled) { %>hidden<% } %>"> + <%= context.fields /* xss-lint: disable=underscore-not-escaped */ %> + <div class="form-field checkbox-optional_fields_toggle"> + <input type="checkbox" id="toggle_optional_fields" class="input-block checkbox""> + <label for="toggle_optional_fields"> + <span class="label-text-small"> + <%- gettext("Support education research by providing additional information") %> + </span> + </label> + </div> - <button type="submit" class="action action-primary action-update js-register register-button"> - <% if ( context.registerFormSubmitButtonText ) { %><%- context.registerFormSubmitButtonText %><% } else { %><%- gettext("Create Account") %><% } %> - </button> + <button type="submit" class="action action-primary action-update js-register register-button"> + <% if ( context.registerFormSubmitButtonText ) { %><%- context.registerFormSubmitButtonText %><% } else { %><%- gettext("Create Account") %><% } %> + </button> + </div> </form> diff --git a/openedx/core/djangoapps/user_authn/toggles.py b/openedx/core/djangoapps/user_authn/toggles.py new file mode 100644 index 0000000000000000000000000000000000000000..3b1cd9c9f2a6351ee90a11b83fdb7a9480f4a44b --- /dev/null +++ b/openedx/core/djangoapps/user_authn/toggles.py @@ -0,0 +1,23 @@ +""" +Toggles for user_authn +""" + + +from django.conf import settings + +# .. toggle_name: ENABLE_REQUIRE_THIRD_PARTY_AUTH +# .. toggle_implementation: DjangoSetting +# .. toggle_default: False +# .. toggle_description: Set to True to prevent using username/password login and registration and only allow authentication with third party auth +# .. toggle_category: admin +# .. toggle_use_cases: open_edx +# .. toggle_creation_date: 2020-09-16 +# .. toggle_expiration_date: None +# .. toggle_tickets: None +# .. toggle_status: supported +# .. toggle_warnings: Requires configuration of third party auth + + +def is_require_third_party_auth_enabled(): + # TODO: Replace function with SettingToggle when it is available. + return getattr(settings, "ENABLE_REQUIRE_THIRD_PARTY_AUTH", False) diff --git a/openedx/core/djangoapps/user_authn/views/login.py b/openedx/core/djangoapps/user_authn/views/login.py index 9ce5ec70306c11cfd0dab712bd1b0672b54a3f05..605079da57027258d11a0883d7915542990c5089 100644 --- a/openedx/core/djangoapps/user_authn/views/login.py +++ b/openedx/core/djangoapps/user_authn/views/login.py @@ -14,7 +14,7 @@ from django.contrib.auth import login as django_login from django.contrib.auth.decorators import login_required from django.contrib.auth.models import User from django.contrib import admin -from django.http import HttpRequest, HttpResponse +from django.http import HttpRequest, HttpResponse, HttpResponseForbidden from django.shortcuts import redirect from django.urls import reverse from django.utils.decorators import method_decorator @@ -36,6 +36,7 @@ from openedx.core.djangoapps.user_authn.exceptions import AuthFailedError from openedx.core.djangoapps.user_authn.utils import should_redirect_to_logistration_mircrofrontend 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.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 @@ -451,6 +452,10 @@ def login_user(request): set_custom_attribute('login_user_course_id', request.POST.get('course_id')) + if is_require_third_party_auth_enabled() and not third_party_auth_requested: + return HttpResponseForbidden( + "Third party authentication is required to login. Username and password were received instead." + ) try: if third_party_auth_requested and not first_party_auth_requested: # The user has already authenticated via third-party auth and has not diff --git a/openedx/core/djangoapps/user_authn/views/login_form.py b/openedx/core/djangoapps/user_authn/views/login_form.py index 1cd92e6ac0841e6f01cf43e3d9e9af64dea032ae..0f453a8ad44e8cc89693dfd93c2b6ce662b2e66f 100644 --- a/openedx/core/djangoapps/user_authn/views/login_form.py +++ b/openedx/core/djangoapps/user_authn/views/login_form.py @@ -28,6 +28,7 @@ from openedx.core.djangoapps.user_authn.utils import should_redirect_to_logistra from openedx.core.djangoapps.user_authn.views.password_reset import get_password_reset_form from openedx.core.djangoapps.user_authn.views.registration_form import RegistrationFormFactory from openedx.core.djangoapps.user_authn.views.utils import third_party_auth_context +from openedx.core.djangoapps.user_authn.toggles import is_require_third_party_auth_enabled from openedx.features.enterprise_support.api import enterprise_customer_for_request from openedx.features.enterprise_support.utils import ( get_enterprise_slug_login_url, @@ -231,7 +232,8 @@ def login_and_registration_form(request, initial_mode="login"): 'ALLOW_PUBLIC_ACCOUNT_CREATION', settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION', True)), 'is_account_recovery_feature_enabled': is_secondary_email_feature_enabled(), 'is_multiple_user_enterprises_feature_enabled': is_multiple_user_enterprises_feature_enabled(), - 'enterprise_slug_login_url': get_enterprise_slug_login_url() + 'enterprise_slug_login_url': get_enterprise_slug_login_url(), + 'is_require_third_party_auth_enabled': is_require_third_party_auth_enabled(), }, 'login_redirect_url': redirect_to, # This gets added to the query string of the "Sign In" button in header 'responsive': True, diff --git a/openedx/core/djangoapps/user_authn/views/register.py b/openedx/core/djangoapps/user_authn/views/register.py index 6ba5d10e63c2f6d1d69188041bf55b4581474e55..ea86ef56c89dd5d99b43e34b029a1871eed516cd 100644 --- a/openedx/core/djangoapps/user_authn/views/register.py +++ b/openedx/core/djangoapps/user_authn/views/register.py @@ -58,6 +58,7 @@ from openedx.core.djangoapps.user_authn.views.registration_form import ( RegistrationFormFactory, get_registration_extension_form ) +from openedx.core.djangoapps.user_authn.toggles import is_require_third_party_auth_enabled from common.djangoapps.student.helpers import ( AccountValidationError, authenticate_new_user, @@ -488,6 +489,12 @@ class RegistrationView(APIView): address already exists HttpResponse: 403 operation not allowed """ + if is_require_third_party_auth_enabled() and not pipeline.running(request): + # if request is not running a third-party auth pipeline + return HttpResponseForbidden( + "Third party authentication is required to register. Username and password were received instead." + ) + data = request.POST.copy() self._handle_terms_of_service(data) diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_login.py b/openedx/core/djangoapps/user_authn/views/tests/test_login.py index 72840863dd2bd2633370a895da4e4a3ac28f0415..c1bd99b4639d93f8fd4d258906ef98b7c5b052b2 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_login.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_login.py @@ -19,7 +19,7 @@ from django.test.client import Client from django.test.utils import override_settings from django.urls import NoReverseMatch, reverse from edx_toggles.toggles.testutils import override_waffle_switch -from mock import patch +from mock import Mock, patch from common.djangoapps.student.tests.factories import RegistrationFactory, UserFactory, UserProfileFactory from openedx.core.djangoapps.password_policy.compliance import ( @@ -82,6 +82,25 @@ class LoginTest(SiteMixin, CacheIsolationTestCase): FEATURES_WITH_LOGIN_MFE_ENABLED = settings.FEATURES.copy() FEATURES_WITH_LOGIN_MFE_ENABLED['ENABLE_LOGISTRATION_MICROFRONTEND'] = True + @patch.dict(settings.FEATURES, { + "ENABLE_THIRD_PARTY_AUTH": True + }) + @patch( + 'openedx.core.djangoapps.user_authn.views.login.is_require_third_party_auth_enabled', + Mock(return_value=True) + ) + @skip_unless_lms + def test_public_login_failure_with_only_third_part_auth_enabled(self): + response, _ = self._login_response( + self.user_email, + self.password, + ) + self.assertEqual(response.status_code, 403) + self.assertEqual( + response.content, + b"Third party authentication is required to login. Username and password were received instead." + ) + @ddt.data( # Default redirect is dashboard. { diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_register.py b/openedx/core/djangoapps/user_authn/views/tests/test_register.py index 2ca881337ca700c142638b0b335c9ad1eaa9ac44..f078bc18f2195e81df48035cad0676bd81d67a5f 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_register.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_register.py @@ -93,6 +93,28 @@ class RegistrationViewValidationErrorTest(ThirdPartyAuthTestMixin, UserAPITestCa super(RegistrationViewValidationErrorTest, self).setUp() self.url = reverse("user_api_registration") + @mock.patch.dict(settings.FEATURES, { + "ENABLE_THIRD_PARTY_AUTH": True, + }) + @mock.patch( + 'openedx.core.djangoapps.user_authn.views.register.is_require_third_party_auth_enabled', + mock.Mock(return_value=True) + ) + def test_register_public_account_with_only_third_party_auth_failure(self): + # fails to register for public user if only third party auth is allowed + response = self.client.post(self.url, { + "email": self.EMAIL, + "name": self.NAME, + "username": self.USERNAME, + "password": self.PASSWORD, + "honor_code": "true", + }) + self.assertEqual(response.status_code, 403) + self.assertEqual( + response.content, + b"Third party authentication is required to register. Username and password were received instead." + ) + def test_register_retired_email_validation_error(self): # Register the first user response = self.client.post(self.url, {