Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ gem 'bootsnap', '>= 1.4.5', require: false

# Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin AJAX possible
# gem 'rack-cors'
gem 'rack-attack'
gem 'rack-cors', require: 'rack/cors'

gem 'devise'
Expand Down
4 changes: 4 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,8 @@ GEM
activesupport (>= 3.0.0)
racc (1.8.1)
rack (3.2.5)
rack-attack (6.8.0)
rack (>= 1.0, < 4)
rack-cors (3.0.0)
logger
rack (>= 3.0.14)
Expand Down Expand Up @@ -324,6 +326,7 @@ DEPENDENCIES
pg
puma
pundit
rack-attack
rack-cors
rails (~> 8.0.2)
rails-i18n
Expand Down Expand Up @@ -412,6 +415,7 @@ CHECKSUMS
pundit (2.5.2) sha256=e374152baa24f90b630428293faf4b4c5468fc3cc010165f7d8fcb44ce108bbd
racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
rack (3.2.5) sha256=4cbd0974c0b79f7a139b4812004a62e4c60b145cba76422e288ee670601ed6d3
rack-attack (6.8.0) sha256=f2499fdebf85bcc05573a22dff57d24305ac14ec2e4156cd3c28d47cafeeecf2
rack-cors (3.0.0) sha256=7b95be61db39606906b61b83bd7203fa802b0ceaaad8fcb2fef39e097bf53f68
rack-session (2.1.1) sha256=0b6dc07dea7e4b583f58a48e8b806d4c9f1c6c9214ebc202ec94562cbea2e4e9
rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463
Expand Down
18 changes: 5 additions & 13 deletions config/initializers/devise.rb
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,7 @@
# Defines which strategy will be used to lock an account.
# :failed_attempts = Locks an account after a number of failed attempts to sign in.
# :none = No lock strategy. You should handle locking by yourself.
# config.lock_strategy = :failed_attempts
config.lock_strategy = :none
config.lock_strategy = :failed_attempts
# Defines which key will be used when locking and unlocking an account
# config.unlock_keys = [:email]

Expand All @@ -216,17 +215,10 @@
# :time = Re-enables login after a certain amount of time (see :unlock_in below)
# :both = Enables both strategies
# :none = No unlock strategy. You should handle unlocking by yourself.
# config.unlock_strategy = :both
config.unlock_strategy = :none
# Number of authentication tries before locking an account if lock_strategy
# is failed attempts.
# config.maximum_attempts = 20

# Time interval to unlock the account if :time is enabled as unlock_strategy.
# config.unlock_in = 1.hour

# Warn on the last attempt before the account is locked.
# config.last_attempt_warning = true
config.unlock_strategy = :time
config.maximum_attempts = 5
config.unlock_in = 15.minutes
config.last_attempt_warning = true

# ==> Configuration for :recoverable
#
Expand Down
41 changes: 41 additions & 0 deletions config/initializers/rack_attack.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
class Rack::Attack
# Throttle OAuth token requests (login) - 5 requests per 15 minutes per IP
throttle("oauth/token/ip", limit: 5, period: 15.minutes) do |req|
req.ip if req.path == "/oauth/token" && req.post?
end

# Throttle password reset requests - 3 per hour per IP
throttle("password_forgotten/ip", limit: 3, period: 1.hour) do |req|
req.ip if req.path == "/users/password-forgotten" && req.post?
end

# Throttle password reset requests - 3 per hour per email
throttle("password_forgotten/email", limit: 3, period: 1.hour) do |req|
if req.path == "/users/password-forgotten" && req.post?
req.params.dig("email").to_s.downcase.strip.presence
end
end

# Throttle password change attempts - 5 per hour per IP
throttle("change_password/ip", limit: 5, period: 1.hour) do |req|
req.ip if req.path == "/users/change-password" && req.put?
end

# Global throttle - 300 requests per 5 minutes per IP
throttle("req/ip", limit: 300, period: 5.minutes, &:ip)

# Return 429 Too Many Requests with JSON body
self.throttled_responder = lambda do |matched, period, limit, request|
now = Time.now.utc
retry_after = (period - (now.to_i % period)).to_s

headers = {
"Content-Type" => "application/json",
"Retry-After" => retry_after
}

body = { error: "Rate limit exceeded. Try again in #{retry_after} seconds." }.to_json

[429, headers, [body]]
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class AddMissingLockableColumnsToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :failed_attempts, :integer, default: 0, null: false
add_column :users, :unlock_token, :string
add_index :users, :unlock_token, unique: true
end
end
5 changes: 4 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[8.0].define(version: 2025_04_08_145426) do
ActiveRecord::Schema[8.0].define(version: 2026_02_18_122029) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"

Expand Down Expand Up @@ -229,8 +229,11 @@
t.string "uuid"
t.datetime "locked_at", precision: nil
t.string "login"
t.integer "failed_attempts", default: 0, null: false
t.string "unlock_token"
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
t.index ["unlock_token"], name: "index_users_on_unlock_token", unique: true
end

create_table "users_pias", force: :cascade do |t|
Expand Down