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:
Tobias Macey
2021-01-07 09:55:27 -05:00
committed by GitHub
parent f62b1c417b
commit 50bb70298c
14 changed files with 152 additions and 29 deletions

View File

@@ -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',

View File

@@ -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);

View File

@@ -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);

View File

@@ -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.

View File

@@ -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
}
})
)

View File

@@ -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

View File

@@ -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">

View File

@@ -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>

View 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)

View File

@@ -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

View File

@@ -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,

View File

@@ -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)

View File

@@ -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.
{

View File

@@ -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, {