From 8d03976154d37dc985307d811e83b4d57a0a75f7 Mon Sep 17 00:00:00 2001 From: Nick Malcolm Date: Fri, 3 Oct 2025 10:50:03 +1300 Subject: [PATCH 01/10] Add a Concern to validate and manipulate the value of email_otp_required_after --- .../concerns/authn/email_otp_enrollment.rb | 55 ++++ app/models/user.rb | 3 + .../authn/email_otp_enrollment_spec.rb | 251 ++++++++++++++++++ spec/models/user_spec.rb | 15 ++ 4 files changed, 324 insertions(+) create mode 100644 app/models/concerns/authn/email_otp_enrollment.rb create mode 100644 spec/models/concerns/authn/email_otp_enrollment_spec.rb diff --git a/app/models/concerns/authn/email_otp_enrollment.rb b/app/models/concerns/authn/email_otp_enrollment.rb new file mode 100644 index 00000000000000..aa8229317c76ea --- /dev/null +++ b/app/models/concerns/authn/email_otp_enrollment.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Authn + module EmailOtpEnrollment + extend ActiveSupport::Concern + + def can_modify_email_otp_enrollment? + email_otp_enrollment_restriction.nil? + end + + def email_otp_enrollment_restriction + return :uses_external_authenticator if password_automatically_set? + return :group_enforcement if require_two_factor_authentication_from_group? + return :instance_enforcement if Gitlab::CurrentSettings.require_two_factor_authentication? + return :future_enforcement if email_otp_required_after&.future? + + # TODO: When Email MFA becomes mandatory, we add the following + # condition. For now, users should be able to turn it on and off. + + nil + end + + # Disables Email OTP as a fallback when 2FA enabled and enforced by + # Group or Instance Policy + # Does not save the record. + def set_email_otp_required_after_based_on_restrictions + return unless otp_required_for_login_changed? + return unless Feature.enabled?(:email_based_mfa, self) + + return unless two_factor_enabled? && has_mandatory_two_factor_policy? + + self.email_otp_required_after = nil + end + + def validate_email_otp_required_after + return unless otp_required_for_login_changed? + return unless Feature.enabled?(:email_based_mfa, self) + + if two_factor_enabled? && has_mandatory_two_factor_policy? && + email_otp_required_after.present? + errors.add(:email_otp_required_after, + _('Email OTP not permitted when 2FA is enforced by a policy')) + end + end + + private + + def has_mandatory_two_factor_policy? + Gitlab::CurrentSettings.require_two_factor_authentication? || + require_two_factor_authentication_from_group? + end + end +end + +::Authn::EmailOtpEnrollment.prepend_mod diff --git a/app/models/user.rb b/app/models/user.rb index 664635aa0bc425..3d238e5814a288 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -36,6 +36,7 @@ class User < ApplicationRecord include Gitlab::InternalEventsTracking include Ci::PipelineScheduleOwnershipValidator include Users::DependentAssociations + include Authn::EmailOtpEnrollment ignore_column %i[role skype], remove_after: '2025-09-18', remove_with: '18.4' @@ -374,12 +375,14 @@ def update_tracked_fields!(request) after_initialize :build_default_user_detail, if: :new_record? before_validation :sanitize_attrs before_validation :ensure_namespace_correct + before_validation :set_email_otp_required_after_based_on_restrictions after_validation :set_username_errors before_save :ensure_incoming_email_token before_save :ensure_user_rights_and_limits, if: ->(user) { user.new_record? || user.external_changed? } before_save :skip_reconfirmation!, if: ->(user) { user.email_changed? && user.read_only_attribute?(:email) } before_save :check_for_verified_email, if: ->(user) { user.email_changed? && !user.new_record? } before_save :ensure_namespace_correct # in case validation is skipped + before_save :validate_email_otp_required_after after_update :username_changed_hook, if: :saved_change_to_username? after_destroy :post_destroy_hook after_destroy :remove_key_cache diff --git a/spec/models/concerns/authn/email_otp_enrollment_spec.rb b/spec/models/concerns/authn/email_otp_enrollment_spec.rb new file mode 100644 index 00000000000000..01dc3c02b82172 --- /dev/null +++ b/spec/models/concerns/authn/email_otp_enrollment_spec.rb @@ -0,0 +1,251 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Authn::EmailOtpEnrollment, feature_category: :system_access do + let(:email_otp_required_after) { Time.current } + let(:user) { create(:user, email_otp_required_after: email_otp_required_after) } + let(:group) { create(:group) } + + before do + stub_feature_flags(email_based_mfa: true) + group.add_developer(user) + end + + describe '#can_modify_email_otp_enrollment?' do + subject { user.can_modify_email_otp_enrollment? } + + it 'returns true when no restriction exists' do + allow(user).to receive(:email_otp_enrollment_restriction).and_return(nil) + is_expected.to be true + end + + it 'returns false when restriction exists' do + allow(user).to receive(:email_otp_enrollment_restriction).and_return(:some_restriction) + is_expected.to be false + end + end + + describe '#email_otp_enrollment_restriction' do + subject { user.email_otp_enrollment_restriction } + + # By default the user is unrestricted and can opt in and out of + # Email-based OTP + it { is_expected.to be_nil } + + context 'when user has email OTP required and has other MFA enabled' do + before do + allow(user).to receive(:two_factor_enabled?).and_return(true) + end + + # No restrictions + it { is_expected.to be_nil } + end + + context 'when user uses an external authenticator and has no GitLab password' do + before do + user.update!(password_automatically_set: true) + end + + it { is_expected.to eq(:uses_external_authenticator) } + end + + context 'when group enforces 2FA', :sidekiq_inline do + before do + group.update!(require_two_factor_authentication: true) + user.reload + end + + it { is_expected.to eq(:group_enforcement) } + end + + context 'when instance enforces 2FA' do + before do + stub_application_setting(require_two_factor_authentication: true) + end + + it { is_expected.to eq(:instance_enforcement) } + end + + context 'when email OTP enforcement is in the future' do + let(:email_otp_required_after) { 1.day.from_now } + + it { is_expected.to eq(:future_enforcement) } + end + end + + describe '#set_email_otp_required_after_based_on_restrictions' do + let(:otp_required_for_login) { false } + + subject(:setter) do + user.otp_required_for_login = otp_required_for_login + user.set_email_otp_required_after_based_on_restrictions + end + + it 'is a no-op when otp_required_for_login has not changed' do + expect { setter }.not_to change { user } + end + + context 'when user enrolls in 2FA' do + # otp_required_for_login maps to the use of App-based TOTP or + # WebAuthN + let(:otp_required_for_login) { true } + + context 'when FF is disabled' do + before do + stub_feature_flags(email_based_mfa: false) + end + + it 'user remains enrolled in email MFA' do + expect { setter }.not_to change { user } + expect(user.email_otp_required_after.past?).to be true + end + end + + context 'when user enables 2FA without mandatory policy' do + it 'user remains enrolled in email MFA' do + expect { setter }.not_to change { user } + expect(user.email_otp_required_after.past?).to be true + end + end + + context 'when user enables 2FA with mandatory group policy', :sidekiq_inline do + before do + group.update!(require_two_factor_authentication: true) + user.reload + end + + it 'disables enrollment in email-based OTP' do + # require 'pry-byebug'; binding.pry + expect { setter }.to change { user.email_otp_required_after }.to nil + end + end + + context 'when user enables 2FA with mandatory instance policy' do + before do + stub_application_setting(require_two_factor_authentication: true) + allow(user).to receive(:two_factor_enabled?).and_return(true) + end + + it 'disables enrollment in email-based OTP' do + expect { setter }.to change { user.email_otp_required_after }.to nil + end + end + end + + context 'when user is enrolled in 2FA and disables it' do + before do + user.update!(otp_required_for_login: true, email_otp_required_after: nil) + end + + it 'allows user to remain opted out of email-based OTP', :freeze_time do + expect { setter }.not_to change { user.email_otp_required_after } + end + end + + context 'when user self enrols in email-based OTP' do + let(:email_otp_required_after) { nil } + + before do + user.email_otp_required_after = Time.current + end + + it 'does not change their enrolment' do + expect { setter }.not_to change { user.email_otp_required_after } + end + end + + context 'when user self un-enrols in email-based OTP' do + before do + user.email_otp_required_after = nil + end + + it 'does not change their enrolment' do + expect { setter }.not_to change { user.email_otp_required_after } + end + end + + it 'does not persist changes to database' do + expect { setter }.not_to change { user.reload.email_otp_required_after } + end + end + + describe '#validate_email_otp_required_after' do + subject(:validate) { user.validate_email_otp_required_after } + + before do + allow(user).to receive_messages(two_factor_enabled?: true, has_mandatory_two_factor_policy?: true) + end + + context 'when otp_required_for_login unchanged' do + it 'does not add error' do + validate + expect(user.errors[:email_otp_required_after]).to be_empty + end + end + + context 'when otp_required_for_login changed' do + before do + user.otp_required_for_login = true + end + + it 'adds an error because email_otp_required_after is present' do + validate + expect(user.errors[:email_otp_required_after]).to be_present + end + + context 'when email_otp_required_after is nil' do + let(:email_otp_required_after) { nil } + + it 'does not add error' do + validate + expect(user.errors[:email_otp_required_after]).to be_empty + end + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(email_based_mfa: false) + end + + it 'does not add error' do + validate + expect(user.errors[:email_otp_required_after]).to be_empty + end + end + end + end + + describe '#has_mandatory_two_factor_policy?' do + subject { user.send(:has_mandatory_two_factor_policy?) } + + it { is_expected.to be false } + + context 'when instance requires 2FA' do + before do + stub_application_setting(require_two_factor_authentication: true) + end + + it { is_expected.to be true } + end + + context 'when group requires 2FA', :sidekiq_inline do + before do + group.update!(require_two_factor_authentication: true) + user.reload + end + + it { is_expected.to be true } + end + + context 'when both instance and group require 2FA' do + before do + stub_application_setting(require_two_factor_authentication: true) + group.add_developer(user) + group.update!(require_two_factor_authentication: true) + end + + it { is_expected.to be true } + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index c5bbfeae2ab063..128ddc39d171c0 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1298,6 +1298,8 @@ expect(user.errors[:composite_identity_enforced]).to include('is not included in the list') end end + + pending 'before_save :validate_email_otp_required_after' end describe 'dashboard enum and methods' do @@ -2052,6 +2054,19 @@ expect(UserDetail.exists?(user.id)).to be(true) end + + describe 'set_email_otp_required_after_based_on_restrictions' do + let(:user) { build_stubbed(:user) } + + before do + allow(user).to receive(:set_email_otp_required_after_based_on_restrictions).and_call_original + end + + it 'runs the validation' do + user.valid? + expect(user).to have_received(:set_email_otp_required_after_based_on_restrictions) + end + end end describe 'before save hook' do -- GitLab From 999c5e9229573051d8000fa192333650d047fa51 Mon Sep 17 00:00:00 2001 From: Nick Malcolm Date: Fri, 3 Oct 2025 10:51:24 +1300 Subject: [PATCH 02/10] Add an Application Setting for email OTP enforcement, but don't expose it to users --- app/models/application_setting.rb | 3 ++- .../application_setting_sign_in_restrictions.json | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index ba82b76bd5c6e4..edf85d4bdf8b23 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -739,7 +739,8 @@ def self.kroki_formats_attributes jsonb_accessor :sign_in_restrictions, disable_password_authentication_for_users_with_sso_identities: [:boolean, { default: false }], root_moved_permanently_redirection: [:boolean, { default: false }], - session_expire_from_init: [:boolean, { default: false }] + session_expire_from_init: [:boolean, { default: false }], + require_minimum_email_based_otp_for_users_with_passwords: [:boolean, { default: false }] validates :sign_in_restrictions, json_schema: { filename: 'application_setting_sign_in_restrictions' } diff --git a/app/validators/json_schemas/application_setting_sign_in_restrictions.json b/app/validators/json_schemas/application_setting_sign_in_restrictions.json index 7ea5ce699180da..d4a4faf3c0713a 100644 --- a/app/validators/json_schemas/application_setting_sign_in_restrictions.json +++ b/app/validators/json_schemas/application_setting_sign_in_restrictions.json @@ -15,6 +15,10 @@ "session_expire_from_init": { "type": "boolean", "description": "When enabled, sessions will expire a specific time after creation even if the session is active. Sessions cannot be extended." + }, + "require_minimum_email_based_otp_for_users_with_passwords": { + "type": "boolean", + "description": "(Experiment) When enabled, users who sign in with a password must use Email-based OTP at a minimum. When other forms of 2FA are enabled, Email-based OTP is optional for them." } } } -- GitLab From f614c6827395c1f195b14ea79ed29635bee9ca53 Mon Sep 17 00:00:00 2001 From: Nick Malcolm Date: Tue, 7 Oct 2025 16:22:33 +1300 Subject: [PATCH 03/10] Refactor and refine the setter and validator, and specs --- .../concerns/authn/email_otp_enrollment.rb | 57 +++- app/models/user.rb | 3 +- .../authn/email_otp_enrollment_spec.rb | 270 ++++++++++++++---- spec/models/user_spec.rb | 17 +- 4 files changed, 275 insertions(+), 72 deletions(-) diff --git a/app/models/concerns/authn/email_otp_enrollment.rb b/app/models/concerns/authn/email_otp_enrollment.rb index aa8229317c76ea..89502c3c8d1a8d 100644 --- a/app/models/concerns/authn/email_otp_enrollment.rb +++ b/app/models/concerns/authn/email_otp_enrollment.rb @@ -10,36 +10,55 @@ def can_modify_email_otp_enrollment? def email_otp_enrollment_restriction return :uses_external_authenticator if password_automatically_set? - return :group_enforcement if require_two_factor_authentication_from_group? - return :instance_enforcement if Gitlab::CurrentSettings.require_two_factor_authentication? + return :group_2fa_enforcement if require_two_factor_authentication_from_group? + return :instance_2fa_enforcement if Gitlab::CurrentSettings.require_two_factor_authentication? return :future_enforcement if email_otp_required_after&.future? - - # TODO: When Email MFA becomes mandatory, we add the following - # condition. For now, users should be able to turn it on and off. + return :two_factor_required if must_require_email_otp? nil end + # Modifies email_otp_required_after to match the user's + # restrictions based on other enabled features. + # # Disables Email OTP as a fallback when 2FA enabled and enforced by - # Group or Instance Policy + # Group or Instance Policy, or enables it when required as a + # minimum. + # # Does not save the record. def set_email_otp_required_after_based_on_restrictions - return unless otp_required_for_login_changed? - return unless Feature.enabled?(:email_based_mfa, self) + return unless otp_required_for_login_changed? || + email_otp_required_after_changed? - return unless two_factor_enabled? && has_mandatory_two_factor_policy? + return unless Feature.enabled?(:email_based_mfa, self) - self.email_otp_required_after = nil + if must_disable_email_otp? + self.email_otp_required_after = nil + elsif must_require_email_otp? + # If the value existed and was being set to nil, set it back. + # Otherwise, set it to now (instant enforcement). + self.email_otp_required_after = email_otp_required_after_was || Time.current + end end + # Validates the presence or absence of email_otp_required_after + # depending on other enabled features. + # This method is the "validate" version of #set_email_otp_required_after_based_on_restrictions + # above. def validate_email_otp_required_after - return unless otp_required_for_login_changed? + return unless otp_required_for_login_changed? || + email_otp_required_after_changed? + return unless Feature.enabled?(:email_based_mfa, self) - if two_factor_enabled? && has_mandatory_two_factor_policy? && - email_otp_required_after.present? + if two_factor_enabled? + if has_mandatory_two_factor_policy? && email_otp_required_after.present? + errors.add(:email_otp_required_after, + _('is not permitted when 2FA is enforced by a policy')) + end + elsif email_otp_required_after.blank? && must_require_email_otp? errors.add(:email_otp_required_after, - _('Email OTP not permitted when 2FA is enforced by a policy')) + _('is required when 2FA disabled')) end end @@ -49,6 +68,16 @@ def has_mandatory_two_factor_policy? Gitlab::CurrentSettings.require_two_factor_authentication? || require_two_factor_authentication_from_group? end + + def must_disable_email_otp? + two_factor_enabled? && has_mandatory_two_factor_policy? + end + + def must_require_email_otp? + !password_automatically_set? && + !two_factor_enabled? && + Gitlab::CurrentSettings.require_minimum_email_based_otp_for_users_with_passwords? + end end end diff --git a/app/models/user.rb b/app/models/user.rb index 3d238e5814a288..e11a24be9e9d82 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -353,6 +353,7 @@ def update_tracked_fields!(request) validate :commit_email_verified, if: :commit_email_changed? validate :email_allowed_by_restrictions, if: ->(user) { user.new_record? ? !user.created_by_id : user.email_changed? } validate :check_username_format, if: :username_changed? + validate :validate_email_otp_required_after validates :theme_id, allow_nil: true, inclusion: { in: Gitlab::Themes.valid_ids, message: ->(*) { _("%{placeholder} is not a valid theme") % { placeholder: '%{value}' } } } @@ -382,7 +383,6 @@ def update_tracked_fields!(request) before_save :skip_reconfirmation!, if: ->(user) { user.email_changed? && user.read_only_attribute?(:email) } before_save :check_for_verified_email, if: ->(user) { user.email_changed? && !user.new_record? } before_save :ensure_namespace_correct # in case validation is skipped - before_save :validate_email_otp_required_after after_update :username_changed_hook, if: :saved_change_to_username? after_destroy :post_destroy_hook after_destroy :remove_key_cache @@ -531,6 +531,7 @@ def should_use_flipped_dashboard_mapping_for_rollout? delegate :bot_namespace, :bot_namespace=, to: :user_detail, allow_nil: true delegate :email_otp, :email_otp=, to: :user_detail, allow_nil: true delegate :email_otp_required_after, :email_otp_required_after=, to: :user_detail, allow_nil: true + delegate :email_otp_required_after_changed?, :email_otp_required_after_was, to: :user_detail delegate :email_otp_last_sent_at, :email_otp_last_sent_at=, to: :user_detail, allow_nil: true delegate :email_otp_last_sent_to, :email_otp_last_sent_to=, to: :user_detail, allow_nil: true diff --git a/spec/models/concerns/authn/email_otp_enrollment_spec.rb b/spec/models/concerns/authn/email_otp_enrollment_spec.rb index 01dc3c02b82172..950e59302bb9e1 100644 --- a/spec/models/concerns/authn/email_otp_enrollment_spec.rb +++ b/spec/models/concerns/authn/email_otp_enrollment_spec.rb @@ -5,11 +5,15 @@ RSpec.describe Authn::EmailOtpEnrollment, feature_category: :system_access do let(:email_otp_required_after) { Time.current } let(:user) { create(:user, email_otp_required_after: email_otp_required_after) } - let(:group) { create(:group) } + let(:group_require_two_factor_authentication) { false } + let(:group) { create(:group, require_two_factor_authentication: group_require_two_factor_authentication) } before do - stub_feature_flags(email_based_mfa: true) + # Adding a user to a group with 2FA requirement triggers + # side-effects that we want to ensure are accounted for. + # Otherwise, we skip #reload for spec performance. group.add_developer(user) + user.reload if group_require_two_factor_authentication end describe '#can_modify_email_otp_enrollment?' do @@ -51,12 +55,9 @@ end context 'when group enforces 2FA', :sidekiq_inline do - before do - group.update!(require_two_factor_authentication: true) - user.reload - end + let(:group_require_two_factor_authentication) { true } - it { is_expected.to eq(:group_enforcement) } + it { is_expected.to eq(:group_2fa_enforcement) } end context 'when instance enforces 2FA' do @@ -64,7 +65,7 @@ stub_application_setting(require_two_factor_authentication: true) end - it { is_expected.to eq(:instance_enforcement) } + it { is_expected.to eq(:instance_2fa_enforcement) } end context 'when email OTP enforcement is in the future' do @@ -72,24 +73,45 @@ it { is_expected.to eq(:future_enforcement) } end + + context 'when instance enforces email OTP as a minimum' do + before do + stub_application_setting(require_minimum_email_based_otp_for_users_with_passwords: true) + end + + it { is_expected.to eq(:two_factor_required) } + + context 'when user has 2FA' do + before do + allow(user).to receive(:two_factor_enabled?).and_return(true) + end + + it { is_expected.to be_nil } + end + end end describe '#set_email_otp_required_after_based_on_restrictions' do - let(:otp_required_for_login) { false } + # otp_required_for_login maps to the use of App-based TOTP or + # WebAuthN + let(:new_otp_required_for_login) { false } + let(:new_email_otp_required_after) { email_otp_required_after } + + before do + user.otp_required_for_login = new_otp_required_for_login + user.email_otp_required_after = new_email_otp_required_after + end subject(:setter) do - user.otp_required_for_login = otp_required_for_login user.set_email_otp_required_after_based_on_restrictions end - it 'is a no-op when otp_required_for_login has not changed' do + it 'is a no-op when otp_required_for_login and email_otp_required_after have not changed' do expect { setter }.not_to change { user } end context 'when user enrolls in 2FA' do - # otp_required_for_login maps to the use of App-based TOTP or - # WebAuthN - let(:otp_required_for_login) { true } + let(:new_otp_required_for_login) { true } context 'when FF is disabled' do before do @@ -110,13 +132,9 @@ end context 'when user enables 2FA with mandatory group policy', :sidekiq_inline do - before do - group.update!(require_two_factor_authentication: true) - user.reload - end + let(:group_require_two_factor_authentication) { true } it 'disables enrollment in email-based OTP' do - # require 'pry-byebug'; binding.pry expect { setter }.to change { user.email_otp_required_after }.to nil end end @@ -133,35 +151,66 @@ end end - context 'when user is enrolled in 2FA and disables it' do - before do - user.update!(otp_required_for_login: true, email_otp_required_after: nil) - end + context 'when user is enrolled in 2FA and disables it', :freeze_time do + let(:email_otp_required_after) { nil } + let(:user) { create(:user, :two_factor, email_otp_required_after: email_otp_required_after) } - it 'allows user to remain opted out of email-based OTP', :freeze_time do + it 'allows user to remain opted out of email-based OTP' do expect { setter }.not_to change { user.email_otp_required_after } end + + context 'when email OTP is required as a minimum' do + before do + stub_application_setting(require_minimum_email_based_otp_for_users_with_passwords: true) + end + + it 'enrolls them in email-based OTP' do + expect { setter }.to change { user.email_otp_required_after }.to Time.current + end + end end context 'when user self enrols in email-based OTP' do let(:email_otp_required_after) { nil } - - before do - user.email_otp_required_after = Time.current - end + let(:new_email_otp_required_after) { Time.current } it 'does not change their enrolment' do expect { setter }.not_to change { user.email_otp_required_after } + user.save! + user.reload + expect(user.email_otp_required_after).to eq(new_email_otp_required_after) end end context 'when user self un-enrols in email-based OTP' do + let(:new_email_otp_required_after) { nil } + + it 'does not change their un-enrolment' do + expect { setter }.not_to change { user.email_otp_required_after } + end + + context 'when email OTP is required as a minimum' do + before do + stub_application_setting(require_minimum_email_based_otp_for_users_with_passwords: true) + end + + it 'does not allow it to be changed and keeps them enrolled in email-based OTP' do + expect { setter }.to change { user.email_otp_required_after }.to email_otp_required_after + end + end + end + + context 'when user enables 2FA AND email OTP at the same time under mandatory policy', :freeze_time do + let(:email_otp_required_after) { nil } + let(:new_otp_required_for_login) { true } + let(:new_email_otp_required_after) { Time.current } + before do - user.email_otp_required_after = nil + stub_application_setting(require_two_factor_authentication: true) end - it 'does not change their enrolment' do - expect { setter }.not_to change { user.email_otp_required_after } + it 'disables email OTP due to mandatory policy taking precedence' do + expect { setter }.to change { user.email_otp_required_after }.to nil end end @@ -171,47 +220,167 @@ end describe '#validate_email_otp_required_after' do - subject(:validate) { user.validate_email_otp_required_after } - before do - allow(user).to receive_messages(two_factor_enabled?: true, has_mandatory_two_factor_policy?: true) + allow(user).to receive(:has_mandatory_two_factor_policy?).and_call_original + allow(user).to receive(:must_require_email_otp?).and_call_original + allow(user).to receive(:two_factor_enabled?).and_call_original + end + + subject(:validation) { user.validate_email_otp_required_after } + + context 'when neither otp_required_for_login nor email_otp_required_after have changed' do + it 'returns early without validating' do + validation + expect(user).not_to have_received(:two_factor_enabled?) + end + end + + context 'when FF is disabled' do + before do + stub_feature_flags(email_based_mfa: false) + end + + it 'returns early without validating' do + user.otp_required_for_login = true + validation + expect(user).not_to have_received(:two_factor_enabled?) + end end - context 'when otp_required_for_login unchanged' do - it 'does not add error' do - validate + context 'with no restrictions' do + it 'allows enabling 2FA with Email OTP off' do + user.otp_required_for_login = true + validation + expect(user.errors[:email_otp_required_after]).to be_empty + end + + it 'allows enabling both 2FA and Email OTP' do + user.otp_required_for_login = true + user.email_otp_required_after = Time.current + + validation + expect(user.errors[:email_otp_required_after]).to be_empty + end + + it 'allows enabling Email OTP without 2FA' do + user.email_otp_required_after = Time.current + + validation expect(user.errors[:email_otp_required_after]).to be_empty end + + context 'with 2FA already enabled' do + before do + user.update!(otp_required_for_login: true) + end + + it 'allows disabling 2FA with Email OTP off' do + user.otp_required_for_login = false + user.email_otp_required_after = nil + + validation + expect(user.errors[:email_otp_required_after]).to be_empty + end + + it 'allows disabling 2FA with Email OTP on' do + user.otp_required_for_login = false + user.email_otp_required_after = Time.current + + validation + expect(user.errors[:email_otp_required_after]).to be_empty + end + end end - context 'when otp_required_for_login changed' do + context 'with mandatory 2FA policy' do before do + allow(user).to receive(:has_mandatory_two_factor_policy?).and_return(true) + end + + it 'allows enabling 2FA with Email OTP off' do user.otp_required_for_login = true + user.email_otp_required_after = nil + + validation + expect(user.errors[:email_otp_required_after]).to be_empty end - it 'adds an error because email_otp_required_after is present' do - validate - expect(user.errors[:email_otp_required_after]).to be_present + it 'prevents Email OTP remaining enabled when 2FA is turned on' do + user.otp_required_for_login = true + user.email_otp_required_after = Time.current + + validation + expect(user.errors[:email_otp_required_after]) + .to include('is not permitted when 2FA is enforced by a policy') end - context 'when email_otp_required_after is nil' do + context 'with 2FA already enabled' do let(:email_otp_required_after) { nil } - it 'does not add error' do - validate - expect(user.errors[:email_otp_required_after]).to be_empty + before do + user.update!(otp_required_for_login: true) + end + + it 'prevents enabling Email OTP' do + user.email_otp_required_after = Time.current + + validation + expect(user.errors[:email_otp_required_after]) + .to include('is not permitted when 2FA is enforced by a policy') end end + end + + context 'with minimum Email OTP requirement' do + before do + allow(user).to receive(:must_require_email_otp?).and_return(true) + end + + it 'allows 2FA on with Email OTP on' do + user.otp_required_for_login = true + user.email_otp_required_after = Time.current + + validation + expect(user.errors[:email_otp_required_after]).to be_empty + end - context 'when feature flag is disabled' do + it 'allows 2FA on with Email OTP off' do + user.otp_required_for_login = true + user.email_otp_required_after = nil + + validation + expect(user.errors[:email_otp_required_after]).to be_empty + end + + it 'prevents disabling Email OTP without 2FA' do + user.email_otp_required_after = nil + + validation + expect(user.errors[:email_otp_required_after]) + .to include('is required when 2FA disabled') + end + + context 'with 2FA already enabled' do before do - stub_feature_flags(email_based_mfa: false) + user.update!(otp_required_for_login: true) end - it 'does not add error' do - validate + it 'allows 2FA off with Email OTP on' do + user.otp_required_for_login = false + user.email_otp_required_after = Time.current + + validation expect(user.errors[:email_otp_required_after]).to be_empty end + + it 'prevents 2FA off with Email OTP off' do + user.otp_required_for_login = false + user.email_otp_required_after = nil + + validation + expect(user.errors[:email_otp_required_after]) + .to include('is required when 2FA disabled') + end end end end @@ -238,10 +407,9 @@ it { is_expected.to be true } end - context 'when both instance and group require 2FA' do + context 'when both instance and group require 2FA', :sidekiq_inline do before do stub_application_setting(require_two_factor_authentication: true) - group.add_developer(user) group.update!(require_two_factor_authentication: true) end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 128ddc39d171c0..3f294a68ed632f 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1299,7 +1299,15 @@ end end - pending 'before_save :validate_email_otp_required_after' + describe 'validate_email_otp_required_after' do + let(:user) { build_stubbed(:user) } + + # This method is tested in spec/models/concerns/authn/email_otp_enrollment_spec.rb + it 'calls validate_email_otp_required_after' do + expect(user).to receive(:validate_email_otp_required_after) + user.valid? + end + end end describe 'dashboard enum and methods' do @@ -2058,13 +2066,10 @@ describe 'set_email_otp_required_after_based_on_restrictions' do let(:user) { build_stubbed(:user) } - before do - allow(user).to receive(:set_email_otp_required_after_based_on_restrictions).and_call_original - end - + # This method is tested in spec/models/concerns/authn/email_otp_enrollment_spec.rb it 'runs the validation' do + expect(user).to receive(:set_email_otp_required_after_based_on_restrictions) user.valid? - expect(user).to have_received(:set_email_otp_required_after_based_on_restrictions) end end end -- GitLab From 0f4ff61d9ae43489b5e198e84af8db5704ea132c Mon Sep 17 00:00:00 2001 From: Nick Malcolm Date: Tue, 7 Oct 2025 16:46:34 +1300 Subject: [PATCH 04/10] Add specs for the new application setting --- spec/models/application_setting_spec.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index 45e1f690fcbec8..ebfdf645ac2b41 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -242,6 +242,7 @@ security_approval_policies_limit: 5, session_expire_delay: Settings.gitlab['session_expire_delay'], session_expire_from_init: false, + require_minimum_email_based_otp_for_users_with_passwords: false, shared_runners_enabled: Settings.gitlab_ci['shared_runners_enabled'], shared_runners_text: nil, show_migrate_from_jenkins_banner: true, @@ -252,7 +253,8 @@ sign_in_restrictions: { 'disable_password_authentication_for_users_with_sso_identities' => false, 'root_moved_permanently_redirection' => false, - 'session_expire_from_init' => false + 'session_expire_from_init' => false, + 'require_minimum_email_based_otp_for_users_with_passwords' => false }, signup_enabled: Settings.gitlab['signup_enabled'], silent_admin_exports_enabled: false, -- GitLab From 2711e1582f9e0c71af0dbed83e6c5d4cac8617eb Mon Sep 17 00:00:00 2001 From: Nick Malcolm Date: Tue, 7 Oct 2025 16:48:28 +1300 Subject: [PATCH 05/10] Add new validation errors to the locale file --- locale/gitlab.pot | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 77bcb15db4b60a..b50f1df284ccf6 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -78650,6 +78650,9 @@ msgstr "" msgid "is not one of" msgstr "" +msgid "is not permitted when 2FA is enforced by a policy" +msgstr "" + msgid "is not valid." msgstr "" @@ -78668,6 +78671,9 @@ msgstr "" msgid "is reserved and cannot be used" msgstr "" +msgid "is required when 2FA disabled" +msgstr "" + msgid "is too large. Maximum size allowed is %{size}" msgstr "" -- GitLab From fac44fa3f77bc0b1a3ecf4dff937211766442808 Mon Sep 17 00:00:00 2001 From: Nick Malcolm Date: Tue, 7 Oct 2025 21:45:52 +1300 Subject: [PATCH 06/10] Freeze time in a spec where we check time --- spec/models/concerns/authn/email_otp_enrollment_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/models/concerns/authn/email_otp_enrollment_spec.rb b/spec/models/concerns/authn/email_otp_enrollment_spec.rb index 950e59302bb9e1..3f2c52ee0c4ab8 100644 --- a/spec/models/concerns/authn/email_otp_enrollment_spec.rb +++ b/spec/models/concerns/authn/email_otp_enrollment_spec.rb @@ -170,7 +170,7 @@ end end - context 'when user self enrols in email-based OTP' do + context 'when user self enrols in email-based OTP', :freeze_time do let(:email_otp_required_after) { nil } let(:new_email_otp_required_after) { Time.current } -- GitLab From ae87633222a106361251f6e45baf4bd1efa2083c Mon Sep 17 00:00:00 2001 From: Nick Malcolm Date: Wed, 8 Oct 2025 10:42:16 +1300 Subject: [PATCH 07/10] Rename an ambiguous email_otp_enrollment_restriction --- app/models/concerns/authn/email_otp_enrollment.rb | 2 +- spec/models/concerns/authn/email_otp_enrollment_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/concerns/authn/email_otp_enrollment.rb b/app/models/concerns/authn/email_otp_enrollment.rb index 89502c3c8d1a8d..3c0b7a414c4a2a 100644 --- a/app/models/concerns/authn/email_otp_enrollment.rb +++ b/app/models/concerns/authn/email_otp_enrollment.rb @@ -13,7 +13,7 @@ def email_otp_enrollment_restriction return :group_2fa_enforcement if require_two_factor_authentication_from_group? return :instance_2fa_enforcement if Gitlab::CurrentSettings.require_two_factor_authentication? return :future_enforcement if email_otp_required_after&.future? - return :two_factor_required if must_require_email_otp? + return :email_otp_required if must_require_email_otp? nil end diff --git a/spec/models/concerns/authn/email_otp_enrollment_spec.rb b/spec/models/concerns/authn/email_otp_enrollment_spec.rb index 3f2c52ee0c4ab8..c5211eaeb003a8 100644 --- a/spec/models/concerns/authn/email_otp_enrollment_spec.rb +++ b/spec/models/concerns/authn/email_otp_enrollment_spec.rb @@ -79,7 +79,7 @@ stub_application_setting(require_minimum_email_based_otp_for_users_with_passwords: true) end - it { is_expected.to eq(:two_factor_required) } + it { is_expected.to eq(:email_otp_required) } context 'when user has 2FA' do before do -- GitLab From 6b9b8696040e7728ecc901aeaa1705cab324af44 Mon Sep 17 00:00:00 2001 From: Nick Malcolm Date: Wed, 8 Oct 2025 12:45:53 +1300 Subject: [PATCH 08/10] Fix setter logic which overwrote values incorrectly --- .../concerns/authn/email_otp_enrollment.rb | 9 ++++-- .../authn/email_otp_enrollment_spec.rb | 32 ++++++++++++++++++- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/app/models/concerns/authn/email_otp_enrollment.rb b/app/models/concerns/authn/email_otp_enrollment.rb index 3c0b7a414c4a2a..c91f4c989f5fbc 100644 --- a/app/models/concerns/authn/email_otp_enrollment.rb +++ b/app/models/concerns/authn/email_otp_enrollment.rb @@ -35,9 +35,12 @@ def set_email_otp_required_after_based_on_restrictions if must_disable_email_otp? self.email_otp_required_after = nil elsif must_require_email_otp? - # If the value existed and was being set to nil, set it back. - # Otherwise, set it to now (instant enforcement). - self.email_otp_required_after = email_otp_required_after_was || Time.current + # The user must be enrolled. We use, in order: + # - The new value if it's not nil + # - The old value via *_was if it's not nil + # - Now (instance enforcement) as the final option + # Any value, including a date in the future, is permitted. + self.email_otp_required_after = email_otp_required_after || email_otp_required_after_was || Time.current end end diff --git a/spec/models/concerns/authn/email_otp_enrollment_spec.rb b/spec/models/concerns/authn/email_otp_enrollment_spec.rb index c5211eaeb003a8..4ac3a2c48e36aa 100644 --- a/spec/models/concerns/authn/email_otp_enrollment_spec.rb +++ b/spec/models/concerns/authn/email_otp_enrollment_spec.rb @@ -174,7 +174,19 @@ let(:email_otp_required_after) { nil } let(:new_email_otp_required_after) { Time.current } - it 'does not change their enrolment' do + it 'does not prevent their enrolment' do + expect { setter }.not_to change { user.email_otp_required_after } + user.save! + user.reload + expect(user.email_otp_required_after).to eq(new_email_otp_required_after) + end + end + + context 'when user is not enrolled and sets a future-dated email-based OTP', :freeze_time do + let(:email_otp_required_after) { nil } + let(:new_email_otp_required_after) { 60.days.from_now } + + it 'does not prevent their future enrolment' do expect { setter }.not_to change { user.email_otp_required_after } user.save! user.reload @@ -217,6 +229,24 @@ it 'does not persist changes to database' do expect { setter }.not_to change { user.reload.email_otp_required_after } end + + context 'with minimum Email OTP requirement' do + before do + allow(user).to receive(:must_require_email_otp?).and_return(true) + end + + context 'when user is not enrolled and sets a future-dated email-based OTP', :freeze_time do + let(:email_otp_required_after) { nil } + let(:new_email_otp_required_after) { 60.days.from_now } + + it 'does not prevent their future enrolment' do + expect { setter }.not_to change { user.email_otp_required_after } + user.save! + user.reload + expect(user.email_otp_required_after).to eq(new_email_otp_required_after) + end + end + end end describe '#validate_email_otp_required_after' do -- GitLab From cbcfbe83cb542bb3d17251d1f1c9f026bc0ec615 Mon Sep 17 00:00:00 2001 From: Nick Malcolm Date: Wed, 8 Oct 2025 13:25:36 +1300 Subject: [PATCH 09/10] Fix locale/gitlab.pot translation order --- locale/gitlab.pot | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index b50f1df284ccf6..a44ce62e02495d 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -78668,10 +78668,10 @@ msgstr "" msgid "is read-only" msgstr "" -msgid "is reserved and cannot be used" +msgid "is required when 2FA disabled" msgstr "" -msgid "is required when 2FA disabled" +msgid "is reserved and cannot be used" msgstr "" msgid "is too large. Maximum size allowed is %{size}" -- GitLab From e2c035511d472b0180a5c570012ce922fbe1bf2c Mon Sep 17 00:00:00 2001 From: Nick Malcolm Date: Fri, 10 Oct 2025 13:14:10 +1300 Subject: [PATCH 10/10] Address reviewer feedback - Add a module comment - Moving the shared precondition logic to a single method - Add comments about why the setter and validate don't have an "else" clause - Remove the EE `prepend_mod` as it isn't needed - Improve wording of an error message --- .../concerns/authn/email_otp_enrollment.rb | 31 ++++++++++--------- locale/gitlab.pot | 2 +- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/app/models/concerns/authn/email_otp_enrollment.rb b/app/models/concerns/authn/email_otp_enrollment.rb index c91f4c989f5fbc..7090bbf9135a64 100644 --- a/app/models/concerns/authn/email_otp_enrollment.rb +++ b/app/models/concerns/authn/email_otp_enrollment.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true module Authn + # This module uses ActiveRecord callbacks to ensure that Two Factor + # Authentication methods align with requirements placed on the user, + # regardless of whether a user is created or updated directly, or via + # a Service. module EmailOtpEnrollment extend ActiveSupport::Concern @@ -27,21 +31,19 @@ def email_otp_enrollment_restriction # # Does not save the record. def set_email_otp_required_after_based_on_restrictions - return unless otp_required_for_login_changed? || - email_otp_required_after_changed? - - return unless Feature.enabled?(:email_based_mfa, self) + return unless enrollment_check_required? if must_disable_email_otp? self.email_otp_required_after = nil - elsif must_require_email_otp? + elsif must_require_email_otp? && email_otp_required_after.blank? # The user must be enrolled. We use, in order: - # - The new value if it's not nil # - The old value via *_was if it's not nil # - Now (instance enforcement) as the final option # Any value, including a date in the future, is permitted. - self.email_otp_required_after = email_otp_required_after || email_otp_required_after_was || Time.current + self.email_otp_required_after = email_otp_required_after_was || Time.current end + # If neither condition match, email_otp_required_after is not + # an invalid value. end # Validates the presence or absence of email_otp_required_after @@ -49,10 +51,7 @@ def set_email_otp_required_after_based_on_restrictions # This method is the "validate" version of #set_email_otp_required_after_based_on_restrictions # above. def validate_email_otp_required_after - return unless otp_required_for_login_changed? || - email_otp_required_after_changed? - - return unless Feature.enabled?(:email_based_mfa, self) + return unless enrollment_check_required? if two_factor_enabled? if has_mandatory_two_factor_policy? && email_otp_required_after.present? @@ -61,12 +60,18 @@ def validate_email_otp_required_after end elsif email_otp_required_after.blank? && must_require_email_otp? errors.add(:email_otp_required_after, - _('is required when 2FA disabled')) + _('is required when 2FA is disabled')) end + # If neither condition match, the user is in a valid state. end private + def enrollment_check_required? + (otp_required_for_login_changed? || email_otp_required_after_changed?) && + Feature.enabled?(:email_based_mfa, self) + end + def has_mandatory_two_factor_policy? Gitlab::CurrentSettings.require_two_factor_authentication? || require_two_factor_authentication_from_group? @@ -83,5 +88,3 @@ def must_require_email_otp? end end end - -::Authn::EmailOtpEnrollment.prepend_mod diff --git a/locale/gitlab.pot b/locale/gitlab.pot index a44ce62e02495d..400f2f99705867 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -78668,7 +78668,7 @@ msgstr "" msgid "is read-only" msgstr "" -msgid "is required when 2FA disabled" +msgid "is required when 2FA is disabled" msgstr "" msgid "is reserved and cannot be used" -- GitLab