diff --git a/Gemfile b/Gemfile index 574c6291..b5fe288f 100644 --- a/Gemfile +++ b/Gemfile @@ -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' diff --git a/Gemfile.lock b/Gemfile.lock index 84e586bc..6d9c5d36 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -324,6 +326,7 @@ DEPENDENCIES pg puma pundit + rack-attack rack-cors rails (~> 8.0.2) rails-i18n @@ -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 diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 9d676df9..132a25d3 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -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] @@ -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 # diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb new file mode 100644 index 00000000..09411e52 --- /dev/null +++ b/config/initializers/rack_attack.rb @@ -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 diff --git a/db/migrate/20260218122029_add_missing_lockable_columns_to_users.rb b/db/migrate/20260218122029_add_missing_lockable_columns_to_users.rb new file mode 100644 index 00000000..8952dcfa --- /dev/null +++ b/db/migrate/20260218122029_add_missing_lockable_columns_to_users.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 01fe62fa..44a544ec 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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" @@ -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|