diff --git a/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 index 662eb31adc1..36baea72a9e 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 @@ -39,6 +39,26 @@ export default Ember.Controller.extend({ return findAll().length > 0; }, + @computed( + "siteSettings.enforce_second_factor", + "currentUser", + "currentUser.second_factor_enabled", + "currentUser.staff" + ) + showEnforcedNotice( + enforce_second_factor, + user, + second_factor_enabled, + staff + ) { + return ( + user && + !second_factor_enabled && + (enforce_second_factor === "all" || + (enforce_second_factor === "staff" && staff)) + ); + }, + toggleSecondFactor(enable) { if (!this.get("secondFactorToken")) return; this.set("loading", true); diff --git a/app/assets/javascripts/discourse/routes/preferences-second-factor.js.es6 b/app/assets/javascripts/discourse/routes/preferences-second-factor.js.es6 index 4bfa3a84918..703bcf32c37 100644 --- a/app/assets/javascripts/discourse/routes/preferences-second-factor.js.es6 +++ b/app/assets/javascripts/discourse/routes/preferences-second-factor.js.es6 @@ -13,5 +13,28 @@ export default RestrictedUserRoute.extend({ setupController(controller, model) { controller.setProperties({ model, newUsername: model.get("username") }); + }, + + actions: { + willTransition(transition) { + this._super(...arguments); + + const controller = this.controllerFor("preferences/second-factor"); + const user = controller.get("currentUser"); + const settings = controller.get("siteSettings"); + + if ( + transition.targetName === "preferences.second-factor" || + !user || + user.second_factor_enabled || + (settings.enforce_second_factor === "staff" && !user.staff) || + settings.enforce_second_factor === "no" + ) { + return true; + } + + transition.abort(); + return false; + } } }); diff --git a/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs b/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs index ad50e770957..5fefc9f1ea4 100644 --- a/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs +++ b/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs @@ -1,6 +1,14 @@
+ {{#if showEnforcedNotice}} +
+
+
{{i18n 'user.second_factor.enforced_notice'}}
+
+
+ {{/if}} + {{#if errorMessage}}
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index e870e2bdc0a..1f1de4778a7 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -685,10 +685,9 @@ class ApplicationController < ActionController::Base end def redirect_to_login_if_required - return if current_user || (request.format.json? && is_api?) - - if SiteSetting.login_required? + return if request.format.json? && is_api? + if !current_user && SiteSetting.login_required? flash.keep dont_cache_page @@ -704,6 +703,18 @@ class ApplicationController < ActionController::Base redirect_to path("/login") end end + + if current_user && + !current_user.totp_enabled? && + !request.format.json? && + !is_api? && + ((SiteSetting.enforce_second_factor == 'staff' && current_user.staff?) || + SiteSetting.enforce_second_factor == 'all') + redirect_path = "#{GlobalSetting.relative_url_root}/u/#{current_user.username}/preferences/second-factor" + if !request.fullpath.start_with?(redirect_path) + redirect_to path(redirect_path) + end + end end def block_if_readonly_mode diff --git a/app/controllers/extra_locales_controller.rb b/app/controllers/extra_locales_controller.rb index d0e4884fcc4..72e1cd38d5f 100644 --- a/app/controllers/extra_locales_controller.rb +++ b/app/controllers/extra_locales_controller.rb @@ -3,7 +3,7 @@ class ExtraLocalesController < ApplicationController layout :false - skip_before_action :check_xhr, :preload_json + skip_before_action :check_xhr, :preload_json, :redirect_to_login_if_required def show bundle = params[:bundle] diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 10e3d2f13d2..154e6e19051 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -810,6 +810,7 @@ en: Two factor authentication adds extra security to your account by requiring a one-time token in addition to your password. Tokens can be generated on Android and iOS devices. oauth_enabled_warning: "Please note that social logins will be disabled once two factor authentication has been enabled on your account." use: "Use Authenticator app" + enforced_notice: "You are required to enable two factor authentication before accessing this site." change_about: title: "Change About Me" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index cf60082ddfd..61d3777b15d 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1339,6 +1339,7 @@ en: notification_email: "The from: email address used when sending all essential system emails. The domain specified here must have SPF, DKIM and reverse PTR records set correctly for email to arrive." email_custom_headers: "A pipe-delimited list of custom email headers" email_subject: "Customizable subject format for standard emails. See https://meta.discourse.org/t/customize-subject-format-for-standard-emails/20801" + enforce_second_factor: "Forces users to enable second factor authentication. Select 'all' to enforce it to all users. Select 'staff' to enforce it to staff users only." force_https: "Force your site to use HTTPS only. WARNING: do NOT enable this until you verify HTTPS is fully set up and working absolutely everywhere! Did you check your CDN, all social logins, and any external logos / dependencies to make sure they are all HTTPS compatible, too?" same_site_cookies: "Use same site cookies, they eliminate all vectors Cross Site Request Forgery on supported browsers (Lax or Strict). Warning: Strict will only work on sites that force login and use SSO." summary_score_threshold: "The minimum score required for a post to be included in 'Summarize This Topic'" diff --git a/config/site_settings.yml b/config/site_settings.yml index a2c4f3148b7..45860c63a6b 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -1220,6 +1220,14 @@ trust: default: true security: + enforce_second_factor: + client: true + type: enum + default: 'no' + choices: + - 'no' + - 'staff' + - 'all' force_https: default: false shadowed_by_global: true diff --git a/spec/requests/application_controller_spec.rb b/spec/requests/application_controller_spec.rb index 494752c446c..06a2719519c 100644 --- a/spec/requests/application_controller_spec.rb +++ b/spec/requests/application_controller_spec.rb @@ -20,6 +20,63 @@ RSpec.describe ApplicationController do end end + describe '#redirect_to_second_factor_if_required' do + let(:admin) { Fabricate(:admin) } + let(:user) { Fabricate(:user) } + + before do + admin # to skip welcome wizard at home page `/` + end + + it "should redirect admins when enforce_second_factor is 'all'" do + SiteSetting.enforce_second_factor = "all" + sign_in(admin) + + get "/" + expect(response).to redirect_to("/u/#{admin.username}/preferences/second-factor") + end + + it "should redirect users when enforce_second_factor is 'all'" do + SiteSetting.enforce_second_factor = "all" + sign_in(user) + + get "/" + expect(response).to redirect_to("/u/#{user.username}/preferences/second-factor") + end + + it "should redirect admins when enforce_second_factor is 'staff'" do + SiteSetting.enforce_second_factor = "staff" + sign_in(admin) + + get "/" + expect(response).to redirect_to("/u/#{admin.username}/preferences/second-factor") + end + + it "should not redirect users when enforce_second_factor is 'staff'" do + SiteSetting.enforce_second_factor = "staff" + sign_in(user) + + get "/" + expect(response.status).to eq(200) + end + + it "should not redirect admins when turned off" do + SiteSetting.enforce_second_factor = "no" + sign_in(admin) + + get "/" + expect(response.status).to eq(200) + end + + it "should not redirect users when turned off" do + SiteSetting.enforce_second_factor = "no" + sign_in(user) + + get "/" + expect(response.status).to eq(200) + end + end + describe 'invalid request params' do before do @old_logger = Rails.logger diff --git a/test/javascripts/acceptance/enforce-second-factor-test.js.es6 b/test/javascripts/acceptance/enforce-second-factor-test.js.es6 new file mode 100644 index 00000000000..dac89e66a29 --- /dev/null +++ b/test/javascripts/acceptance/enforce-second-factor-test.js.es6 @@ -0,0 +1,51 @@ +import { acceptance, replaceCurrentUser } from "helpers/qunit-helpers"; + +acceptance("Enforce Second Factor", { + loggedIn: true +}); + +QUnit.test("as an admin", async assert => { + await visit("/u/eviltrout/preferences/second-factor"); + Discourse.SiteSettings.enforce_second_factor = "staff"; + + await visit("/u/eviltrout/summary"); + + assert.equal( + find(".control-label").text(), + "Password", + "it will not transition from second-factor preferences" + ); + + await click("#toggle-hamburger-menu"); + await click("a.admin-link"); + + assert.equal( + find(".control-label").text(), + "Password", + "it stays at second-factor preferences" + ); +}); + +QUnit.test("as a user", async assert => { + replaceCurrentUser({ staff: false, admin: false }); + + await visit("/u/eviltrout/preferences/second-factor"); + Discourse.SiteSettings.enforce_second_factor = "all"; + + await visit("/u/eviltrout/summary"); + + assert.equal( + find(".control-label").text(), + "Password", + "it will not transition from second-factor preferences" + ); + + await click("#toggle-hamburger-menu"); + await click("a.about-link"); + + assert.equal( + find(".control-label").text(), + "Password", + "it stays at second-factor preferences" + ); +});