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>
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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-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 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>
|
||||
</div>
|
||||
|
||||
|
||||
<button type="submit" class="action action-primary action-update js-register register-button">
|
||||
<% if ( context.registerFormSubmitButtonText ) { %><%- context.registerFormSubmitButtonText %><% } else { %><%- gettext("Create Account") %><% } %>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
23
openedx/core/djangoapps/user_authn/toggles.py
Normal file
23
openedx/core/djangoapps/user_authn/toggles.py
Normal file
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
{
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user