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

LG-14261 Add attempt count to mfa setup auth events #11293

Open
wants to merge 45 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 40 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
76dcf08
changelog: Internal, MFA setup, Add attempt count to MFA setup analyt…
kevinsmaster5 Sep 24, 2024
c6402f9
add piv attempts, use common name in analytics
kevinsmaster5 Sep 25, 2024
ca53ef1
add logging to otp and totp setup
kevinsmaster5 Sep 26, 2024
6542fe3
reset attempts count on success
kevinsmaster5 Sep 26, 2024
587da36
add totp reset and refactor reset on webauthn
kevinsmaster5 Sep 26, 2024
ce6927a
update otp specs
kevinsmaster5 Sep 26, 2024
a5b8825
update totp specs
kevinsmaster5 Sep 26, 2024
afb95c5
update piv_cac_setup specs
kevinsmaster5 Sep 26, 2024
f9e035d
add otp spec that confirms incremented mfa_attempts analytics log
kevinsmaster5 Sep 27, 2024
563173a
add specs for piv and totp
kevinsmaster5 Sep 27, 2024
c93c566
refactor slightly session key setter
kevinsmaster5 Sep 30, 2024
34cc178
add testing for attempts on webauthn setup
kevinsmaster5 Sep 30, 2024
34f553c
refactor pulling commonly use function into mfa setup concern
kevinsmaster5 Oct 3, 2024
73ecdc8
move mfa attempts to 2fa methods concern, change to user_session
kevinsmaster5 Oct 4, 2024
45fd975
equip otp with mfa attempt logging at authentication
kevinsmaster5 Oct 7, 2024
8dd9cd6
update spec because of session token change, add attempt count to web…
kevinsmaster5 Oct 7, 2024
961f06b
add mfa attempt count for totp authentication
kevinsmaster5 Oct 7, 2024
ff1564c
add mfa count for piv authentication
kevinsmaster5 Oct 7, 2024
c5d3fbe
clear user_session token when changing mfa after a failed attempt
kevinsmaster5 Oct 7, 2024
0572f73
add params to piv analytics event
kevinsmaster5 Oct 7, 2024
d5a4d20
fix piv verification spec. reset mfa account for setup failure
kevinsmaster5 Oct 7, 2024
54f296b
express auth attempts as a hash consisting of attempt count and method
kevinsmaster5 Oct 8, 2024
f57e9cd
group all attempts into a hash
kevinsmaster5 Oct 10, 2024
a692ecf
sync rspec up with changes made
kevinsmaster5 Oct 10, 2024
ab1fe0e
add mfa attempt to event expectation
kevinsmaster5 Oct 10, 2024
5d92095
add mfa attempt to event expectation
kevinsmaster5 Oct 10, 2024
25a70ff
fixes specs to catch missing events
kevinsmaster5 Oct 11, 2024
c6f08f0
fix webauthn spec to correct user flow
kevinsmaster5 Oct 11, 2024
a97a0ac
fix mfa label in spec
kevinsmaster5 Oct 11, 2024
02ec7f7
remove private_key gsub
kevinsmaster5 Oct 15, 2024
097a114
leverage symbols for mfa methods, remove no longer needed method from…
kevinsmaster5 Oct 15, 2024
1dcea81
revise spec to use symbol
kevinsmaster5 Oct 15, 2024
e1ae532
address keypath warnings from spec
kevinsmaster5 Oct 15, 2024
9500700
fix how otp verification controller generates the attempts count, upd…
kevinsmaster5 Oct 15, 2024
1800e9b
gsub personal_key for mfa_attempts
kevinsmaster5 Oct 17, 2024
39b18a9
convert to sym
kevinsmaster5 Oct 17, 2024
6528f70
convert key to sym with correct method
kevinsmaster5 Oct 17, 2024
1f9ae4c
rename incrementing method param. repair rspec to correct exptected mfa
kevinsmaster5 Oct 17, 2024
da9afdf
fix rspec expected mfa types
kevinsmaster5 Oct 17, 2024
65f3232
place gsub behind a conditional
kevinsmaster5 Oct 17, 2024
b92b9a6
set up a programmatic way of protecting pii keys in sessions
kevinsmaster5 Oct 18, 2024
b9b2843
refactor increment verb
kevinsmaster5 Oct 18, 2024
ee762d5
fix broken logic
kevinsmaster5 Oct 18, 2024
7a58848
put expected attempt key back
kevinsmaster5 Oct 18, 2024
2f17d79
add testing for change to session_encryptor
kevinsmaster5 Oct 18, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions app/controllers/concerns/two_factor_authenticatable_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,24 @@ def auth_methods_session
end

def handle_verification_for_authentication_context(result:, auth_method:, extra_analytics: nil)
mfa_selection_attempt_count(auth_method)
analytics.multi_factor_auth(
**result.to_h,
multi_factor_auth_method: auth_method,
enabled_mfa_methods_count: mfa_context.enabled_mfa_methods_count,
new_device: new_device?,
**extra_analytics.to_h,
mfa_attempts: user_session[:mfa_attempts],
pii_like_keypaths: [
[:mfa_attempts, :otp],
[:errors, :personal_key],
[:error_details, :personal_key],
Comment on lines +25 to +26
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we log errors.personal_key and error_details.personal_key here? I'm surprised this would come up just now.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was in response to an error I was getting. I can take another look for more clarity.

Copy link
Contributor Author

@kevinsmaster5 kevinsmaster5 Oct 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was in the same spec that was exhibiting the session sensitive key error
./spec/features/legacy_passwords_spec.rb:61

  1. legacy passwords signing in with an incorrect uak personal key digest does not grant access
    Failure/Error:
    raise PiiDetected, <<~ERROR
    track_event received pii key path: #{current_keypath.inspect}
    event: #{event} (#{constant_name})
    full event: #{attributes.inspect}
    allowlisted keypaths: #{pii_like_keypaths.inspect}
    ERROR

    FakeAnalytics::PiiDetected:
    track_event received pii key path: [:errors, :personal_key]
    event: Multi-Factor Authentication ()
    full event: {:success=>false, :errors=>{:personal_key=>["Incorrect personal key"]}, :error_details=>{:personal_key=>{:personal_key_incorrect=>true}}, :new_device=>true, :mfa_attempts=>{"personal_key"=>1}, :multi_factor_auth_method=>"personal-key", :enabled_mfa_methods_count=>1}
    allowlisted keypaths: [[:mfa_attempts, :otp], [:error_details, :personal_key]]

],
)

if result.success?
handle_valid_verification_for_authentication_context(auth_method:)
user_session.delete(:mfa_attempts)
else
handle_invalid_verification_for_authentication_context
end
Expand Down Expand Up @@ -124,6 +132,18 @@ def handle_remember_device_preference(remember_device_preference)
save_remember_device_preference(remember_device_preference)
end

def mfa_selection_attempt_count(increment_auth_method)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My previous suggestion was to rename the method name to include a verb action, not the argument name.

Something like:

Suggested change
def mfa_selection_attempt_count(increment_auth_method)
def increment_mfa_selection_attempt_count(auth_method)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops. Fixed that in b9b2843

user_session[:mfa_attempts] ||= {}
if increment_auth_method == 'personal_key'
increment_auth_method = increment_auth_method.gsub(
'personal_key',
'personal-key',
)
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Continuing the previous discussion about trying to exempt from SessionEncryptor errors, the suggestion to explore pii_like_keypaths might not work so well since it's not implemented by default, but moreso because SessionEncryptor is concerned with the session and not the log detail.

Instead, I wonder if we could create a set of "safe" paths, following the existing pattern of the SENSITIVE_PATHS in SessionEncryptor, which would be exempt from the default error handling.

I played around with this locally and this seemed to work okay:

class SessionEncryptor
  # ...

  # Paths which would trigger sensitive key detection, but which don't contain PII
  SAFE_PATHS = [
    ['warden.user.user.session', 'mfa_attempts', 'personal_key'],
  ].freeze

  # ...

  def alert_or_raise_if_contains_sensitive_keys!(hash)
    except_nested(hash, SAFE_PATHS).deep_transform_keys do |key|
      # ...
    end
  end

  def except_nested(hash, key_paths)
    hash = hash.deep_dup
    key_paths.each do |key_path|
      nested_hash = hash.dig(*key_path[..-2])
      nested_hash&.delete!(key_path.last)
    end
    hash
  end
end
    it 'does not raise if exempted PII key appears' do
      session = { 'warden.user.user.session' => { 'mfa_attempts' => { 'personal_key' => 1 } } }
      session = session.deep_transform_values(&:freeze)

      expect { subject.dump(session) }.not_to raise_error(SessionEncryptor::SensitiveKeyError)
    end

    it 'maintains all values from the original session' do
      session = {
        'warden.user.user.session' => {
          'idv_new' => { 'nested' => {} },
          'mfa_attempts' => { 'personal_key' => 1 },
        },
      }

      dumped = subject.dump(session.deep_dup)
      loaded = subject.load(dumped)

      expect(loaded).to eq(session)
    end

It's starting to get a bit complex and I'd have some reservations about the performance impact of extra logic to deep-duplicate the session hash for every request on a session that's very unlikely to ever contain the value we care about. But I also don't feel very good about the renaming approach, since it seems to be intentionally circumventing the intent of the session encryptor in identifying a "personal key" key within the session.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another thought, if we're okay to just increment the count of attempts for the current method but want to ensure that we reset when switching to another method, we could track it something like...

user_session = {
  mfa_attempts: { attempts: 0, auth_method: 'personal_key' }
}

...then make sure to reset attempts if the auth method is different from what's in the session

user_session[:mfa_attempts][:attempts] = 0 if user_session[:mfa_attempts][:auth_method] != auth_method

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made a run at this before I saw your comment with b92b9a6
I wanted to try to make it work programmatically and not hard-code the session key so I ended up adding another session value. Not sure if that's a great approach though. It doesn't work exactly the same as how

if pii_attr_names.include?(key) && !pii_like_keypaths.include?(current_keypath)
was solved however.

user_session[:mfa_attempts][increment_auth_method] ||= 0
user_session[:mfa_attempts][increment_auth_method] += 1
end

# Method will be renamed in the next refactor.
# You can pass in any "type" with a corresponding I18n key in
# two_factor_authentication.invalid_#{type}
Expand Down
aduth marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ def show
end

def create
mfa_selection_attempt_count(:otp) if UserSessionContext.confirmation_context?(context)
result = otp_verification_form.submit
post_analytics(result)

Expand All @@ -42,6 +43,7 @@ def create
end

reset_otp_session_data
user_session.delete(:mfa_attempts)
else
handle_invalid_otp(type: 'otp')
end
Expand Down Expand Up @@ -156,6 +158,12 @@ def analytics_properties
phone_configuration_id: phone_configuration&.id,
in_account_creation_flow: user_session[:in_account_creation_flow] || false,
enabled_mfa_methods_count: mfa_context.enabled_mfa_methods_count,
mfa_attempts: user_session[:mfa_attempts],
pii_like_keypaths: [
[:mfa_attempts, :otp],
[:errors, :personal_key],
[:error_details, :personal_key],
],
}
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ def create
result:,
auth_method: TwoFactorAuthenticatable::AuthMethod::TOTP,
)

if result.success?
handle_remember_device_preference(params[:remember_device])
redirect_to after_sign_in_path_for(current_user)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,13 @@ def piv_cac_service_url_with_redirect
end

def process_piv_cac_setup
mfa_selection_attempt_count(:piv_cac)
result = user_piv_cac_form.submit
properties = result.to_h.merge(analytics_properties)
analytics.multi_factor_auth_setup(**properties)
if result.success?
process_valid_submission
user_session.delete(:mfa_attempts)
else
process_invalid_submission
end
Expand Down Expand Up @@ -126,6 +128,7 @@ def analytics_properties
{
in_account_creation_flow: user_session[:in_account_creation_flow] || false,
enabled_mfa_methods_count: mfa_context.enabled_mfa_methods_count,
mfa_attempts: user_session[:mfa_attempts],
}
end

Expand Down
4 changes: 3 additions & 1 deletion app/controllers/users/totp_setup_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,13 @@ def new

def confirm
result = totp_setup_form.submit

mfa_selection_attempt_count(:totp)
properties = result.to_h.merge(analytics_properties)
analytics.multi_factor_auth_setup(**properties)

if result.success?
process_valid_code
user_session.delete(:mfa_attempts)
else
process_invalid_code
end
Expand Down Expand Up @@ -118,6 +119,7 @@ def analytics_properties
{
in_account_creation_flow: in_account_creation_flow?,
pii_like_keypaths: [[:mfa_method_counts, :phone]],
mfa_attempts: user_session[:mfa_attempts],
}
end
end
Expand Down
3 changes: 3 additions & 0 deletions app/controllers/users/webauthn_setup_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ def new
end

flash_error(result.errors) unless result.success?
mfa_selection_attempt_count(:webauthn)
end

def confirm
Expand All @@ -74,6 +75,7 @@ def confirm

if result.success?
process_valid_webauthn(form)
user_session.delete(:mfa_attempts)
else
flash.now[:error] = result.first_error_message
render :new
Expand Down Expand Up @@ -141,6 +143,7 @@ def process_valid_webauthn(form)
def analytics_properties
{
in_account_creation_flow: user_session[:in_account_creation_flow] || false,
mfa_attempts: user_session[:mfa_attempts],
}
end

Expand Down
21 changes: 20 additions & 1 deletion app/services/analytics_events.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4945,6 +4945,7 @@ def logout_initiated(
# @param [Boolean] new_device Whether the user is authenticating from a new device
# @param [String] multi_factor_auth_method Authentication method used
# @param [String] multi_factor_auth_method_created_at When the authentication method was created
# @param [Hash] mfa_attempts number of MFA setup attempts
# @param [Integer] auth_app_configuration_id Database ID of authentication app configuration
# @param [Integer] piv_cac_configuration_id Database ID of PIV/CAC configuration
# @param [String] piv_cac_configuration_dn_uuid PIV/CAC X509 distinguished name UUID
Expand All @@ -4968,6 +4969,7 @@ def multi_factor_auth(
errors: nil,
error_details: nil,
context: nil,
mfa_attempts: nil,
multi_factor_auth_method_created_at: nil,
auth_app_configuration_id: nil,
piv_cac_configuration_id: nil,
Expand All @@ -4991,6 +4993,7 @@ def multi_factor_auth(
error_details:,
context:,
new_device:,
mfa_attempts:,
multi_factor_auth_method:,
multi_factor_auth_method_created_at:,
auth_app_configuration_id:,
Expand Down Expand Up @@ -5038,17 +5041,20 @@ def multi_factor_auth_added_phone(
# @param [Integer] enabled_mfa_methods_count Number of enabled MFA methods on the account
# @param [Boolean] in_account_creation_flow whether user is going through creation flow
# @param ['piv_cac'] method_name Authentication method added
# @param [Hash] mfa_attempts number of MFA setup attempts
def multi_factor_auth_added_piv_cac(
enabled_mfa_methods_count:,
in_account_creation_flow:,
method_name: :piv_cac,
mfa_attempts: nil,
**extra
)
track_event(
:multi_factor_auth_added_piv_cac,
method_name:,
enabled_mfa_methods_count:,
in_account_creation_flow:,
mfa_attempts:,
**extra,
)
end
Expand Down Expand Up @@ -5088,6 +5094,7 @@ def multi_factor_auth_enter_backup_code_visit(context:, **extra)
end

# @param ["authentication", "reauthentication", "confirmation"] context User session context
# @param [Hash] mfa_attempts number of MFA setup attempts
# @param [String] multi_factor_auth_method
# @param [Boolean] confirmation_for_add_phone
# @param [Integer] phone_configuration_id
Expand All @@ -5107,11 +5114,13 @@ def multi_factor_auth_enter_otp_visit(
phone_fingerprint:,
in_account_creation_flow:,
enabled_mfa_methods_count:,
mfa_attempts: nil,
**extra
)
track_event(
'Multi-Factor Authentication: enter OTP visited',
context:,
mfa_attempts:,
multi_factor_auth_method:,
confirmation_for_add_phone:,
phone_configuration_id:,
Expand Down Expand Up @@ -5287,6 +5296,7 @@ def multi_factor_auth_phone_setup(
# @param [String, nil] key_id PIV/CAC key_id from PKI service
# @param [Hash] mfa_method_counts Hash of MFA method with the number of that method on the account
# @param [Hash] authenticator_data_flags WebAuthn authenticator data flags
# @param [Hash] mfa_attempts number of MFA setup attempts
# @param [String, nil] aaguid AAGUID value of WebAuthn device
# @param [String[], nil] unknown_transports Array of unrecognized WebAuthn transports, intended to
# be used in case of future specification changes.
Expand All @@ -5310,6 +5320,7 @@ def multi_factor_auth_setup(
key_id: nil,
mfa_method_counts: nil,
authenticator_data_flags: nil,
mfa_attempts: nil,
aaguid: nil,
unknown_transports: nil,
**extra
Expand All @@ -5335,6 +5346,7 @@ def multi_factor_auth_setup(
key_id:,
mfa_method_counts:,
authenticator_data_flags:,
mfa_attempts:,
aaguid:,
unknown_transports:,
**extra,
Expand Down Expand Up @@ -5984,11 +5996,18 @@ def piv_cac_recommended_visited
# Tracks when user's piv cac setup
# @param [Boolean] in_account_creation_flow Whether user is going through account creation
# @param [Integer] enabled_mfa_methods_count Number of enabled MFA methods on the account
def piv_cac_setup_visited(in_account_creation_flow:, enabled_mfa_methods_count: nil, **extra)
# @param [Hash] mfa_attempts number of MFA setup attempts
def piv_cac_setup_visited(
in_account_creation_flow:,
enabled_mfa_methods_count: nil,
mfa_attempts: nil,
**extra
)
track_event(
:piv_cac_setup_visited,
in_account_creation_flow:,
enabled_mfa_methods_count:,
mfa_attempts:,
**extra,
)
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
multi_factor_auth_method: TwoFactorAuthenticatable::AuthMethod::REMEMBER_DEVICE,
enabled_mfa_methods_count: 0,
new_device: true,
mfa_attempts: { 'remember_device' => 1 },
)
end

Expand Down Expand Up @@ -189,6 +190,7 @@
multi_factor_auth_method: TwoFactorAuthenticatable::AuthMethod::SMS,
enabled_mfa_methods_count: 1,
new_device: true,
mfa_attempts: { 'sms' => 1 },
)
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
multi_factor_auth_method_created_at: Time.zone.now.strftime('%s%L'),
enabled_mfa_methods_count: 1,
new_device: true,
mfa_attempts: { 'backup_code' => 1 },
)

expect(subject.user_session[:auth_events]).to eq(
Expand Down Expand Up @@ -97,6 +98,7 @@
multi_factor_auth_method_created_at: Time.zone.now.strftime('%s%L'),
enabled_mfa_methods_count: 1,
new_device: true,
mfa_attempts: { 'backup_code' => 1 },
)
expect(@analytics).to have_logged_event(
'User marked authenticated',
Expand Down Expand Up @@ -175,6 +177,7 @@
multi_factor_auth_method: 'backup_code',
enabled_mfa_methods_count: 1,
new_device: true,
mfa_attempts: { 'backup_code' => 1 },
)
expect(@analytics).to have_logged_event('Multi-Factor Authentication: max attempts reached')
end
Expand Down
Loading