diff --git a/Gemfile b/Gemfile
index 9eb64a26d..dd418d067 100644
--- a/Gemfile
+++ b/Gemfile
@@ -50,6 +50,8 @@ gem "paper_trail"
# Store active record objects in version whodunnits
gem "paper_trail-globalid"
# Receive exceptions and configure alerts
+gem "rack-attack"
+gem "redis"
gem "sentry-rails"
gem "sentry-ruby"
diff --git a/Gemfile.lock b/Gemfile.lock
index 384257f8c..ee085ab03 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -290,6 +290,8 @@ GEM
nio4r (~> 2.0)
racc (1.6.0)
rack (2.2.3)
+ rack-attack (6.6.0)
+ rack (>= 1.0, < 3)
rack-mini-profiler (2.3.4)
rack (>= 1.2.0)
rack-proxy (0.7.2)
@@ -332,6 +334,7 @@ GEM
rb-fsevent (0.11.1)
rb-inotify (0.10.1)
ffi (~> 1.0)
+ redis (4.6.0)
regexp_parser (2.2.1)
request_store (1.5.1)
rack (>= 1.4)
@@ -490,8 +493,10 @@ DEPENDENCIES
postcodes_io
pry-byebug
puma (~> 5.0)
+ rack-attack
rack-mini-profiler (~> 2.0)
rails (~> 7.0.1)
+ redis
roo
rspec-rails
rubocop-govuk
diff --git a/app/controllers/errors_controller.rb b/app/controllers/errors_controller.rb
index 6b5abfc42..6b4c63f65 100644
--- a/app/controllers/errors_controller.rb
+++ b/app/controllers/errors_controller.rb
@@ -22,4 +22,11 @@ class ErrorsController < ApplicationController
format.json { render json: { error: "Unprocessable entity" }, status: :unprocessable_entity }
end
end
+
+ def too_many_requests
+ respond_to do |format|
+ format.html { render status: :too_many_requests }
+ format.json { render json: { error: "Too many requests" }, status: :too_many_requests }
+ end
+ end
end
diff --git a/app/services/paas_configuration_service.rb b/app/services/paas_configuration_service.rb
index dc27a1b3b..008b4d2ab 100644
--- a/app/services/paas_configuration_service.rb
+++ b/app/services/paas_configuration_service.rb
@@ -1,10 +1,11 @@
class PaasConfigurationService
- attr_reader :s3_buckets
+ attr_reader :s3_buckets, :redis_uris
def initialize(logger = Rails.logger)
@logger = logger
@paas_config = read_pass_config
@s3_buckets = read_s3_buckets
+ @redis_uris = read_redis_uris
end
def config_present?
@@ -15,6 +16,10 @@ class PaasConfigurationService
config_present? && @paas_config.key?(:"aws-s3-bucket")
end
+ def redis_config_present?
+ config_present? && @paas_config.key?(:redis)
+ end
+
private
def read_pass_config
@@ -42,4 +47,16 @@ private
end
s3_buckets
end
+
+ def read_redis_uris
+ return {} unless redis_config_present?
+
+ redis_uris = {}
+ @paas_config[:redis].each do |redis_config|
+ if redis_config.key?(:instance_name)
+ redis_uris[redis_config[:instance_name].to_sym] = redis_config.dig(:credentials, :uri)
+ end
+ end
+ redis_uris
+ end
end
diff --git a/app/views/errors/too_many_requests.erb b/app/views/errors/too_many_requests.erb
new file mode 100644
index 000000000..2e41652e7
--- /dev/null
+++ b/app/views/errors/too_many_requests.erb
@@ -0,0 +1,8 @@
+<% content_for :title, "Sorry, you have sent too many requests to our service" %>
+
+
+
+
<%= content_for(:title) %>
+
Try again in a minute.
+
+
diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb
new file mode 100644
index 000000000..f5f86b742
--- /dev/null
+++ b/config/initializers/rack_attack.rb
@@ -0,0 +1,20 @@
+if Rails.env.development? || Rails.env.test?
+ Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
+ Rack::Attack.enabled = false
+else
+ redis_url = PaasConfigurationService.new.redis_uris[:"dluhc-core-#{Redis.env}-redis-rate-limit"]
+ Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(url: redis_url)
+end
+
+Rack::Attack.throttle("password reset requests", limit: 5, period: 60.seconds) do |request|
+ if request.params["user"].present? && request.path == "/users/password" && request.post?
+ request.params["user"]["email"].to_s.downcase.gsub(/\s+/, "")
+ end
+end
+
+Rack::Attack.throttled_responder = lambda do |_env|
+ headers = {
+ "Location" => "/429",
+ }
+ [301, headers, []]
+end
diff --git a/config/routes.rb b/config/routes.rb
index c9738f1e3..c230a969c 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -71,6 +71,7 @@ Rails.application.routes.draw do
scope via: :all do
match "/404", to: "errors#not_found"
+ match "/429", to: "errors#too_many_requests", status: 429
match "/422", to: "errors#unprocessable_entity"
match "/500", to: "errors#internal_server_error"
end
diff --git a/manifest.yml b/manifest.yml
index a80004d41..3c13da1d9 100644
--- a/manifest.yml
+++ b/manifest.yml
@@ -17,6 +17,7 @@ applications:
RAILS_ENV: staging
services:
- dluhc-core-staging-postgres
+ - dluhc-core-staging-redis-rate-limit
- name: dluhc-core-production
<<: *defaults
@@ -30,3 +31,4 @@ applications:
host: submit-social-housing-lettings-sales-data
services:
- dluhc-core-production-postgres
+ - dluhc-core-production-redis-rate-limit
diff --git a/spec/requests/rack_attack_spec.rb b/spec/requests/rack_attack_spec.rb
new file mode 100644
index 000000000..a265261ab
--- /dev/null
+++ b/spec/requests/rack_attack_spec.rb
@@ -0,0 +1,64 @@
+require "rails_helper"
+require_relative "../support/devise"
+require "rack/attack"
+
+describe "Rack::Attack" do
+ let(:limit) { 5 }
+ let(:under_limit) { limit / 2 }
+ let(:over_limit) { limit + 1 }
+
+ let(:page) { Capybara::Node::Simple.new(response.body) }
+ let(:notify_client) { instance_double(Notifications::Client) }
+ let(:devise_notify_mailer) { DeviseNotifyMailer.new }
+
+ let(:params) { { user: { email: } } }
+ let(:user) { FactoryBot.create(:user) }
+ let(:email) { user.email }
+
+ before do
+ Rack::Attack.enabled = true
+ allow(DeviseNotifyMailer).to receive(:new).and_return(devise_notify_mailer)
+ allow(devise_notify_mailer).to receive(:notify_client).and_return(notify_client)
+ allow(notify_client).to receive(:send_email).and_return(true)
+ end
+
+ after do
+ Rack::Attack.enabled = false
+ Rack::Attack.reset!
+ end
+
+ context "when a password reset is requested" do
+ context "when the number of requests is under the throttle limit" do
+ it "does not throttle" do
+ under_limit.times do
+ post "/users/password", params: params
+ follow_redirect!
+ end
+ last_response = response
+ expect(last_response.status).to eq(200)
+ end
+ end
+
+ context "when the number of requests is at the throttle limit" do
+ it "does not throttle" do
+ limit.times do
+ post "/users/password", params: params
+ follow_redirect!
+ end
+ last_response = response
+ expect(last_response.status).to eq(200)
+ end
+ end
+
+ context "when the number of requests is over the throttle limit" do
+ it "throttles" do
+ over_limit.times do
+ post "/users/password", params: params
+ follow_redirect!
+ end
+ last_response = response
+ expect(last_response.status).to eq(429)
+ end
+ end
+ end
+end
diff --git a/spec/services/paas_configuration_service_spec.rb b/spec/services/paas_configuration_service_spec.rb
index fa1e99d58..3338ad5ec 100644
--- a/spec/services/paas_configuration_service_spec.rb
+++ b/spec/services/paas_configuration_service_spec.rb
@@ -20,12 +20,15 @@ RSpec.describe PaasConfigurationService do
expect(config_service.s3_buckets).to be_a(Hash)
expect(config_service.s3_buckets).to be_empty
end
+
+ it "does not retrieve any redis configuration" do
+ expect(config_service.redis_uris).to be_a(Hash)
+ expect(config_service.redis_uris).to be_empty
+ end
end
context "when configuration is present but invalid" do
- let(:vcap_services) do
- { "aws-s3-bucket": [{ instance_name: "bucket_1" }, { instance_name: "bucket_2" }] }
- end
+ let(:vcap_services) { "random text" }
before do
allow(ENV).to receive(:[]).with("VCAP_SERVICES").and_return(vcap_services)
@@ -85,4 +88,52 @@ RSpec.describe PaasConfigurationService do
expect(config_service.s3_buckets).to be_empty
end
end
+
+ context "when the paas configuration is present with redis configurations" do
+ let(:vcap_services) do
+ <<-JSON
+ {"redis": [{"instance_name": "redis_1", "credentials": {"uri": "redis_uri" }}]}
+ JSON
+ end
+
+ before do
+ allow(ENV).to receive(:[]).with("VCAP_SERVICES").and_return(vcap_services)
+ end
+
+ it "returns the configuration as present" do
+ expect(config_service.config_present?).to be(true)
+ end
+
+ it "returns the redis configuration as present" do
+ expect(config_service.redis_config_present?).to be(true)
+ end
+
+ it "does retrieve the redis configurations" do
+ redis_uris = config_service.redis_uris
+
+ expect(redis_uris).not_to be_empty
+ expect(redis_uris.count).to be(1)
+ expect(redis_uris).to have_key(:redis_1)
+ expect(redis_uris[:redis_1]).to eq("redis_uri")
+ end
+ end
+
+ context "when the paas configuration is present without redis configuration" do
+ before do
+ allow(ENV).to receive(:[]).with("VCAP_SERVICES").and_return("{}")
+ end
+
+ it "returns the configuration as present" do
+ expect(config_service.config_present?).to be(true)
+ end
+
+ it "returns the redis configuration as not present" do
+ expect(config_service.redis_config_present?).to be(false)
+ end
+
+ it "does not retrieve any redis uris from configuration" do
+ expect(config_service.redis_uris).to be_a(Hash)
+ expect(config_service.redis_uris).to be_empty
+ end
+ end
end