Skip to content

Commit 3290094

Browse files
authored
Add rate limiting and code invalidation to prevent brute-force email code logins (#224)
* Add rate limiting and code invalidation to prevent brute-force email code logins * Use new Login::Attempt model * Switch enums to string columns
1 parent 3d4f2d1 commit 3290094

21 files changed

Lines changed: 469 additions & 15 deletions

‎app/controllers/logins_controller.rb‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ def complete
118118
# Clear the flash - this prevents the error message showing up after an unsuccessful -> successful login
119119
flash.clear
120120

121-
service = ProcessLoginService.new(login: @login)
121+
service = ProcessLoginService.new(login: @login, ip_address: request.remote_ip, user_agent: request.user_agent)
122122

123123
case params[:method]
124124
when "webauthn"
@@ -128,6 +128,7 @@ def complete
128128
)
129129

130130
unless ok
131+
record_failed_completion
131132
redirect_to(auth_users_path, flash: { error: service.errors.full_messages.to_sentence })
132133
return
133134
end
@@ -138,6 +139,7 @@ def complete
138139
)
139140

140141
unless ok
142+
record_failed_completion
141143
flash.now[:error] = service.errors.full_messages.to_sentence
142144
render(:sms, status: :unprocessable_content)
143145
return
@@ -149,6 +151,7 @@ def complete
149151
)
150152

151153
unless ok
154+
record_failed_completion
152155
flash.now[:error] = service.errors.full_messages.to_sentence
153156
render(:email, status: :unprocessable_content)
154157
return
@@ -157,13 +160,15 @@ def complete
157160
ok = service.process_totp(code: params[:code])
158161

159162
unless ok
163+
record_failed_completion
160164
redirect_to(totp_login_path(@login), flash: { error: "Invalid TOTP code, please try again." })
161165
return
162166
end
163167
when "backup_code"
164168
ok = service.process_backup_code(code: params[:backup_code])
165169

166170
unless ok
171+
record_failed_completion
167172
redirect_to(backup_code_login_path(@login), flash: { error: service.errors.full_messages.to_sentence })
168173
return
169174
end
@@ -296,6 +301,11 @@ def fingerprint_info
296301
}
297302
end
298303

304+
# Counts towards the per-IP ban in config/initializers/rack_attack.rb.
305+
def record_failed_completion
306+
Rack::Attack::Fail2Ban.filter(Rack::Attack.login_complete_fail2ban_key(request.remote_ip), Rack::Attack::LOGIN_COMPLETE_FAIL2BAN) { true }
307+
end
308+
299309
def valid_browser_token?
300310
return true if Rails.env.test?
301311
return true unless @login.browser_token

‎app/controllers/sudo_mode_handler.rb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def call
5858
return false
5959
end
6060

61-
service = ProcessLoginService.new(login:)
61+
service = ProcessLoginService.new(login:, ip_address: request.remote_ip, user_agent: request.user_agent)
6262

6363
ok =
6464
case sudo_params.submit_method
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# frozen_string_literal: true
2+
3+
class Login
4+
class Attempt
5+
class ClearOldAttemptsJob < ApplicationJob
6+
queue_as :low
7+
8+
def perform
9+
Login::Attempt
10+
.where.not(ip_address: nil).or(Login::Attempt.where.not(user_agent: nil))
11+
.where("created_at < ?", 18.months.ago)
12+
.find_each(&:clear_ip_metadata!)
13+
end
14+
15+
end
16+
17+
end
18+
19+
end

‎app/models/login.rb‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@
1818
#
1919
# Indexes
2020
#
21-
# index_logins_on_referral_link_id (referral_link_id)
22-
# index_logins_on_user_id (user_id)
23-
# index_logins_on_user_session_id (user_session_id)
21+
# index_logins_on_referral_link_id (referral_link_id)
22+
# index_logins_on_user_id (user_id)
23+
# index_logins_on_user_id_and_created_at (user_id,created_at)
24+
# index_logins_on_user_session_id (user_session_id)
2425
#
2526
class Login < ApplicationRecord
2627
include AASM
@@ -30,6 +31,7 @@ class Login < ApplicationRecord
3031

3132
belongs_to :user
3233
belongs_to :user_session, class_name: "User::Session", optional: true
34+
has_many :attempts, class_name: "Login::Attempt", inverse_of: :login
3335

3436
scope(:initial, -> { where(is_reauthentication: false) })
3537
scope(:reauthentication, -> { where(is_reauthentication: true) })

‎app/models/login/attempt.rb‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# frozen_string_literal: true
2+
3+
# == Schema Information
4+
#
5+
# Table name: login_attempts
6+
#
7+
# id :bigint not null, primary key
8+
# factor :string not null
9+
# ip_address :inet
10+
# status :string default("pending"), not null
11+
# user_agent :text
12+
# created_at :datetime not null
13+
# updated_at :datetime not null
14+
# login_id :bigint not null
15+
#
16+
# Indexes
17+
#
18+
# index_login_attempts_on_created_at (created_at)
19+
# index_login_attempts_on_login_id (login_id)
20+
#
21+
# Foreign Keys
22+
#
23+
# fk_rails_... (login_id => logins.id)
24+
#
25+
class Login
26+
class Attempt < ApplicationRecord
27+
belongs_to :login
28+
has_one :user, through: :login
29+
30+
enum :factor, { email: "email" }
31+
enum :status, { pending: "pending", failed: "failed", succeeded: "succeeded", blocked: "blocked" }
32+
33+
scope :counted, -> { where(status: [:pending, :failed]) }
34+
35+
def clear_ip_metadata!
36+
update!(ip_address: nil, user_agent: nil)
37+
end
38+
39+
end
40+
41+
end

‎app/services/login_code_service/request.rb‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ def send_login_code_by_email(user)
5252
return { error: "You're requesting too many login codes. Please try again later.", method: :email }
5353
end
5454

55+
# Only the newest code works. Otherwise every resend adds another valid
56+
# code, and each guess has that many more chances of matching one.
57+
user.login_codes.active.update_all(used_at: Time.current)
58+
5559
login_code = user.login_codes.create(
5660
ip_address: @ip_address,
5761
user_agent: @user_agent

‎app/services/process_login_service.rb‎

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,23 @@
33
class ProcessLoginService
44
extend ActiveModel::Naming
55

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+
613
attr_reader(:errors, :login, :user)
714

815
# @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)
1019
@login = login
1120
@user = login.user
21+
@ip_address = ip_address
22+
@user_agent = user_agent
1223
@errors = ActiveModel::Errors.new(self)
1324
end
1425

@@ -57,22 +68,21 @@ def process_webauthn(raw_credential:, challenge:)
5768
# @return [Boolean]
5869
# Whether the operation succeeded. If `false` check `errors` for details.
5970
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+
6074
begin
6175
UserService::ExchangeLoginCodeForUser.new(
6276
user_id: user.id,
6377
login_code: code,
64-
sms:,
78+
sms: true,
6579
).run
6680
rescue Errors::InvalidLoginCode
6781
errors.add(:base, "Invalid login code")
6882
return false
6983
end
7084

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)
7686

7787
true
7888
end
@@ -115,4 +125,53 @@ def process_backup_code(code:)
115125
true
116126
end
117127

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+
118177
end
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# frozen_string_literal: true
2+
3+
# Requests reach Puma through the Hetzner load balancer and then Caddy. Both set
4+
# X-Forwarded-*; neither sets, or strips, the RFC 7239 `Forwarded:` header.
5+
# Rack reads `Forwarded:` first by default, and when it's present ignores
6+
# X-Forwarded-For entirely, so a client sending `Forwarded: for=<any ip>` would
7+
# pick its own `request.ip`, and its own `request.remote_ip` too, since
8+
# ActionDispatch::RemoteIp builds on Rack's `forwarded_for`. That breaks every
9+
# IP-keyed safelist and throttle in config/initializers/rack_attack.rb. Only
10+
# trust the headers our proxies set.
11+
Rack::Request.forwarded_priority = [:x_forwarded]

‎config/initializers/rack_attack.rb‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
# frozen_string_literal: true
22

33
class Rack::Attack
4+
# Every rule below keys on `req.ip`, so it has to be an address the client
5+
# can't choose, which ActionDispatch::RemoteIp works out
6+
class Request < ::Rack::Request
7+
def ip
8+
(env["action_dispatch.remote_ip"] || env["REMOTE_ADDR"]).to_s
9+
end
10+
11+
end
12+
413
### Configure Cache ###
514

615
# If you don't want to use Rails.cache (Rack::Attack's default), then
@@ -155,6 +164,38 @@ class Rack::Attack
155164
end
156165
end
157166

167+
# POST /logins/:id/complete is where codes get guessed. The app caps wrong
168+
# email codes per Login and per user (see ProcessLoginService); these are a
169+
# cheaper layer in front of that. Legitimate users post here once per factor,
170+
# plus the odd typo.
171+
LOGIN_COMPLETE_PATH = /\A\/logins\/(?<hashid>[^\/]+)\/complete\z/
172+
173+
throttle("logins/complete/ip", limit: 20, period: 1.minute) do |req|
174+
req.ip if req.post? && LOGIN_COMPLETE_PATH.match?(req.path)
175+
end
176+
177+
throttle("logins/complete/login", limit: 10, period: 5.minutes) do |req|
178+
if req.post? && (m = req.path.match(LOGIN_COMPLETE_PATH))
179+
m[:hashid]
180+
end
181+
end
182+
183+
# Only the app knows whether an attempt failed, so LoginsController#complete
184+
# records failures with `Fail2Ban.filter` (LOGIN_COMPLETE_FAIL2BAN) and this
185+
# rule turns away an IP once it's banned. Spraying guesses across many
186+
# accounts gets past the per-user cap, but not this.
187+
LOGIN_COMPLETE_FAIL2BAN = { maxretry: 30, findtime: 15.minutes, bantime: 1.hour }.freeze
188+
189+
def self.login_complete_fail2ban_key(ip)
190+
"logins/complete/ip:#{ip}"
191+
end
192+
193+
blocklist("fail2ban failed login completions") do |req|
194+
req.post? &&
195+
LOGIN_COMPLETE_PATH.match?(req.path) &&
196+
Rack::Attack::Fail2Ban.banned?(login_complete_fail2ban_key(req.ip))
197+
end
198+
158199
# Throttle POST requests to SMS verification by IP address
159200
throttle("sms_verify/ip", limit: 5, period: 8.hours) do |req|
160201
if req.path == "/users/start_sms_auth_verification" && req.post?
@@ -247,4 +288,6 @@ class Rack::Attack
247288

248289
end
249290

250-
Rack::Attack.enabled = Rails.env.production? # only enable in production
291+
# Only enable in production, unless RACK_ATTACK_ENABLED=true is set to try the
292+
# rules out locally (also run `bin/rails dev:cache`, or nothing gets counted).
293+
Rack::Attack.enabled = Rails.env.production? || ENV["RACK_ATTACK_ENABLED"] == "true"

‎config/schedule.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,11 @@ clear_old_user_sessions:
181181
class: "User::Session::ClearOldUserSessionsJob"
182182
queue: low
183183

184+
clear_old_login_attempts:
185+
cron: "10 3 * * *" # every day at 3:10 AM
186+
class: "Login::Attempt::ClearOldAttemptsJob"
187+
queue: low
188+
184189
delete_anonymous_user_sessions:
185190
cron: "20 * * * *" # run every 1 hour
186191
class: "User::Session::DeleteAnonymousSessionsJob"

0 commit comments

Comments
 (0)