diff --git a/lib/generators/sorcery/templates/initializer.rb b/lib/generators/sorcery/templates/initializer.rb index 15f077b7..96de6b7f 100644 --- a/lib/generators/sorcery/templates/initializer.rb +++ b/lib/generators/sorcery/templates/initializer.rb @@ -465,6 +465,11 @@ # # user.login_lock_time_period = + # When true, sorcery continue to count failed login after reached limit. + # Default: `false` + # + # user.limitless_counting_failed_login = + # Unlock token attribute name # Default: `:unlock_token` # diff --git a/lib/sorcery/controller/submodules/brute_force_protection.rb b/lib/sorcery/controller/submodules/brute_force_protection.rb index 67b12f87..177a1b70 100644 --- a/lib/sorcery/controller/submodules/brute_force_protection.rb +++ b/lib/sorcery/controller/submodules/brute_force_protection.rb @@ -26,7 +26,7 @@ module InstanceMethods # Runs as a hook after a failed login. def update_failed_logins_count!(credentials) user = user_class.sorcery_adapter.find_by_credentials(credentials) - user.register_failed_login! if user + user.register_failed_login!(credentials[1]) if user end # Resets the failed logins counter. diff --git a/lib/sorcery/model/submodules/brute_force_protection.rb b/lib/sorcery/model/submodules/brute_force_protection.rb index 18c55074..7f7d498a 100644 --- a/lib/sorcery/model/submodules/brute_force_protection.rb +++ b/lib/sorcery/model/submodules/brute_force_protection.rb @@ -14,6 +14,8 @@ def self.included(base) :consecutive_login_retries_amount_limit, # how many failed logins allowed. :login_lock_time_period, # how long the user should be banned. # in seconds. 0 for permanent. + :limitless_counting_failed_login, # When true, continue to count failed login + # after reached limit. :unlock_token_attribute_name, # Unlock token attribute name :unlock_token_email_method_name, # Mailer method name :unlock_token_mailer_disabled, # When true, dont send unlock token via email @@ -25,6 +27,7 @@ def self.included(base) :@lock_expires_at_attribute_name => :lock_expires_at, :@consecutive_login_retries_amount_limit => 50, :@login_lock_time_period => 60 * 60, + :@limitless_counting_failed_login => false, :@unlock_token_attribute_name => :unlock_token, :@unlock_token_email_method_name => :send_unlock_token_email, @@ -63,13 +66,14 @@ def define_brute_force_protection_fields module InstanceMethods # Called by the controller to increment the failed logins counter. # Calls 'login_lock!' if login retries limit was reached. - def register_failed_login! + def register_failed_login!(password) config = sorcery_config - return unless login_unlocked? - sorcery_adapter.increment(config.failed_logins_count_attribute_name) + return if login_locked? && !config.limitless_counting_failed_login - return unless send(config.failed_logins_count_attribute_name) >= config.consecutive_login_retries_amount_limit + sorcery_adapter.increment(config.failed_logins_count_attribute_name) unless valid_password?(password) + + return if login_locked? || send(config.failed_logins_count_attribute_name) < config.consecutive_login_retries_amount_limit login_lock! end diff --git a/spec/shared_examples/user_brute_force_protection_shared_examples.rb b/spec/shared_examples/user_brute_force_protection_shared_examples.rb index 2cf15a6e..9dbcabd9 100644 --- a/spec/shared_examples/user_brute_force_protection_shared_examples.rb +++ b/spec/shared_examples/user_brute_force_protection_shared_examples.rb @@ -1,5 +1,8 @@ shared_examples_for 'rails_3_brute_force_protection_model' do - let(:user) { create_new_user } + let(:email) { 'foo@bar.com' } + let(:valid_password) { 'secret' } + let(:invalid_password) { 'foobar' } + let(:user) { create_new_user(username: 'foo_bar', email: email, password: valid_password) } before(:each) do User.sorcery_adapter.delete_all end @@ -38,6 +41,12 @@ expect(config.login_lock_time_period).to eq 2.hours end + it "enables configuration option 'limitless_counting_failed_login'" do + sorcery_model_property_set(:limitless_counting_failed_login, :my_limitless_counting_failed_login) + + expect(config.limitless_counting_failed_login).to eq :my_limitless_counting_failed_login + end + describe '#login_locked?' do it 'is locked' do user.send("#{config.lock_expires_at_attribute_name}=", Time.now + 5.days) @@ -51,12 +60,12 @@ end end - describe '#register_failed_login!' do + describe '#register_failed_login!(password)' do it 'locks user when number of retries reached the limit' do expect(user.lock_expires_at).to be_nil sorcery_model_property_set(:consecutive_login_retries_amount_limit, 1) - user.register_failed_login! + user.register_failed_login!(invalid_password) lock_expires_at = User.sorcery_adapter.find_by_id(user.id).lock_expires_at expect(lock_expires_at).not_to be_nil @@ -69,7 +78,7 @@ sorcery_model_property_set(:login_lock_time_period, 0) sorcery_model_property_set(:unlock_token_mailer, SorceryMailer) - 3.times { user.register_failed_login! } + 3.times { user.register_failed_login!(invalid_password) } expect(ActionMailer::Base.deliveries.size).to eq 0 end @@ -84,30 +93,60 @@ end it 'does not automatically send unlock email' do - 3.times { user.register_failed_login! } + 3.times { user.register_failed_login!(invalid_password) } expect(ActionMailer::Base.deliveries.size).to eq 1 end it 'generates unlock token before mail is sent' do - 3.times { user.register_failed_login! } + 3.times { user.register_failed_login!(invalid_password) } expect(ActionMailer::Base.deliveries.last.body.to_s.match(user.unlock_token)).not_to be_nil end end + + context 'limitless_counting_failed_login is true' do + before do + sorcery_model_property_set(:consecutive_login_retries_amount_limit, 1) + sorcery_model_property_set(:limitless_counting_failed_login, true) + 2.times { user.register_failed_login!(invalid_password) } + end + + it 'increment failed logins count attribute with invalid password after reached limit' do + expect(user.failed_logins_count).to eq 2 + end + + it 'does not increment failed logins count attribute with valid password after reached limit' do + user.register_failed_login!(valid_password) + expect(user.failed_logins_count).to eq 2 + end + end + + context 'limitless_counting_failed_login is false' do + before do + sorcery_model_property_set(:consecutive_login_retries_amount_limit, 1) + sorcery_model_property_set(:limitless_counting_failed_login, false) + end + + it 'does not increment failed logins count attribute after reached limit' do + user.register_failed_login!(invalid_password) + + expect(user.failed_logins_count).to eq 1 + end + end end context '.authenticate' do it 'unlocks after lock time period passes' do sorcery_model_property_set(:consecutive_login_retries_amount_limit, 2) sorcery_model_property_set(:login_lock_time_period, 0.2) - 2.times { user.register_failed_login! } + 2.times { user.register_failed_login!(invalid_password) } lock_expires_at = User.sorcery_adapter.find_by_id(user.id).lock_expires_at expect(lock_expires_at).not_to be_nil Timecop.travel(Time.now.in_time_zone + 0.3) - User.authenticate('bla@bla.com', 'secret') + User.authenticate(email, valid_password) lock_expires_at = User.sorcery_adapter.find_by_id(user.id).lock_expires_at expect(lock_expires_at).to be_nil @@ -118,12 +157,12 @@ sorcery_model_property_set(:consecutive_login_retries_amount_limit, 2) sorcery_model_property_set(:login_lock_time_period, 0) - 2.times { user.register_failed_login! } + 2.times { user.register_failed_login!(invalid_password) } unlock_date = user.lock_expires_at Timecop.travel(Time.now.in_time_zone + 1) - user.register_failed_login! + user.register_failed_login!(invalid_password) expect(user.lock_expires_at.to_s).to eq unlock_date.to_s Timecop.return @@ -135,7 +174,7 @@ sorcery_model_property_set(:consecutive_login_retries_amount_limit, 2) sorcery_model_property_set(:login_lock_time_period, 0) sorcery_model_property_set(:unlock_token_mailer, SorceryMailer) - 3.times { user.register_failed_login! } + 3.times { user.register_failed_login!(invalid_password) } expect(user.unlock_token).not_to be_nil