|
3 | 3 | class ProcessLoginService |
4 | 4 | extend ActiveModel::Naming |
5 | 5 |
|
| 6 | + # Email codes are six digits, so without a cap on wrong guesses they can be |
| 7 | + # brute-forced. Attempts are counted per Login and per user; the per-user cap |
| 8 | + # is what bounds an attacker who keeps starting new Logins. |
| 9 | + MAX_EMAIL_CODE_ATTEMPTS_PER_LOGIN = 5 |
| 10 | + MAX_EMAIL_CODE_ATTEMPTS_PER_USER = 10 |
| 11 | + EMAIL_CODE_ATTEMPTS_WINDOW = 1.hour |
| 12 | + |
6 | 13 | attr_reader(:errors, :login, :user) |
7 | 14 |
|
8 | 15 | # @param login [Login] |
9 | | - def initialize(login:) |
| 16 | + # @param ip_address [String, nil] recorded on each Login::Attempt |
| 17 | + # @param user_agent [String, nil] recorded on each Login::Attempt |
| 18 | + def initialize(login:, ip_address: nil, user_agent: nil) |
10 | 19 | @login = login |
11 | 20 | @user = login.user |
| 21 | + @ip_address = ip_address |
| 22 | + @user_agent = user_agent |
12 | 23 | @errors = ActiveModel::Errors.new(self) |
13 | 24 | end |
14 | 25 |
|
@@ -57,22 +68,21 @@ def process_webauthn(raw_credential:, challenge:) |
57 | 68 | # @return [Boolean] |
58 | 69 | # Whether the operation succeeded. If `false` check `errors` for details. |
59 | 70 | def process_login_code(code:, sms:) |
| 71 | + # Twilio Verify limits attempts on SMS codes itself; email codes are ours. |
| 72 | + return process_email_login_code(code:) unless sms |
| 73 | + |
60 | 74 | begin |
61 | 75 | UserService::ExchangeLoginCodeForUser.new( |
62 | 76 | user_id: user.id, |
63 | 77 | login_code: code, |
64 | | - sms:, |
| 78 | + sms: true, |
65 | 79 | ).run |
66 | 80 | rescue Errors::InvalidLoginCode |
67 | 81 | errors.add(:base, "Invalid login code") |
68 | 82 | return false |
69 | 83 | end |
70 | 84 |
|
71 | | - if sms |
72 | | - login.update!(authenticated_with_sms: true) |
73 | | - else |
74 | | - login.update!(authenticated_with_email: true) |
75 | | - end |
| 85 | + login.update!(authenticated_with_sms: true) |
76 | 86 |
|
77 | 87 | true |
78 | 88 | end |
@@ -115,4 +125,53 @@ def process_backup_code(code:) |
115 | 125 | true |
116 | 126 | end |
117 | 127 |
|
| 128 | + private |
| 129 | + |
| 130 | + def process_email_login_code(code:) |
| 131 | + # Record the attempt before counting, so concurrent guesses see each other. |
| 132 | + # This must commit on its own, outside any wrapping transaction. |
| 133 | + attempt = login.attempts.create!(factor: :email, ip_address: @ip_address, user_agent: @user_agent) |
| 134 | + |
| 135 | + user_attempts = Login::Attempt |
| 136 | + .email |
| 137 | + .counted |
| 138 | + .where(created_at: EMAIL_CODE_ATTEMPTS_WINDOW.ago..) |
| 139 | + .joins(:login) |
| 140 | + # Attempts can only be made on an active Login, so older |
| 141 | + # Logins can't have any in the window. Bounding them keeps |
| 142 | + # this to a few rows of `index_logins_on_user_id_and_created_at`. |
| 143 | + .where(logins: { user_id: user.id, created_at: (EMAIL_CODE_ATTEMPTS_WINDOW + Login::EXPIRATION).ago.. }) |
| 144 | + .count |
| 145 | + |
| 146 | + if user_attempts > MAX_EMAIL_CODE_ATTEMPTS_PER_USER |
| 147 | + attempt.blocked! |
| 148 | + errors.add(:base, "Too many incorrect login codes. Please try again later.") |
| 149 | + return false |
| 150 | + end |
| 151 | + |
| 152 | + if login.attempts.email.counted.count > MAX_EMAIL_CODE_ATTEMPTS_PER_LOGIN |
| 153 | + attempt.blocked! |
| 154 | + errors.add(:base, "Too many incorrect login codes. Please start again and request a new code.") |
| 155 | + return false |
| 156 | + end |
| 157 | + |
| 158 | + begin |
| 159 | + UserService::ExchangeLoginCodeForUser.new( |
| 160 | + user_id: user.id, |
| 161 | + login_code: code, |
| 162 | + sms: false, |
| 163 | + ).run |
| 164 | + rescue Errors::InvalidLoginCode |
| 165 | + attempt.failed! |
| 166 | + errors.add(:base, "Invalid login code") |
| 167 | + return false |
| 168 | + end |
| 169 | + |
| 170 | + # A successful sign-in shouldn't eat into the user's budget for the hour. |
| 171 | + attempt.succeeded! |
| 172 | + login.update!(authenticated_with_email: true) |
| 173 | + |
| 174 | + true |
| 175 | + end |
| 176 | + |
118 | 177 | end |
0 commit comments