-
Notifications
You must be signed in to change notification settings - Fork 112
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
base: main
Are you sure you want to change the base?
LG-14261 Add attempt count to mfa setup auth events #11293
Conversation
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. |
There was a problem hiding this 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.
Do you mean something like abstracting this sort of thing into a concern?
It does look repetitive since it's sort of identical in 4 places. |
Added suggestion from #11293 (review) |
There was a problem hiding this 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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
session[:mfa_attempts] = nil | |
session.delete(:mfa_attempts) |
app/controllers/two_factor_authentication/otp_verification_controller.rb
Show resolved
Hide resolved
If someone fails authenticating or setting up with one authentication method and then switches to another, what do we expect the 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 Would we want to see something like |
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 |
There was a problem hiding this 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
app/controllers/two_factor_authentication/options_controller.rb
Outdated
Show resolved
Hide resolved
app/controllers/two_factor_authentication/otp_verification_controller.rb
Outdated
Show resolved
Hide resolved
app/controllers/two_factor_authentication/otp_verification_controller.rb
Outdated
Show resolved
Hide resolved
@@ -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), |
There was a problem hiding this comment.
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') |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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 | ||
|
There was a problem hiding this comment.
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.
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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Those are removed
mfa_attempts: { | ||
'otp' => 1, | ||
'sms' => 1, | ||
}, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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").
There was a problem hiding this comment.
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') |
There was a problem hiding this comment.
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') |
There was a problem hiding this comment.
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)
mfa_selection_attempt_count('otp') | |
mfa_selection_attempt_count(:otp) |
Related: https://fullstackheroes.com/tutorials/ruby/symbols-vs-strings/
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That makes sense. Thanks!
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 } |
There was a problem hiding this comment.
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?
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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Revised that to simplify.
can you rebase this branch to pick up changes to the reviewapp deploy process? |
…ate specs to sync with that
df8b957
to
da9afdf
Compare
@@ -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) |
There was a problem hiding this comment.
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:
def mfa_selection_attempt_count(increment_auth_method) | |
def increment_mfa_selection_attempt_count(auth_method) |
There was a problem hiding this comment.
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
[:errors, :personal_key], | ||
[:error_details, :personal_key], |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
-
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}
ERRORFakeAnalytics::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 increment_auth_method == 'personal_key' | ||
increment_auth_method = increment_auth_method.gsub( | ||
'personal_key', | ||
'personal-key', | ||
) | ||
end |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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) |
🎫 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.
properties > event_properties > mfa_attempts is registered
Attempt count will increment by failing and retrying to add mfa for example:
👀 Screenshots