Lock users out temporarily after too many OTPs#1464
Conversation
There was a problem hiding this comment.
Call this method inside handle_valid_otp_delivery_preference since it only applies to OTP, and not TOTP? A global before action means this logic will get exercised when it doesn't need to. It only needs to be exercised when handle_valid_otp_delivery_preference is called.
There was a problem hiding this comment.
That's a great point! Thanks
There was a problem hiding this comment.
I'm not sure we want to treat this as a MULTI_FACTOR_AUTH_MAX_ATTEMPTS event. I think we need a separate event. Also, we were discussing not locking the user out, and displaying a message such as "Didn't receive your OTP? Please wait a minute before requesting a new one."
There was a problem hiding this comment.
Yeah you're right, it woudl be confusing with the same lockout event, I'll make another.
I don't recall the discussion about not locking the user out, but happy to change that behavior if we want
|
Thanks for taking a first stab at this. Some notes:
|
|
Given the complexity of the logic, I propose that we move it to a separate class. |
|
Good idea! Will move it to an |
There was a problem hiding this comment.
This reset_attempt_count_if_user_no_longer_locked_out method only gets called after a user submits an OTP. It's a separate event than the one we are dealing with here. We need a new method in the OtpLimiter class that resets otp_send_count and otp_first_sent_at when the findtime has expired. Every time a user is about to be sent an OTP, the questions we need to ask are:
- Has it been more than
findtimeminutes since the user last received an OTP?- YES: set
otp_send_countto 1 andotp_first_sent_atto Time.now. Incidentally, this should probably be calledotp_last_sent_at. - NO: Is
otp_send_count>=maxretry?- YES: Prevent the user from receiving OTPs for
bantimeminutes - NO: Increment
otp_send_count
- YES: Prevent the user from receiving OTPs for
- YES: set
There was a problem hiding this comment.
Ah, yes. Thanks for pointing this out!
There was a problem hiding this comment.
I will rename the column and try to implement this, thanks for the details 😁
There was a problem hiding this comment.
Ok I gave this a shot & rebased
9cca387 to
dfbe091
Compare
168a2a0 to
fb12051
Compare
|
I pushed a new commit with specs that define the desired behavior. Our goal is to make the tests pass. I can work on this. |
|
I pushed a commit to make one of the tests pass. One more to go. |
|
I added a new commit that should make all tests pass now. Let me know what you think of this approach. I know there are CC issues. I'll fix them later. There's also an issue that I'll need to write a test for because it's not tested: once a user gets locked out due to sending too many OTPs, when they log back in, the countdown timer is based on the max attempts lockout, which is 5 minutes, which might not necessarily be the same as the |
386163e to
fd1dfab
Compare
zachmargolis
left a comment
There was a problem hiding this comment.
New changes make sense to me!
There was a problem hiding this comment.
Can we add t.timestamps and an index on updated_at? That will let us have a periodic job to delete old records so the table doesn't grow too much
There was a problem hiding this comment.
nit: double it
|
@zachmargolis I addressed your feedback and made the changes to use a single lockout period for both max OTP attempts and requests. I think this PR is in good enough shape for now. Wanna take one last look, please? |
19ba64a to
d97e4f0
Compare
zachmargolis
left a comment
There was a problem hiding this comment.
LGTM! I have some small cleanup suggestions but nothing big
app/decorators/user_decorator.rb
Outdated
There was a problem hiding this comment.
This is identitcal to lockout_time_remaining so I think we can remove it now
app/services/otp_rate_limiter.rb
Outdated
There was a problem hiding this comment.
So I think (like you mentioned) there is a small opportunity for a race condition here.
I think that if possible we should take advantage of Rail's first_or_create (as well as a top-level retry), so something like:
def find_or_create_with_phone(phone)
return if phone.blank? # maybe just raise an error here?
begin
OtpRequestsTracker.where(phone_fingerprint: Pii::Fingerprinter.fingerprint(phone)).
first_or_create(otp_send_count: 0, otp_last_sent_at: Time.zone.now)
rescue ActiveRecord::RecordNotUnique
retry # possibly use with_retries gem or add additional logic to only retry once
end
endThere was a problem hiding this comment.
Good idea. Thanks for the suggestion!
There was a problem hiding this comment.
Weird. I pushed a 6th commit several minutes ago, but I don't see it on GitHub yet.
d97e4f0 to
f2682b6
Compare
app/models/otp_requests_tracker.rb
Outdated
There was a problem hiding this comment.
We need to raise when we're not retrying otherwise this will just return nil and we'll get a NoMethodError rather than a duplicate key error
so like
rescue ActiveRecord::RecordNotUnique
retry unless ...
raise
end
There was a problem hiding this comment.
Sweet! Thanks!!
0880ef5 to
f65cadd
Compare
|
What do you think about the Code Climate similar code issues? Are they worth fixing now, or can we ignore them? |
|
This PR has dragged out so long I think it would be worth ignoring the duplicate code, I can follow up with a PR to generate an encrypted setter for one-way hashes the way we do for others |
|
Sounds good. I told CC to approve this PR. Wanna squash and merge? |
|
ok! I'll rebase this PR into two commits (one for my changes, one for yours) and then merge this |
**Why**: Use the database rather than rack-attack
**Why**: - Since we don't enforce uniqueness of phone number, a malicious user could create multiple accounts with the same number and request OTPs from each account. We want to keep track of the request count based on the phone number used and then lock out all users who have that phone number. - To keep things simple, we use the same lockout period for both max OTP attempts and max OTP requests
f65cadd to
af3f5c7
Compare
Why:
Use the database rather than rack-attack