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

Conversation

kevinsmaster5
Copy link
Contributor

🎫 Ticket

Link to the relevant ticket:
LG-14261

🛠 Summary of changes

Adds a session token to track number of attempts when setting up an mfa type.
Total sum of attempts gets passed to analytics_events.
Backup codes are excluded since during setup they're added instantly.

📜 Testing Plan

Checklist of steps to confirm the changes.

  • In a separate console run make watch_events
  • http://localhost:3000 with existing or new account add MFA.
  • Observe in the watch_events feed 'Multi-Factor Authentication Setup' that a predictable value for
    properties > event_properties > mfa_attempts is registered

Attempt count will increment by failing and retrying to add mfa for example:

  • Webauthn (Face|Touch or security key) clicking cancel at the browser dialog before completing setup
  • PIV test screen select a failing condition
  • SMS/Voice enter (change) supplied auth code
  • Auth App enter wrong auth code

👀 Screenshots

Screenshot 2024-09-26 at 11 39 10 AM (2)

Screenshot 2024-09-26 at 11 36 53 AM (2)

@kevinsmaster5
Copy link
Contributor Author

Taking a look at second_factor_attempts_count to see if that might take the place of creating a session token.

@kevinsmaster5
Copy link
Contributor Author

Taking a look at second_factor_attempts_count to see if that might take the place of creating a session token.

seems to only work for OTP.

@kevinsmaster5 kevinsmaster5 marked this pull request as ready for review September 30, 2024 13:31
@kevinsmaster5 kevinsmaster5 requested a review from a team September 30, 2024 14:53
Copy link
Member

@mdiarra3 mdiarra3 left a comment

Choose a reason for hiding this comment

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

Looks good and seems to work locally, but wondering if theres a way for us to push this out to a helper or concern for the verification controllers.

@kevinsmaster5
Copy link
Contributor Author

Looks good and seems to work locally, but wondering if theres a way for us to push this out to a helper or concern for the verification controllers.

Do you mean something like abstracting this sort of thing into a concern?

session[:piv_cac_attempts] ||= 0
session[:piv_cac_attempts] += 1

It does look repetitive since it's sort of identical in 4 places.

@kevinsmaster5
Copy link
Contributor Author

Added suggestion from #11293 (review)

Copy link
Member

@aduth aduth left a comment

Choose a reason for hiding this comment

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

The ticket wants this to be included for both setup and verification attempts, but the pull request is currently focused on setup only.

Copy link
Member

Choose a reason for hiding this comment

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

This file is a concern related to MFA setup, but the ticket should include attempts for verification as well. It might make sense to put these methods somewhere else more common, maybe app/controllers/concerns/two_factor_authenticatable_methods.rb.

@@ -64,6 +64,15 @@ def in_account_creation_flow?
user_session[:in_account_creation_flow] || false
end

def mfa_selection_attempt_count
session[:mfa_attempts] ||= 0
Copy link
Member

Choose a reason for hiding this comment

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

Should we be using user_session so that it gets cleaned up when the user is logged out?

end

def reset_mfa_selection_attempt_count
session[:mfa_attempts] = nil
Copy link
Member

Choose a reason for hiding this comment

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

We should probably delete the key altogether, rather than keeping it as a nil value.

Suggested change
session[:mfa_attempts] = nil
session.delete(:mfa_attempts)

@aduth
Copy link
Member

aduth commented Oct 7, 2024

If someone fails authenticating or setting up with one authentication method and then switches to another, what do we expect the mfa_attempts to be for that second MFA method? I think we wouldn't want to unfairly bias attempts for that second method based on failures with another method.

I think we should either only log the attempts for that individual method, or the log value should be a hash of attempts for each method. For both cases, I'd imagine the session value would be a hash of method-to-attempts.

@kevinsmaster5
Copy link
Contributor Author

kevinsmaster5 commented Oct 7, 2024

I think we should either only log the attempts for that individual method, or the log value should be a hash of attempts for each method. For both cases, I'd imagine the session value would be a hash of method-to-attempts.

So rather than
"event_properties": {
"success": true,
"errors": {},
"multi_factor_auth_method": "piv_cac",
"in_account_creation_flow": true,
"enabled_mfa_methods_count": 1,
"mfa_attempts": 1
},

Would we want to see something like
"event_properties": {
"success": true,
"errors": {},
"multi_factor_auth_method": "piv_cac",
"in_account_creation_flow": true,
"enabled_mfa_methods_count": 1,
"mfa_attempts": {
"piv_cac": 1
}
},

@aduth
Copy link
Member

aduth commented Oct 8, 2024

I think we could keep the logged value a single integer if we wanted, but in order to make sure that we're only logging attempts for that method, we'd need to track the attempts in the session as per-method (a hash).

So the session value would look something like the hash in your example, and getting the value from the session is something like user_session[:mfa_attempts][method].

Copy link
Member

@aduth aduth left a comment

Choose a reason for hiding this comment

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

I'm wondering if it'd be better to implement the incrementing

@@ -18,6 +18,7 @@ def handle_verification_for_authentication_context(result:, auth_method:, extra_
enabled_mfa_methods_count: mfa_context.enabled_mfa_methods_count,
new_device: new_device?,
**extra_analytics.to_h,
mfa_attempts: mfa_attempts_hash(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.

Similar to how we're sharing the logging code here in handle_verification_for_authentication_context, I think it'd also make sense to handle the incrementing code as well? Rather than in each individual method's controller.

@@ -124,6 +127,25 @@ def handle_remember_device_preference(remember_device_preference)
save_remember_device_preference(remember_device_preference)
end

def mfa_selection_attempt_count(auth_method)
user_session[:mfa_attempts] ||= {}
auth_method = auth_method.gsub('personal_key', 'personal-key')
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Adding 'personal_key' to a session would trigger SessionEncryptor::SensitiveKeyError even though here we're just counting attempts with that as the key.
Interested in a more idiomatic way of handling this edge case.

Copy link
Member

Choose a reason for hiding this comment

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

Maybe we could build support for something similar to FakeAnalytics' pii_like_keypaths to bypass the default error handling.

Comment on lines 137 to 141
def reset_mfa_selection_attempt_count
user_session.delete(:mfa_attempts)
end

def mfa_attempts_hash(auth_method)
return nil if user_session[:mfa_attempts].nil?
{
attempts: user_session[:mfa_attempts],
auth_method: auth_method,
}
end

Copy link
Member

Choose a reason for hiding this comment

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

These methods appears to be unused.

Suggested change
def reset_mfa_selection_attempt_count
user_session.delete(:mfa_attempts)
end
def mfa_attempts_hash(auth_method)
return nil if user_session[:mfa_attempts].nil?
{
attempts: user_session[:mfa_attempts],
auth_method: auth_method,
}
end

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Those are removed

Comment on lines 155 to 157
mfa_attempts: {
'otp' => 1,
'sms' => 1,
},
Copy link
Member

Choose a reason for hiding this comment

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

Evidenced by this test, I think we're double-counting attempts for verification. Maybe related to the comment I had asking about conditionally incrementing count for confirmation context?

Luckily they seem to be a different name, but I'd still only expect one or the other here per attempt.

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 have that worked out now using the suggested condition check

@@ -124,6 +127,25 @@ def handle_remember_device_preference(remember_device_preference)
save_remember_device_preference(remember_device_preference)
end

def mfa_selection_attempt_count(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.

Nit: Since this method is doing a thing, I'd expect its name to include a verb (e.g. "increment").

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Revised to incorporate increment

@@ -124,6 +127,25 @@ def handle_remember_device_preference(remember_device_preference)
save_remember_device_preference(remember_device_preference)
end

def mfa_selection_attempt_count(auth_method)
user_session[:mfa_attempts] ||= {}
auth_method = auth_method.gsub('personal_key', 'personal-key')
Copy link
Member

Choose a reason for hiding this comment

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

Maybe we could build support for something similar to FakeAnalytics' pii_like_keypaths to bypass the default error handling.

@@ -21,6 +21,7 @@ def show
end

def create
mfa_selection_attempt_count('otp')
Copy link
Member

Choose a reason for hiding this comment

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

I think since these values are "known", it makes more sense to use symbol values rather than strings.

(same comment across the board for the hash keys)

Suggested change
mfa_selection_attempt_count('otp')
mfa_selection_attempt_count(:otp)

Related: https://fullstackheroes.com/tutorials/ruby/symbols-vs-strings/

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That makes sense. Thanks!

Comment on lines 131 to 134
user_session[:mfa_attempts] ||= {}
auth_method = auth_method.gsub('personal_key', 'personal-key')
attempt = { auth_method => 1 }
user_session[:mfa_attempts].merge!(attempt) { |_key, old_val, new_val| old_val + new_val }
Copy link
Member

Choose a reason for hiding this comment

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

Feels like this could be simplified?

Suggested change
user_session[:mfa_attempts] ||= {}
auth_method = auth_method.gsub('personal_key', 'personal-key')
attempt = { auth_method => 1 }
user_session[:mfa_attempts].merge!(attempt) { |_key, old_val, new_val| old_val + new_val }
user_session[:mfa_attempts] ||= {}
user_session[:mfa_attempts][auth_method] ||= 0
user_session[:mfa_attempts][auth_method] += 1

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Revised that to simplify.

@voidlily
Copy link
Contributor

can you rebase this branch to pick up changes to the reviewapp deploy process?

@kevinsmaster5 kevinsmaster5 force-pushed the kmas-lg-14261-add-attempt-count-to-mfa-setup-auth branch from df8b957 to da9afdf Compare October 17, 2024 19:04
@@ -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

Comment on lines +25 to +26
[:errors, :personal_key],
[:error_details, :personal_key],
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]]

Comment on lines 137 to 142
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants