diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 5b4ca6b5..a483617e 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -10,6 +10,7 @@ def current_user = nil # TODO: this is a temp hack to fix partials until /backen helper_method :detected_country_alpha2 before_action :invalidate_v1_sessions, :authenticate_identity!, :set_honeybadger_context + before_action :require_two_factor_enrollment! before_action :set_paper_trail_whodunnit before_action :touch_session_last_seen_at @@ -44,6 +45,19 @@ def authenticate_identity! end end + def require_two_factor_enrollment! + return unless identity_signed_in? + return unless current_identity.two_factor_enrollment_required? + + flash[:error] = "Your account requires two-factor authentication. Set up an authenticator app or passkey to continue." + if request.headers["HX-Request"] + response.headers["HX-Redirect"] = security_path + head :ok + else + redirect_to security_path + end + end + def set_honeybadger_context return unless current_identity diff --git a/app/controllers/authorized_applications_controller.rb b/app/controllers/authorized_applications_controller.rb index 085c6354..ccaeec13 100644 --- a/app/controllers/authorized_applications_controller.rb +++ b/app/controllers/authorized_applications_controller.rb @@ -1,6 +1,10 @@ class AuthorizedApplicationsController < ApplicationController include AhoyAnalytics + # Rendered as a panel on the security page, which is the enrollment gate's + # destination — and revoking app access is safe for un-enrolled users. + skip_before_action :require_two_factor_enrollment! + def index @access_tokens = current_identity.access_tokens .includes(:application) diff --git a/app/controllers/backend/identities_controller.rb b/app/controllers/backend/identities_controller.rb index 562b9784..65d31736 100644 --- a/app/controllers/backend/identities_controller.rb +++ b/app/controllers/backend/identities_controller.rb @@ -279,7 +279,7 @@ def set_identity end def identity_params - permitted = [ :first_name, :last_name, :legal_first_name, :legal_last_name, :primary_email, :phone_number, :birthday, :country, :hq_override, :ysws_eligible, :permabanned, :disallow_slack ] + permitted = [ :first_name, :last_name, :legal_first_name, :legal_last_name, :primary_email, :phone_number, :birthday, :country, :hq_override, :ysws_eligible, :permabanned, :disallow_slack, :two_factor_required ] permitted << :can_hq_officialize if current_user&.super_admin? params.require(:identity).permit(permitted) end diff --git a/app/controllers/identities_controller.rb b/app/controllers/identities_controller.rb index 14b5dce0..bc141d27 100644 --- a/app/controllers/identities_controller.rb +++ b/app/controllers/identities_controller.rb @@ -238,6 +238,13 @@ def toggle_2fa return end + # Admin-required 2FA can't be disabled by the user + if current_identity.two_factor_required? + flash[:error] = "Two-factor authentication is required for your account and can't be disabled." + redirect_to security_path + return + end + # Disabling 2FA requires step-up auth redirect_to new_step_up_path(action_type: "disable_2fa") end diff --git a/app/controllers/identity_backup_codes_controller.rb b/app/controllers/identity_backup_codes_controller.rb index 0df60952..76228974 100644 --- a/app/controllers/identity_backup_codes_controller.rb +++ b/app/controllers/identity_backup_codes_controller.rb @@ -1,4 +1,6 @@ class IdentityBackupCodesController < ApplicationController + skip_before_action :require_two_factor_enrollment! + def index @backup_codes = current_identity.backup_codes.active.order(created_at: :desc) diff --git a/app/controllers/identity_sessions_controller.rb b/app/controllers/identity_sessions_controller.rb index e8fcb966..3923ca38 100644 --- a/app/controllers/identity_sessions_controller.rb +++ b/app/controllers/identity_sessions_controller.rb @@ -1,4 +1,6 @@ class IdentitySessionsController < ApplicationController + skip_before_action :require_two_factor_enrollment! + def index @sessions = current_identity.sessions .where(signed_out_at: nil) diff --git a/app/controllers/identity_totps_controller.rb b/app/controllers/identity_totps_controller.rb index 016ed64e..a7a7a5ee 100644 --- a/app/controllers/identity_totps_controller.rb +++ b/app/controllers/identity_totps_controller.rb @@ -1,4 +1,6 @@ class IdentityTotpsController < ApplicationController + skip_before_action :require_two_factor_enrollment! + def index @totp = current_identity.totp diff --git a/app/controllers/identity_webauthn_credentials_controller.rb b/app/controllers/identity_webauthn_credentials_controller.rb index 3bce81fe..efda7abb 100644 --- a/app/controllers/identity_webauthn_credentials_controller.rb +++ b/app/controllers/identity_webauthn_credentials_controller.rb @@ -1,6 +1,8 @@ class IdentityWebauthnCredentialsController < ApplicationController include WebauthnAuthenticatable + skip_before_action :require_two_factor_enrollment! + def index @webauthn_credentials = current_identity.webauthn_credentials.order(created_at: :desc) render layout: request.headers["HX-Request"] ? "htmx" : false diff --git a/app/controllers/logins_controller.rb b/app/controllers/logins_controller.rb index ae6b699f..6df9b0d7 100644 --- a/app/controllers/logins_controller.rb +++ b/app/controllers/logins_controller.rb @@ -8,6 +8,7 @@ class LoginsController < ApplicationController WEBAUTHN_SESSION_KEY = :webauthn_authentication_challenge skip_before_action :authenticate_identity! + skip_before_action :require_two_factor_enrollment! before_action :set_return_to, only: [ :new, :create ] before_action :set_attempt, except: [ :new, :create ] before_action :validate_browser_token, except: [ :new, :create ] diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 0f818c04..0a524963 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,4 +1,6 @@ class SessionsController < ApplicationController + skip_before_action :require_two_factor_enrollment! + def logout flash[:info] = "You've been logged out. Nice seeing you!" sign_out diff --git a/app/controllers/static_pages_controller.rb b/app/controllers/static_pages_controller.rb index a9cf38be..bb1ff069 100644 --- a/app/controllers/static_pages_controller.rb +++ b/app/controllers/static_pages_controller.rb @@ -1,5 +1,6 @@ class StaticPagesController < ApplicationController skip_before_action :authenticate_identity!, only: [ :external_api_docs, :welcome, :oauth_welcome ] + skip_before_action :require_two_factor_enrollment!, only: [ :security ] def home @sso_apps = SAMLService::Entities.service_providers.values.select do |sp| diff --git a/app/controllers/step_up_controller.rb b/app/controllers/step_up_controller.rb index 12715b1c..b19fb2a7 100644 --- a/app/controllers/step_up_controller.rb +++ b/app/controllers/step_up_controller.rb @@ -1,6 +1,8 @@ class StepUpController < ApplicationController include WebauthnAuthenticatable + skip_before_action :require_two_factor_enrollment! + helper_method :step_up_cancel_path WEBAUTHN_SESSION_KEY = :step_up_webauthn_challenge @@ -147,6 +149,11 @@ def complete_step_up(action_type, return_to) case action_type when "remove_totp" totp = current_identity.totp + if current_identity.two_factor_required? && (current_identity.two_factor_methods - [ totp ]).empty? + redirect_to security_path, alert: "Two-factor authentication is required for your account. Add another method before removing this one." + return + end + totp&.destroy TwoFactorMailer.authentication_method_disabled(current_identity).deliver_later @@ -159,6 +166,11 @@ def complete_step_up(action_type, return_to) redirect_to security_path, notice: "Two-factor authentication disabled" when "disable_2fa" + if current_identity.two_factor_required? + redirect_to security_path, alert: "Two-factor authentication is required for your account and can't be disabled." + return + end + current_identity.update!(use_two_factor_authentication: false) TwoFactorMailer.required_authentication_disabled(current_identity).deliver_later consume_step_up! @@ -175,6 +187,10 @@ def complete_step_up(action_type, return_to) when "remove_passkey" credential_id = session.delete(:pending_destroy_credential_id) credential = current_identity.webauthn_credentials.find_by(id: credential_id) if credential_id + if credential && current_identity.two_factor_required? && (current_identity.two_factor_methods - [ credential ]).empty? + redirect_to security_path, alert: "Two-factor authentication is required for your account. Add another method before removing this one." + return + end if credential credential.destroy consume_step_up! diff --git a/app/models/identity.rb b/app/models/identity.rb index ce714e3f..880a557c 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -370,9 +370,13 @@ def has_two_factor_method? = two_factor_methods.any? def primary_two_factor_method = two_factor_methods.first def requires_two_factor? - use_two_factor_authentication? && has_two_factor_method? + (use_two_factor_authentication? || two_factor_required?) && has_two_factor_method? end + # Admin-set override without any enrolled method: the user must enroll before + # they can use HCA (enforced by ApplicationController#require_two_factor_enrollment!) + def two_factor_enrollment_required? = two_factor_required? && !has_two_factor_method? + def legacy_migrated? = legacy_migrated_at.present? def suggested_aadhaar_password diff --git a/app/views/backend/identities/edit.html.erb b/app/views/backend/identities/edit.html.erb index a96741c2..526b46c5 100644 --- a/app/views/backend/identities/edit.html.erb +++ b/app/views/backend/identities/edit.html.erb @@ -42,6 +42,7 @@ + <% super_admin_tool do %> diff --git a/app/views/backend/identities/show.html.erb b/app/views/backend/identities/show.html.erb index b3bc2c74..2033c08b 100644 --- a/app/views/backend/identities/show.html.erb +++ b/app/views/backend/identities/show.html.erb @@ -134,7 +134,8 @@ <% if @identity.came_in_through_adult_program %>adult program<% end %> <% if @identity.developer_mode %>developer<% end %> <% if @identity.disallow_slack %>no slack<% end %> - <% unless @identity.hq_override || @identity.can_hq_officialize || @identity.is_alum || @identity.came_in_through_adult_program || @identity.developer_mode || @identity.disallow_slack %> + <% if @identity.two_factor_required %>2fa required<%= " — not enrolled" unless @identity.has_two_factor_method? %><% end %> + <% unless @identity.hq_override || @identity.can_hq_officialize || @identity.is_alum || @identity.came_in_through_adult_program || @identity.developer_mode || @identity.disallow_slack || @identity.two_factor_required %> none <% end %> diff --git a/app/views/identity_totps/index.html.erb b/app/views/identity_totps/index.html.erb index 12d3ef06..78582c11 100644 --- a/app/views/identity_totps/index.html.erb +++ b/app/views/identity_totps/index.html.erb @@ -18,7 +18,9 @@ <%= t(".require_2fa_title") %>
<%= t(".require_2fa_description") %>
- <% if current_identity.use_two_factor_authentication? %> + <% if current_identity.two_factor_required? %> + <%= t(".required_by_hq") %> + <% elsif current_identity.use_two_factor_authentication? %> <%= button_to t(".disable_requirement"), toggle_2fa_identity_path, method: :post, diff --git a/app/views/static_pages/security.html.erb b/app/views/static_pages/security.html.erb index 50ba0e50..d663862c 100644 --- a/app/views/static_pages/security.html.erb +++ b/app/views/static_pages/security.html.erb @@ -5,6 +5,13 @@<%= t ".enrollment_required_description" %>
+