From f4c621ffe38f44bcca99a112f6b763660959c25c Mon Sep 17 00:00:00 2001 From: umkay Date: Wed, 2 Nov 2016 13:51:56 -0700 Subject: [PATCH] admin: Enable admins to toggle supported auth methods via UI. Add a table to the administration page that will allow realm admins to activate and deactivate the supported authentication methods for that realm. --- frontend_tests/casper_tests/10-admin.js | 46 +++++++++++++++++++ frontend_tests/node_tests/templates.js | 20 ++++++++ static/js/admin.js | 42 +++++++++++++++-- static/js/server_events.js | 3 ++ static/locale/en/translations.json | 3 +- static/templates/admin_tab.handlebars | 1 + .../auth-methods-settings-admin.handlebars | 22 +++++++++ .../admin_auth_methods_list.handlebars | 11 +++++ zerver/lib/actions.py | 15 ++++++ zerver/tests/test_auth_backends.py | 40 +++++++++++++++- zerver/tests/test_events.py | 23 ++++++++++ zerver/tests/tests.py | 1 + zerver/views/__init__.py | 1 + zerver/views/realm.py | 17 +++++-- 14 files changed, 235 insertions(+), 10 deletions(-) create mode 100644 static/templates/auth-methods-settings-admin.handlebars create mode 100644 static/templates/settings/admin_auth_methods_list.handlebars diff --git a/frontend_tests/casper_tests/10-admin.js b/frontend_tests/casper_tests/10-admin.js index bac88e0b0c..6014114353 100644 --- a/frontend_tests/casper_tests/10-admin.js +++ b/frontend_tests/casper_tests/10-admin.js @@ -529,6 +529,52 @@ casper.waitUntilVisible('#admin-realm-default-language-status', function () { casper.test.assertSelectorHasText('#admin-realm-default-language-status', 'Default language changed!'); }); +// Test authentication methods setting +casper.waitForSelector('input[type="checkbox"]', function () { + casper.click(".method_row[data-method='Email'] input[type='checkbox']"); + casper.click('form.admin-realm-form input.button'); +}); + +// Test setting was activated--default is checked +casper.then(function () { + // Scroll to bottom so that casper snapshots show the auth methods table + this.scrollToBottom(); + // Test setting was activated + casper.waitUntilVisible('#admin-realm-authentication-methods-status', function () { + casper.test.assertSelectorHasText('#admin-realm-authentication-methods-status', 'Authentication methods saved!'); + casper.test.assertEval(function () { + return !(document.querySelector(".method_row[data-method='Email'] input[type='checkbox']").checked); + }); + }); +}); + +casper.then(function () { + // Leave the page and return + casper.click('#settings-dropdown'); + casper.click('a[href^="#subscriptions"]'); + casper.click('#settings-dropdown'); + casper.click('a[href^="#administration"]'); + + casper.waitForSelector(".method_row[data-method='Email'] input[type='checkbox']", function () { + // Test Setting was saved + casper.test.assertEval(function () { + return !(document.querySelector(".method_row[data-method='Email'] input[type='checkbox']").checked); + }); + }); +}); + +// Deactivate setting--default is checked +casper.then(function () { + casper.click(".method_row[data-method='Email'] input[type='checkbox']"); + casper.click('form.admin-realm-form input.button'); + casper.waitUntilVisible('#admin-realm-authentication-methods-status', function () { + casper.test.assertSelectorHasText('#admin-realm-authentication-methods-status', 'Authentication methods saved!'); + casper.test.assertEval(function () { + return document.querySelector(".method_row[data-method='Email'] input[type='checkbox']").checked; + }); + }); +}); + common.then_log_out(); casper.run(function () { diff --git a/frontend_tests/node_tests/templates.js b/frontend_tests/node_tests/templates.js index 6cb351bfac..190975fc9a 100644 --- a/frontend_tests/node_tests/templates.js +++ b/frontend_tests/node_tests/templates.js @@ -216,6 +216,26 @@ function render(template_name, args) { assert.equal(count.text(), 99); }()); +(function admin_auth_methods_list() { + var args = { + method: { + "method": "Email", + "enabled": false + } + }; + + var html = ''; + html += ''; + html += render('admin_auth_methods_list', args); + html += ''; + + global.write_test_output('admin_auth_methods_list.handlebars', html); + + var method = $(html).find('tr.method_row:first span.method'); + assert.equal(method.text(), 'Email'); + assert.equal(method.is("checked"), false); +}()); + (function bookend() { // Do subscribed/unsubscribed cases here. var args = { diff --git a/static/js/admin.js b/static/js/admin.js index 522ff1e1e5..890dacc8dd 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -174,6 +174,20 @@ exports.reset_realm_default_language = function () { $("#id_realm_default_language").val(page_params.realm_default_language); }; +exports.populate_auth_methods = function (auth_methods) { + var auth_methods_table = $("#admin_auth_methods_table").expectOne(); + auth_methods_table.find('tr.method_row').remove(); + _.each(_.keys(auth_methods).sort(), function (key) { + auth_methods_table.append(templates.render('admin_auth_methods_list', { + method: { + method: key, + enabled: auth_methods[key] + } + })); + }); + loading.destroy_indicator($('#admin_page_auth_methods_loading_indicator')); +}; + function _setup_page() { var domains_string = stringify_list_with_conjunction(page_params.domains, "or"); var atdomains = page_params.domains.slice(); @@ -189,6 +203,7 @@ function _setup_page() { realm_restricted_to_domain: page_params.realm_restricted_to_domain, realm_invite_required: page_params.realm_invite_required, realm_invite_by_admins_only: page_params.realm_invite_by_admins_only, + realm_authentication_methods: page_params.realm_authentication_methods, realm_create_stream_by_admins_only: page_params.realm_create_stream_by_admins_only, realm_allow_message_editing: page_params.realm_allow_message_editing, realm_message_content_edit_limit_minutes: Math.ceil(page_params.realm_message_content_edit_limit_seconds / 60), @@ -202,6 +217,7 @@ function _setup_page() { $("#admin-realm-restricted-to-domain-status").expectOne().hide(); $("#admin-realm-invite-required-status").expectOne().hide(); $("#admin-realm-invite-by-admins-only-status").expectOne().hide(); + $("#admin-realm-authentication-methods-status").expectOne().hide(); $("#admin-realm-create-stream-by-admins-only-status").expectOne().hide(); $("#admin-realm-message-editing-status").expectOne().hide(); $("#admin-realm-default-language-status").expectOne().hide(); @@ -215,6 +231,7 @@ function _setup_page() { loading.make_indicator($('#admin_page_streams_loading_indicator')); loading.make_indicator($('#admin_page_deactivated_users_loading_indicator')); loading.make_indicator($('#admin_page_emoji_loading_indicator')); + loading.make_indicator($('#admin_page_auth_methods_loading_indicator')); // Populate users and bots tables channel.get({ @@ -234,6 +251,9 @@ function _setup_page() { error: failed_listing_streams }); + // Populate authentication methods table + exports.populate_auth_methods(page_params.realm_authentication_methods); + // Populate emoji table exports.populate_emoji(page_params.realm_emoji); exports.update_default_streams_table(); @@ -406,14 +426,16 @@ function _setup_page() { var restricted_to_domain_status = $("#admin-realm-restricted-to-domain-status").expectOne(); var invite_required_status = $("#admin-realm-invite-required-status").expectOne(); var invite_by_admins_only_status = $("#admin-realm-invite-by-admins-only-status").expectOne(); + var authentication_methods_status = $("#admin-realm-authentication-methods-status").expectOne(); var create_stream_by_admins_only_status = $("#admin-realm-create-stream-by-admins-only-status").expectOne(); var message_editing_status = $("#admin-realm-message-editing-status").expectOne(); var default_language_status = $("#admin-realm-default-language-status").expectOne(); - + var auth_methods_table = $('.admin_auth_methods_table'); name_status.hide(); restricted_to_domain_status.hide(); invite_required_status.hide(); invite_by_admins_only_status.hide(); + authentication_methods_status.hide(); create_stream_by_admins_only_status.hide(); message_editing_status.hide(); default_language_status.hide(); @@ -429,7 +451,10 @@ function _setup_page() { var new_allow_message_editing = $("#id_realm_allow_message_editing").prop("checked"); var new_message_content_edit_limit_minutes = $("#id_realm_message_content_edit_limit_minutes").val(); var new_default_language = $("#id_realm_default_language").val(); - + var new_auth_methods = {}; + _.each($("#admin_auth_methods_table").find('tr.method_row'), function (method_row) { + new_auth_methods[$(method_row).data('method')] = $(method_row).find('input').prop('checked'); + }); // If allow_message_editing is unchecked, message_content_edit_limit_minutes // is irrelevant. Hence if allow_message_editing is unchecked, and // message_content_edit_limit_minutes is poorly formed, we set the latter to @@ -448,6 +473,7 @@ function _setup_page() { restricted_to_domain: JSON.stringify(new_restricted), invite_required: JSON.stringify(new_invite), invite_by_admins_only: JSON.stringify(new_invite_by_admins_only), + authentication_methods: JSON.stringify(new_auth_methods), create_stream_by_admins_only: JSON.stringify(new_create_stream_by_admins_only), allow_message_editing: JSON.stringify(new_allow_message_editing), message_content_edit_limit_seconds: JSON.stringify(parseInt(new_message_content_edit_limit_minutes, 10) * 60), @@ -489,6 +515,11 @@ function _setup_page() { ui.report_success(i18n.t("Any user may now create new streams!"), create_stream_by_admins_only_status); } } + if (response_data.authentication_methods !== undefined) { + if (response_data.authentication_methods) { + ui.report_success(i18n.t("Authentication methods saved!"), authentication_methods_status); + } + } if (response_data.allow_message_editing !== undefined) { // We expect message_content_edit_limit_seconds was sent in the // response as well @@ -515,7 +546,12 @@ function _setup_page() { } }, error: function (xhr, error) { - ui.report_error(i18n.t("Failed!"), xhr, name_status); + var reason = $.parseJSON(xhr.responseText).reason; + if (reason === "no authentication") { + ui.report_error(i18n.t("Failed!"), xhr, authentication_methods_status); + } else { + ui.report_error(i18n.t("Failed!"), xhr, name_status); + } } }); }); diff --git a/static/js/server_events.js b/static/js/server_events.js index 079cfcb2f5..9e05dd75af 100644 --- a/static/js/server_events.js +++ b/static/js/server_events.js @@ -64,6 +64,9 @@ function dispatch_normal_event(event) { $.each(event.data, function (key, value) { page_params['realm_' + key] = value; }); + if (event.data.authentication_methods !== undefined) { + admin.populate_auth_methods(event.data.authentication_methods); + } } else if (event.op === 'update' && event.property === 'default_language') { page_params.realm_default_language = event.value; admin.reset_realm_default_language(); diff --git a/static/locale/en/translations.json b/static/locale/en/translations.json index 749669df8f..cdf57aa79d 100644 --- a/static/locale/en/translations.json +++ b/static/locale/en/translations.json @@ -49,7 +49,8 @@ "Narrow to private messages with __message.sender_full_name__": "Narrow to private messages with __message.sender_full_name__", "Pin stream to top
of left sidebar": "Pin stream to top
of left sidebar", "Password": "Password", - "Any user may now invite new users!": "Any user may now invite new users!", + "Any user may now invite new users!": "Any user may now invite new users!", + "Authentication methods saved!":"Authentication methods saved!", "Receive desktop
notifications": "Receive desktop
notifications", "Please specify a stream": "Please specify a stream", "Delete Alert Word": "Delete Alert Word", diff --git a/static/templates/admin_tab.handlebars b/static/templates/admin_tab.handlebars index dbcdab0d97..5a6fac5f47 100644 --- a/static/templates/admin_tab.handlebars +++ b/static/templates/admin_tab.handlebars @@ -27,6 +27,7 @@
{{ partial "organization-settings-admin" }} {{ partial "emoji-settings-admin" }} + {{ partial "auth-methods-settings-admin" }}
{{ partial "user-list-admin" }} diff --git a/static/templates/auth-methods-settings-admin.handlebars b/static/templates/auth-methods-settings-admin.handlebars new file mode 100644 index 0000000000..e355beab5c --- /dev/null +++ b/static/templates/auth-methods-settings-admin.handlebars @@ -0,0 +1,22 @@ +
+
+ {{t "Authentication Methods" }}
+
+
+
+

{{#tr this}}Configure the authentication methods for the __domain__ organization.{{/tr}}

+ + + + + +
{{t "Method" }}{{t "Enabled" }}
+
+
+
+
+ +
+
+
+
diff --git a/static/templates/settings/admin_auth_methods_list.handlebars b/static/templates/settings/admin_auth_methods_list.handlebars new file mode 100644 index 0000000000..ea0e43982a --- /dev/null +++ b/static/templates/settings/admin_auth_methods_list.handlebars @@ -0,0 +1,11 @@ +{{#with method}} + + + {{method}} + + + + + +{{/with}} diff --git a/zerver/lib/actions.py b/zerver/lib/actions.py index b2a8e9d05d..9137a09498 100644 --- a/zerver/lib/actions.py +++ b/zerver/lib/actions.py @@ -437,6 +437,20 @@ def do_set_realm_invite_by_admins_only(realm, invite_by_admins_only): ) send_event(event, active_user_ids(realm)) +def do_set_realm_authentication_methods(realm, authentication_methods): + # type: (Realm, Dict[str, bool]) -> None + for key, value in list(authentication_methods.items()): + index = getattr(realm.authentication_methods, key).number + realm.authentication_methods.set_bit(index, int(value)) + realm.save(update_fields=['authentication_methods']) + event = dict( + type="realm", + op="update_dict", + property='default', + data=dict(authentication_methods=realm.authentication_methods_dict()) + ) + send_event(event, active_user_ids(realm)) + def do_set_realm_create_stream_by_admins_only(realm, create_stream_by_admins_only): # type: (Realm, bool) -> None realm.create_stream_by_admins_only = create_stream_by_admins_only @@ -2842,6 +2856,7 @@ def fetch_initial_state_data(user_profile, event_types, queue_id): state['realm_restricted_to_domain'] = user_profile.realm.restricted_to_domain state['realm_invite_required'] = user_profile.realm.invite_required state['realm_invite_by_admins_only'] = user_profile.realm.invite_by_admins_only + state['realm_authentication_methods'] = user_profile.realm.authentication_methods_dict() state['realm_create_stream_by_admins_only'] = user_profile.realm.create_stream_by_admins_only state['realm_allow_message_editing'] = user_profile.realm.allow_message_editing state['realm_message_content_edit_limit_seconds'] = user_profile.realm.message_content_edit_limit_seconds diff --git a/zerver/tests/test_auth_backends.py b/zerver/tests/test_auth_backends.py index 82c7902668..e916f07fe3 100644 --- a/zerver/tests/test_auth_backends.py +++ b/zerver/tests/test_auth_backends.py @@ -29,7 +29,7 @@ from confirmation.models import Confirmation from zproject.backends import ZulipDummyBackend, EmailAuthBackend, \ GoogleMobileOauth2Backend, ZulipRemoteUserBackend, ZulipLDAPAuthBackend, \ ZulipLDAPUserPopulator, DevAuthBackend, GitHubAuthBackend, ZulipAuthMixin, \ - password_auth_enabled, github_auth_enabled, AUTH_BACKEND_NAME_MAP + dev_auth_enabled, password_auth_enabled, github_auth_enabled, AUTH_BACKEND_NAME_MAP from zerver.views.auth import maybe_send_to_registration @@ -1444,3 +1444,41 @@ class TestMaybeSendToRegistration(ZulipTestCase): confirmation_key = confirmation.confirmation_key self.assertIn('do_confirm/' + confirmation_key, result.url) self.assertEqual(PreregistrationUser.objects.all().count(), 1) + +class TestAdminSetBackends(ZulipTestCase): + + def test_change_enabled_backends(self): + # type: () -> None + # Log in as admin + self.login("iago@zulip.com") + result = self.client_patch("/json/realm", { + 'authentication_methods': ujson.dumps({u'Email': False, u'Dev': True})}) + self.assert_json_success(result) + realm = get_realm('zulip.com') + self.assertFalse(password_auth_enabled(realm)) + self.assertTrue(dev_auth_enabled()) + + def test_disable_all_backends(self): + # type: () -> None + # Log in as admin + self.login("iago@zulip.com") + result = self.client_patch("/json/realm", { + 'authentication_methods' : ujson.dumps({u'Email': False, u'Dev': False})}) + self.assert_json_error(result, 'At least one authentication method must be enabled.', status_code=403) + realm = get_realm('zulip.com') + self.assertTrue(password_auth_enabled(realm)) + self.assertTrue(dev_auth_enabled()) + + def test_supported_backends_only_updated(self): + # type: () -> None + # Log in as admin + self.login("iago@zulip.com") + # Set some supported and unsupported backends + result = self.client_patch("/json/realm", { + 'authentication_methods' : ujson.dumps({u'Email': False, u'Dev': True, u'GitHub': False})}) + self.assert_json_success(result) + realm = get_realm('zulip.com') + # Check that unsupported backend is not enabled + self.assertFalse(github_auth_enabled(realm)) + self.assertTrue(dev_auth_enabled()) + self.assertFalse(password_auth_enabled(realm)) diff --git a/zerver/tests/test_events.py b/zerver/tests/test_events.py index 3e7baf7c04..e21b17b06c 100644 --- a/zerver/tests/test_events.py +++ b/zerver/tests/test_events.py @@ -41,6 +41,7 @@ from zerver.lib.actions import ( do_set_realm_invite_by_admins_only, do_set_realm_message_editing, do_set_realm_default_language, + do_set_realm_authentication_methods, do_update_message, do_update_pointer, do_change_twenty_four_hour_time, @@ -466,6 +467,28 @@ class EventsRegisterTest(ZulipTestCase): error = schema_checker('events[0]', events[0]) self.assert_on_error(error) + def test_change_realm_authentication_methods(self): + # type: () -> None + schema_checker = check_dict([ + ('type', equals('realm')), + ('op', equals('update_dict')), + ('property', equals('default')), + ('data', check_dict([])), + ]) + # Test transitions; any new backends should be tested with T/T/T/F/T + for (auth_method_dict) in \ + ({'Google': True, 'Email': True, 'GitHub': True, 'LDAP': False, 'Dev': False}, + {'Google': True, 'Email': True, 'GitHub': False, 'LDAP': False, 'Dev': False}, + {'Google': True, 'Email': False, 'GitHub': False, 'LDAP': False, 'Dev': False}, + {'Google': True, 'Email': False, 'GitHub': True, 'LDAP': False, 'Dev': False }, + {'Google': False, 'Email': False, 'GitHub': False, 'LDAP': False, 'Dev': True}, + {'Google': False, 'Email': False, 'GitHub': True, 'LDAP': False, 'Dev': True}, + {'Google': False, 'Email': True, 'GitHub': True, 'LDAP': True, 'Dev': False}): + events = self.do_test(lambda: do_set_realm_authentication_methods(self.user_profile.realm, + auth_method_dict)) + error = schema_checker('events[0]', events[0]) + self.assert_on_error(error) + def test_change_realm_invite_by_admins_only(self): # type: () -> None schema_checker = check_dict([ diff --git a/zerver/tests/tests.py b/zerver/tests/tests.py index 836aa9378a..7cd96f9640 100644 --- a/zerver/tests/tests.py +++ b/zerver/tests/tests.py @@ -1825,6 +1825,7 @@ class HomeTest(ZulipTestCase): "product_name", "prompt_for_invites", "realm_allow_message_editing", + "realm_authentication_methods", "realm_create_stream_by_admins_only", "realm_default_language", "realm_default_streams", diff --git a/zerver/views/__init__.py b/zerver/views/__init__.py index f62b919cd5..294f5fdecf 100644 --- a/zerver/views/__init__.py +++ b/zerver/views/__init__.py @@ -581,6 +581,7 @@ def home(request): realm_name = register_ret['realm_name'], realm_invite_required = register_ret['realm_invite_required'], realm_invite_by_admins_only = register_ret['realm_invite_by_admins_only'], + realm_authentication_methods = register_ret['realm_authentication_methods'], realm_create_stream_by_admins_only = register_ret['realm_create_stream_by_admins_only'], realm_allow_message_editing = register_ret['realm_allow_message_editing'], realm_message_content_edit_limit_seconds = register_ret['realm_message_content_edit_limit_seconds'], diff --git a/zerver/views/realm.py b/zerver/views/realm.py index 71219d8c78..259f4524a3 100644 --- a/zerver/views/realm.py +++ b/zerver/views/realm.py @@ -1,7 +1,6 @@ from __future__ import absolute_import from typing import Any, Optional - from django.http import HttpRequest, HttpResponse from django.utils.translation import ugettext as _ @@ -14,11 +13,12 @@ from zerver.lib.actions import ( do_set_realm_message_editing, do_set_realm_restricted_to_domain, do_set_realm_default_language, + do_set_realm_authentication_methods ) from zerver.lib.i18n import get_available_language_codes from zerver.lib.request import has_request_variables, REQ, JsonableError from zerver.lib.response import json_success, json_error -from zerver.lib.validator import check_string, check_list, check_bool +from zerver.lib.validator import check_string, check_dict, check_bool from zerver.models import UserProfile @require_realm_admin @@ -30,12 +30,12 @@ def update_realm(request, user_profile, name=REQ(validator=check_string, default create_stream_by_admins_only=REQ(validator=check_bool, default=None), allow_message_editing=REQ(validator=check_bool, default=None), message_content_edit_limit_seconds=REQ(converter=to_non_negative_int, default=None), - default_language=REQ(validator=check_string, default=None)): - # type: (HttpRequest, UserProfile, Optional[str], Optional[bool], Optional[bool], Optional[bool], Optional[bool], Optional[bool], Optional[int], Optional[str]) -> HttpResponse + default_language=REQ(validator=check_string, default=None), + authentication_methods=REQ(validator=check_dict([]), default=None)): + # type: (HttpRequest, UserProfile, Optional[str], Optional[bool], Optional[bool], Optional[bool], Optional[bool], Optional[bool], Optional[int], Optional[str], Optional[dict]) -> HttpResponse # Validation for default_language if default_language is not None and default_language not in get_available_language_codes(): raise JsonableError(_("Invalid language '%s'" % (default_language,))) - realm = user_profile.realm data = {} # type: Dict[str, Any] if name is not None and realm.name != name: @@ -50,6 +50,13 @@ def update_realm(request, user_profile, name=REQ(validator=check_string, default if invite_by_admins_only is not None and realm.invite_by_admins_only != invite_by_admins_only: do_set_realm_invite_by_admins_only(realm, invite_by_admins_only) data['invite_by_admins_only'] = invite_by_admins_only + if authentication_methods is not None and realm.authentication_methods != authentication_methods: + if True not in list(authentication_methods.values()): + return json_error(_("At least one authentication method must be enabled."), + data={"reason": "no authentication"}, status=403) + else: + do_set_realm_authentication_methods(realm, authentication_methods) + data['authentication_methods'] = authentication_methods if create_stream_by_admins_only is not None and realm.create_stream_by_admins_only != create_stream_by_admins_only: do_set_realm_create_stream_by_admins_only(realm, create_stream_by_admins_only) data['create_stream_by_admins_only'] = create_stream_by_admins_only