Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

User Validation on MFA Level #3905

Merged
merged 2 commits into from
Jul 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
16 changes: 16 additions & 0 deletions app/models/concerns/user_multifactor_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ module UserMultifactorMethods
include UserWebauthnMethods

enum mfa_level: { disabled: 0, ui_only: 1, ui_and_api: 2, ui_and_gem_signin: 3 }, _prefix: :mfa

validate :mfa_level_for_enabled_devices
end

def mfa_enabled?
Expand All @@ -20,6 +22,10 @@ def no_mfa_devices?
totp_disabled? && webauthn_disabled?
end

def mfa_devices_present?
!no_mfa_devices?
end

def mfa_gem_signin_authorized?(otp)
return true unless strong_mfa_level?
api_mfa_verified?(otp)
Expand Down Expand Up @@ -73,6 +79,16 @@ def mfa_required?
rubygems.mfa_required.any?
end

def mfa_level_for_enabled_devices
return if correct_mfa_level_set_conditions

errors.add(:mfa_level, :invalid)
end

def correct_mfa_level_set_conditions
(mfa_disabled? && no_mfa_devices?) || (mfa_enabled? && mfa_devices_present?)
end

class_methods do
def without_mfa
where(mfa_level: "disabled")
Expand Down
24 changes: 24 additions & 0 deletions test/factories/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,29 @@
mfa_level { User.mfa_levels["ui_and_api"] }
mfa_recovery_codes { %w[aaa bbb ccc] }
end

trait :disabled do
totp_seed { "" }
mfa_level { User.mfa_levels["disabled"] }
mfa_recovery_codes { [] }
end

trait :ui_only do
totp_seed { "123abc" }
mfa_level { User.mfa_levels["ui_only"] }
mfa_recovery_codes { %w[aaa bbb ccc] }
end

trait :ui_and_api do
totp_seed { "123abc" }
mfa_level { User.mfa_levels["ui_and_api"] }
mfa_recovery_codes { %w[aaa bbb ccc] }
end

trait :ui_and_gem_signin do
totp_seed { "123abc" }
mfa_level { User.mfa_levels["ui_and_gem_signin"] }
mfa_recovery_codes { %w[aaa bbb ccc] }
end
end
end
7 changes: 4 additions & 3 deletions test/integration/gems_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ class GemsSystemTest < SystemTest

test "shows owners without mfa when logged in as owner" do
@user.enable_totp!("some-seed", "ui_and_api")
user_without_mfa = create(:user, mfa_level: "disabled")
user_without_mfa = create(:user)

create(:ownership, rubygem: @rubygem, user: @user)
create(:ownership, rubygem: @rubygem, user: user_without_mfa)
Expand All @@ -116,7 +116,8 @@ class GemsSystemTest < SystemTest

test "show mfa enabled when logged in as owner but everyone has mfa enabled" do
@user.enable_totp!("some-seed", "ui_and_api")
user_with_mfa = create(:user, mfa_level: "ui_only")
user_with_mfa = create(:user)
user_with_mfa.enable_totp!("some-seed", "ui_and_api")

create(:ownership, rubygem: @rubygem, user: @user)
create(:ownership, rubygem: @rubygem, user: user_with_mfa)
Expand All @@ -129,7 +130,7 @@ class GemsSystemTest < SystemTest

test "does not show owners without mfa when not logged in as owner" do
@user.enable_totp!("some-seed", "ui_and_api")
user_without_mfa = create(:user, mfa_level: "disabled")
user_without_mfa = create(:user)

create(:ownership, rubygem: @rubygem, user: @user)
create(:ownership, rubygem: @rubygem, user: user_without_mfa)
Expand Down
11 changes: 8 additions & 3 deletions test/jobs/mfa_usage_stats_job_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@ class MfaUsageStatsJobTest < ActiveJob::TestCase
include StatsD::Instrument::Assertions

setup do
create(:user, mfa_level: :disabled) # non-mfa user
2.times { create(:user, totp_seed: ROTP::Base32.random_base32) } # otp-only users
create(:user) # non-mfa user
2.times { create(:user).enable_totp!(ROTP::Base32.random_base32, :ui_and_api) } # otp-only users
3.times { create(:webauthn_credential, user: create(:user)) } # webauthn-only users
4.times { create(:webauthn_credential, user: create(:user, totp_seed: ROTP::Base32.random_base32)) } # webauthn-and-otp users
# webauthn-and-otp users
4.times do
user = create(:user)
user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api)
create(:webauthn_credential, user: user)
end
end

test "it sends the count of non-MFA users to statsd" do
Expand Down
101 changes: 98 additions & 3 deletions test/models/concerns/user_multifactor_methods_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,101 @@ class UserMultifactorMethodsTest < ActiveSupport::TestCase
@user = create(:user)
end

context "validation" do
context "#mfa_level_for_enabled_devices" do
context "when mfa_level is disabled" do
should "be valid if there no mfa devices" do
assert_predicate @user, :valid?
ericherscovich marked this conversation as resolved.
Show resolved Hide resolved
assert_predicate @user, :no_mfa_devices?
end

should "be invalid if totp is enabled" do
@user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api)
@user.mfa_level = :disabled

refute_predicate @user, :valid?
end

should "be invalid if webauthn is enabled" do
create(:webauthn_credential, user: @user)
@user.mfa_level = :disabled

refute_predicate @user, :valid?
end

should "be invalid if both totp and webauthn are enabled" do
@user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api)
create(:webauthn_credential, user: @user)
@user.mfa_level = :disabled

refute_predicate @user, :valid?
end
end

context "when mfa_level is ui_and_gem_signin" do
should "be invalid if there no mfa devices" do
@user.mfa_level = :ui_and_gem_signin

ericherscovich marked this conversation as resolved.
Show resolved Hide resolved
assert_predicate @user, :no_mfa_devices?
refute_predicate @user, :valid?
end

should "be valid if totp is enabled" do
@user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api)
@user.mfa_level = :ui_and_gem_signin

assert_predicate @user, :valid?
end

should "be valid if webauthn is enabled" do
create(:webauthn_credential, user: @user)
@user.mfa_level = :ui_and_gem_signin

assert_predicate @user, :valid?
end

should "be valid if both totp and webauthn are enabled" do
@user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api)
create(:webauthn_credential, user: @user)
@user.mfa_level = :ui_and_gem_signin

assert_predicate @user, :valid?
end
end

context "when mfa_level is ui_and_api" do
should "be invalid if there no mfa devices" do
@user.mfa_level = :ui_and_api

ericherscovich marked this conversation as resolved.
Show resolved Hide resolved
assert_predicate @user, :no_mfa_devices?
refute_predicate @user, :valid?
end

should "be valid if totp is enabled" do
@user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api)
@user.mfa_level = :ui_and_api

assert_predicate @user, :valid?
end

should "be valid if webauthn is enabled" do
create(:webauthn_credential, user: @user)
@user.mfa_level = :ui_and_api

assert_predicate @user, :valid?
end

should "be valid if both totp and webauthn are enabled" do
@user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api)
create(:webauthn_credential, user: @user)
@user.mfa_level = :ui_and_api

assert_predicate @user, :valid?
end
end
end
end

context "#mfa_enabled" do
should "return true if multifactor auth is not disabled using totp" do
@user.enable_totp!(ROTP::Base32.random_base32, :ui_only)
Expand Down Expand Up @@ -310,8 +405,8 @@ class UserMultifactorMethodsTest < ActiveSupport::TestCase
refute @user.ui_mfa_verified?(ROTP::TOTP.new(ROTP::Base32.random_base32).now)
end

should "return false if the totp_seed is blank" do
@user.update!(totp_seed: nil)
should "return false if the mfa_seed is blank" do
@user.disable_totp!

refute @user.ui_mfa_verified?(ROTP::TOTP.new(ROTP::Base32.random_base32).now)
end
Expand Down Expand Up @@ -405,7 +500,7 @@ class UserMultifactorMethodsTest < ActiveSupport::TestCase

context ".without_mfa" do
setup do
create(:user, mfa_level: :ui_and_api)
create(:user).enable_totp!(ROTP::Base32.random_base32, :ui_and_api)
end

should "return instances without mfa" do
Expand Down
4 changes: 2 additions & 2 deletions test/models/user_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -579,8 +579,8 @@ class UserTest < ActiveSupport::TestCase

context ".without_mfa" do
setup do
create(:user, handle: "has_mfa", mfa_level: "ui_and_api")
create(:user, handle: "no_mfa", mfa_level: "disabled")
create(:user, handle: "has_mfa").enable_totp!(ROTP::Base32.random_base32, :ui_and_api)
create(:user, handle: "no_mfa")
end

should "return only users without mfa" do
Expand Down
5 changes: 3 additions & 2 deletions test/unit/helpers/rubygems_helper_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,9 @@ class RubygemsHelperTest < ActionView::TestCase
end

should "create links to gem owners without mfa" do
with_mfa = create(:user, mfa_level: "ui_and_api")
without_mfa = create_list(:user, 2, mfa_level: "disabled")
with_mfa = create(:user)
with_mfa.enable_totp!(ROTP::Base32.random_base32, :ui_and_api)
without_mfa = create_list(:user, 2)
rubygem = create(:rubygem, owners: [*without_mfa, with_mfa])

expected_links = without_mfa.sort_by(&:id).map do |u|
Expand Down
13 changes: 9 additions & 4 deletions test/unit/mailer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ class MailerTest < ActionMailer::TestCase
end

should "send mail to users with with more than 180M+ downloads and have weak MFA" do
user = create(:user, mfa_level: "ui_only")
user = create(:user)
user.enable_totp!(ROTP::Base32.random_base32, :ui_only)

create(:rubygem, owners: [user], downloads: MIN_DOWNLOADS_FOR_MFA_REQUIRED_POLICY)

perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do
Expand All @@ -64,7 +66,8 @@ class MailerTest < ActionMailer::TestCase
end

should "not send mail to users with with more than 180M+ downloads and have strong MFA" do
user = create(:user, mfa_level: "ui_and_api")
user = create(:user)
user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api)
create(:rubygem, owners: [user], downloads: MIN_DOWNLOADS_FOR_MFA_REQUIRED_POLICY)

perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do
Expand Down Expand Up @@ -108,7 +111,8 @@ class MailerTest < ActionMailer::TestCase
end

should "send mail to users with more than 180M+ downloads and have weak MFA enabled" do
user = create(:user, mfa_level: "ui_only")
user = create(:user)
user.enable_totp!(ROTP::Base32.random_base32, :ui_only)
create(:rubygem, owners: [user], downloads: MIN_DOWNLOADS_FOR_MFA_REQUIRED_POLICY)

perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do
Expand All @@ -126,7 +130,8 @@ class MailerTest < ActionMailer::TestCase
end

should "not send mail to users with more than 180M+ downloads and have strong MFA enabled" do
user = create(:user, mfa_level: "ui_and_api")
user = create(:user)
user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api)
create(:rubygem, owners: [user], downloads: MIN_DOWNLOADS_FOR_MFA_REQUIRED_POLICY)

perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do
Expand Down