diff --git a/.env.production.sample b/.env.production.sample index 15004b9d0d9..d11c65aeaa4 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -109,3 +109,6 @@ FETCH_REPLIES_MAX_SINGLE=500 # Max number of replies Collection pages to fetch - total FETCH_REPLIES_MAX_PAGES=500 + +# MFA Required for Users +REQUIRE_MULTI_FACTOR_AUTH=false \ No newline at end of file diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 82d9e8380fc..f785874e1c3 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -14,6 +14,7 @@ class ApplicationController < ActionController::Base include DatabaseHelper include AuthorizedFetchHelper include SelfDestructHelper + include MfaForceConcern helper_method :current_account helper_method :current_session diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index fc430544fbe..1383e6ac78e 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -18,6 +18,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController skip_before_action :check_self_destruct!, only: [:edit, :update] skip_before_action :require_functional!, only: [:edit, :update] + skip_before_action :check_mfa_requirement, only: [:edit, :update] def new super(&:build_invite_request) @@ -100,12 +101,13 @@ class Auth::RegistrationsController < Devise::RegistrationsController end end - private - def set_invite @invite = begin - invite = Invite.find_by(code: invite_code) if invite_code.present? - invite if invite&.valid_for_use? + if invite_code.present? + Invite.find_by(code: invite_code) + elsif params[:invite_code].present? + Invite.find_by(code: params[:invite_code]) + end end end @@ -132,15 +134,13 @@ class Auth::RegistrationsController < Devise::RegistrationsController def require_rules_acceptance! return if @rules.empty? || (session[:accept_token].present? && params[:accept] == session[:accept_token]) - @accept_token = session[:accept_token] = SecureRandom.hex - @invite_code = invite_code - - set_locale { render :rules } + session[:accept_token] = SecureRandom.hex(16) + redirect_to new_user_registration_path(accept: session[:accept_token]) end def is_flashing_format? # rubocop:disable Naming/PredicatePrefix if params[:action] == 'create' - false # Disable flash messages for sign-up + false else super end diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index 182f242ae5b..78073a49dc4 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -11,6 +11,7 @@ class Auth::SessionsController < Devise::SessionsController skip_before_action :require_no_authentication, only: [:create] skip_before_action :require_functional! skip_before_action :update_user_sign_in + skip_before_action :check_mfa_requirement, only: [:destroy] around_action :preserve_stored_location, only: :destroy, if: :continue_after? @@ -199,12 +200,8 @@ class Auth::SessionsController < Devise::SessionsController def respond_to_on_destroy respond_to do |format| - format.json do - render json: { - redirect_to: after_sign_out_path_for(resource_name), - }, status: 200 - end - format.all { super } + format.any(*navigational_formats) { redirect_to after_sign_out_path_for(:user) } + format.all { head 204 } end end end diff --git a/app/controllers/auth/setup_controller.rb b/app/controllers/auth/setup_controller.rb index 5e7b14646a0..519452d9d9b 100644 --- a/app/controllers/auth/setup_controller.rb +++ b/app/controllers/auth/setup_controller.rb @@ -8,6 +8,7 @@ class Auth::SetupController < ApplicationController before_action :set_user skip_before_action :require_functional! + skip_before_action :check_mfa_requirement def show; end diff --git a/app/controllers/concerns/mfa_force_concern.rb b/app/controllers/concerns/mfa_force_concern.rb new file mode 100644 index 00000000000..ca68d3649c9 --- /dev/null +++ b/app/controllers/concerns/mfa_force_concern.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module MfaForceConcern + extend ActiveSupport::Concern + + included do + prepend_before_action :check_mfa_requirement, if: :user_signed_in? + end + + private + + def check_mfa_requirement + return unless mfa_force_enabled? + return if current_user.otp_enabled? + + flash[:alert] = I18n.t('require_multi_factor_auth.required_message') + redirect_to settings_otp_authentication_path + end + + def mfa_force_enabled? + mfa_config[:force_enabled] + end + + def mfa_config + @mfa_config ||= Rails.application.config_for(:mfa) + end +end diff --git a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb index eae990e79b2..00b2c68ccaf 100644 --- a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb +++ b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb @@ -6,6 +6,7 @@ module Settings include ChallengableConcern skip_before_action :require_functional! + skip_before_action :check_mfa_requirement before_action :require_challenge! before_action :ensure_otp_secret diff --git a/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb b/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb index ca8d46afe48..9e03fcdd354 100644 --- a/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb +++ b/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb @@ -6,6 +6,7 @@ module Settings include ChallengableConcern skip_before_action :require_functional! + skip_before_action :check_mfa_requirement before_action :verify_otp_not_enabled, only: [:show] before_action :require_challenge!, only: [:create] diff --git a/app/controllers/settings/two_factor_authentication_methods_controller.rb b/app/controllers/settings/two_factor_authentication_methods_controller.rb index a6d5c1fe2dd..0face326e49 100644 --- a/app/controllers/settings/two_factor_authentication_methods_controller.rb +++ b/app/controllers/settings/two_factor_authentication_methods_controller.rb @@ -6,6 +6,7 @@ module Settings skip_before_action :check_self_destruct! skip_before_action :require_functional! + skip_before_action :check_mfa_requirement before_action :require_challenge!, only: :disable before_action :require_otp_enabled diff --git a/config/locales/en.yml b/config/locales/en.yml index ebbb72fb073..b90a1a1c920 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2151,3 +2151,7 @@ en: not_supported: This browser doesn't support security keys otp_required: To use security keys please enable two-factor authentication first. registered_on: Registered on %{date} + + require_multi_factor_auth: + required_message: The administrator of this site has configured as mandatory that users enable two-factor authentication due to security policies. Please configure your two-factor authentication to continue using the platform. + security_policy: Security Policy Requirement diff --git a/config/locales/es.yml b/config/locales/es.yml index 93b8bb8817e..8082cf7dcae 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -2149,4 +2149,4 @@ es: not_enabled: Aún no has activado WebAuthn not_supported: Este navegador no soporta claves de seguridad otp_required: Para usar claves de seguridad, por favor habilite primero la autenticación de doble factor. - registered_on: Registrado el %{date} + registered_on: Registrado el %{date} \ No newline at end of file diff --git a/config/mfa.yml b/config/mfa.yml new file mode 100644 index 00000000000..9e365ec9cea --- /dev/null +++ b/config/mfa.yml @@ -0,0 +1,15 @@ +# Multi-Factor Authentication configuration +default: &default + force_enabled: false + +development: + <<: *default + force_enabled: <%= ENV.fetch('REQUIRE_MULTI_FACTOR_AUTH', 'false') == 'true' %> + +test: + <<: *default + force_enabled: false + +production: + <<: *default + force_enabled: <%= ENV.fetch('REQUIRE_MULTI_FACTOR_AUTH', 'false') == 'true' %> diff --git a/spec/controllers/concerns/mfa_force_concern_spec.rb b/spec/controllers/concerns/mfa_force_concern_spec.rb new file mode 100644 index 00000000000..4181f5d8c19 --- /dev/null +++ b/spec/controllers/concerns/mfa_force_concern_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe MfaForceConcern do + controller(ApplicationController) do + def index + render plain: 'OK' + end + end + + let(:user) { Fabricate(:user) } + + before do + routes.draw { get 'index' => 'anonymous#index' } + end + + describe 'MFA force functionality' do + context 'when REQUIRE_MULTI_FACTOR_AUTH is enabled' do + before do + ClimateControl.modify(REQUIRE_MULTI_FACTOR_AUTH: 'true') do + sign_in user, scope: :user + end + end + + context 'when user has MFA enabled' do + before do + user.update(otp_required_for_login: true) + end + + it 'allows access to normal pages' do + ClimateControl.modify(REQUIRE_MULTI_FACTOR_AUTH: 'true') do + get :index + expect(response).to have_http_status(200) + end + end + end + + context 'when user does not have MFA enabled' do + before do + user.update(otp_required_for_login: false) + end + + it 'redirects to MFA setup page' do + ClimateControl.modify(REQUIRE_MULTI_FACTOR_AUTH: 'true') do + get :index + expect(response).to redirect_to(settings_otp_authentication_path) + end + end + + it 'shows the required message' do + ClimateControl.modify(REQUIRE_MULTI_FACTOR_AUTH: 'true') do + get :index + expect(flash[:alert]).to eq(I18n.t('require_multi_factor_auth.required_message')) + end + end + + context 'when accessing MFA setup pages' do + it 'allows access to OTP authentication page' do + ClimateControl.modify(REQUIRE_MULTI_FACTOR_AUTH: 'true') do + allow(controller.request).to receive(:path).and_return('/settings/otp_authentication') + get :index + expect(response).to have_http_status(200) + end + end + + it 'allows access to MFA confirmation page' do + ClimateControl.modify(REQUIRE_MULTI_FACTOR_AUTH: 'true') do + allow(controller.request).to receive(:path).and_return('/settings/two_factor_authentication/confirmation') + get :index + expect(response).to have_http_status(200) + end + end + + it 'allows access to logout' do + ClimateControl.modify(REQUIRE_MULTI_FACTOR_AUTH: 'true') do + allow(controller.request).to receive(:path).and_return('/auth/sign_out') + get :index + expect(response).to have_http_status(200) + end + end + end + end + end + + context 'when REQUIRE_MULTI_FACTOR_AUTH is disabled' do + before do + ClimateControl.modify(REQUIRE_MULTI_FACTOR_AUTH: 'false') do + sign_in user, scope: :user + user.update(otp_required_for_login: false) + end + end + + it 'allows access to normal pages' do + ClimateControl.modify(REQUIRE_MULTI_FACTOR_AUTH: 'false') do + get :index + expect(response).to have_http_status(200) + end + end + end + + context 'when user is not signed in' do + it 'allows access to normal pages' do + ClimateControl.modify(REQUIRE_MULTI_FACTOR_AUTH: 'true') do + get :index + expect(response).to have_http_status(200) + end + end + end + end +end