From 50bb70298cd30170e5572dfd9e7d2914dec3a4cd Mon Sep 17 00:00:00 2001
From: Tobias Macey
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
---
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 7a60ef949b..a5f87a95f4 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 a2aa534c8f..86d703130c 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 78c517eeb5..4c59665f51 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 9d547d3639..7991be104d 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 466c133535..30a61e4610 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 7bd7e5aef3..3953f27393 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 eccdfc8000..c2f9055419 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) %>
<%- gettext("To continue learning with this account, sign in below.") %>
-<% } else { %>
+<% } else if (!context.is_require_third_party_auth_enabled) { %>
<%- gettext("Sign In") %>
<% } %>
@@ -50,9 +50,11 @@
<% } %>
- <%= HtmlUtils.HTML(context.fields) %>
+ <% if (!context.is_require_third_party_auth_enabled) { %>
+ <%= HtmlUtils.HTML(context.fields) %>
-
+
+ <% } %>
<% if ( context.providers.length > 0 && !context.currentProvider) { %>
diff --git a/lms/templates/student_account/register.underscore b/lms/templates/student_account/register.underscore
index aa992e08a9..84cdb09d5c 100644
--- a/lms/templates/student_account/register.underscore
+++ b/lms/templates/student_account/register.underscore
@@ -39,31 +39,34 @@
<% } %>
-
-
- <%- gettext("or create a new one here") %>
-
-
- <% } else { %>
+ <% if (!context.is_require_third_party_auth_enabled) { %>
+
+
+ <%- gettext("or create a new one here") %>
+
+
+ <% } %>
+ <% } else if (!context.is_require_third_party_auth_enabled) { %>
<%- gettext('Create an Account')%>
<% } %>
<% } else if (context.autoRegisterWelcomeMessage) { %>
<%- context.autoRegisterWelcomeMessage %>
<% } %>
- <%= context.fields /* xss-lint: disable=underscore-not-escaped */ %>
+
+ <%= context.fields /* xss-lint: disable=underscore-not-escaped */ %>
-
-
-
-
diff --git a/openedx/core/djangoapps/user_authn/toggles.py b/openedx/core/djangoapps/user_authn/toggles.py
new file mode 100644
index 0000000000..3b1cd9c9f2
--- /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 9ce5ec7030..605079da57 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 1cd92e6ac0..0f453a8ad4 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 6ba5d10e63..ea86ef56c8 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 72840863dd..c1bd99b463 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 2ca881337c..f078bc18f2 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, {