From 50bb70298cd30170e5572dfd9e7d2914dec3a4cd Mon Sep 17 00:00:00 2001
From: Tobias Macey <tmacey@mit.edu>
Date: Thu, 7 Jan 2021 09:55:27 -0500
Subject: [PATCH] Added a configuration flag to force third party auth (#24789)

This adds a toggle to allow operators to prevent user registration and login via username/password authentication, forcing the platform to only support login and registration using third-party auth such as SAML.

Co-authored-by: Umar Asghar <mrumarasghar@gmail.com>
---
 lms/envs/production.py                        |  2 +
 .../js/spec/student_account/login_spec.js     | 15 +++++++
 .../js/spec/student_account/register_spec.js  | 14 +++++++
 .../js/student_account/views/AccessView.js    |  7 +++-
 .../js/student_account/views/LoginView.js     |  4 +-
 .../js/student_account/views/RegisterView.js  |  6 ++-
 .../student_account/login.underscore          |  8 ++--
 .../student_account/register.underscore       | 41 ++++++++++---------
 openedx/core/djangoapps/user_authn/toggles.py | 23 +++++++++++
 .../core/djangoapps/user_authn/views/login.py |  7 +++-
 .../djangoapps/user_authn/views/login_form.py |  4 +-
 .../djangoapps/user_authn/views/register.py   |  7 ++++
 .../user_authn/views/tests/test_login.py      | 21 +++++++++-
 .../user_authn/views/tests/test_register.py   | 22 ++++++++++
 14 files changed, 152 insertions(+), 29 deletions(-)
 create mode 100644 openedx/core/djangoapps/user_authn/toggles.py

diff --git a/lms/envs/production.py b/lms/envs/production.py
index 7a60ef949bf..a5f87a95f4a 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 a2aa534c8ff..86d703130c8 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 78c517eeb52..4c59665f512 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 9d547d36399..7991be104db 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 466c133535c..30a61e46105 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 7bd7e5aef39..3953f273932 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 eccdfc80001..c2f9055419e 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 aa992e08a9a..84cdb09d5ce 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 00000000000..3b1cd9c9f2a
--- /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 9ce5ec70306..605079da570 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 1cd92e6ac08..0f453a8ad44 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 6ba5d10e63c..ea86ef56c89 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 72840863dd2..c1bd99b4639 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 2ca881337ca..f078bc18f21 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, {
-- 
GitLab