diff --git a/app/controllers/devise_recognisable/sessions_controller.rb b/app/controllers/devise_recognisable/sessions_controller.rb index 79704b5..e855944 100644 --- a/app/controllers/devise_recognisable/sessions_controller.rb +++ b/app/controllers/devise_recognisable/sessions_controller.rb @@ -56,7 +56,8 @@ def check_for_authentication_token sign_in_ip: request.location.ip, sign_in_at: Time.now, user_agent: request.user_agent, - accept_header: request.headers["HTTP_ACCEPT"] + accept_header: request.headers["HTTP_ACCEPT"], + accept_language: request.headers["Accept-Language"] ) redirect_to after_sign_in_path_for(resource) end @@ -69,7 +70,8 @@ def store_recognisable_details sign_in_ip: request.location.ip, sign_in_at: Time.now, user_agent: request.user_agent, - accept_header: request.headers["HTTP_ACCEPT"] + accept_header: request.headers["HTTP_ACCEPT"], + accept_language: request.headers["Accept-Language"] ) end end diff --git a/app/lib/devise_recognisable/guard.rb b/app/lib/devise_recognisable/guard.rb index c03a48c..98fc32d 100644 --- a/app/lib/devise_recognisable/guard.rb +++ b/app/lib/devise_recognisable/guard.rb @@ -8,9 +8,9 @@ class DeviseRecognisable::Guard MAX_LEVENSHTEIN_DISTANCE = Rails.env.test? ? 10 : 0 @@required_scores = { - relaxed: 2, - normal: 3, - strict: 4 + relaxed: 3, + normal: 4, + strict: 5 } def self.with(previous_sessions) @@ -61,6 +61,9 @@ def calculate_score_for(session) # Is the request's Accept header different to the previous sign in? score += 1 if session.accept_header == @request.headers["HTTP_ACCEPT"] + # Is the request's Accept-Language header different to the previous sign in? + score += 1 if session.accept_language == @request.headers["Accept-Language"] + return score end @@ -165,6 +168,14 @@ def failures } end + # Is the request's Accept-Language header different to the previous sign in? + unless @closest_match[:session].accept_language == @request.headers["Accept-Language"] + failures[:failures][:accept_language] = { + request_value: @request.headers["Accept-Language"], + session_value: @closest_match[:session].accept_language + } + end + return failures end diff --git a/app/lib/generators/devise_recognisable/install_template.rb.erb b/app/lib/generators/devise_recognisable/install_template.rb.erb index cbca181..ea1398d 100644 --- a/app/lib/generators/devise_recognisable/install_template.rb.erb +++ b/app/lib/generators/devise_recognisable/install_template.rb.erb @@ -6,6 +6,7 @@ class CreateRecognisableSessions < <%= "ActiveRecord::Migration[#{ActiveRecord:: t.<%= ip_column %> :sign_in_ip t.string :user_agent t.string :accept_header + t.string :accept_language t.datetime :sign_in_at end add_index :recognisable_sessions, [:recognisable_type, :recognisable_id], :name => 'recognisable_index' diff --git a/spec/dummy-app/db/migrate/20200930175344_create_recognisable_sessions.rb b/spec/dummy-app/db/migrate/20200930175344_create_recognisable_sessions.rb index da676a0..bce1398 100644 --- a/spec/dummy-app/db/migrate/20200930175344_create_recognisable_sessions.rb +++ b/spec/dummy-app/db/migrate/20200930175344_create_recognisable_sessions.rb @@ -6,6 +6,7 @@ def change t.string :sign_in_ip t.string :user_agent t.string :accept_header + t.string :accept_language t.datetime :sign_in_at end add_index :recognisable_sessions, [:recognisable_type, :recognisable_id], :name => 'recognisable_index' diff --git a/spec/dummy-app/db/schema.rb b/spec/dummy-app/db/schema.rb index 3931582..98655ea 100644 --- a/spec/dummy-app/db/schema.rb +++ b/spec/dummy-app/db/schema.rb @@ -17,6 +17,7 @@ t.string "sign_in_ip" t.string "user_agent" t.string "accept_header" + t.string "accept_language" t.datetime "sign_in_at", precision: nil t.index ["recognisable_type", "recognisable_id"], name: "recognisable_index" end diff --git a/spec/dummy-app/spec/features/sign_in_spec.rb b/spec/dummy-app/spec/features/sign_in_spec.rb index d610656..ac9a19e 100644 --- a/spec/dummy-app/spec/features/sign_in_spec.rb +++ b/spec/dummy-app/spec/features/sign_in_spec.rb @@ -7,16 +7,19 @@ let!(:user_agent) { 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36' } let!(:accept_header) { 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' } + let!(:accept_language) { "en-GB,en-US;q=0.9,en;q=0.8" } let!(:recognisable_session_values) {{ recognisable_id: user.id, recognisable_type: 'User', user_agent: user_agent, - accept_header: accept_header + accept_header: accept_header, + accept_language: accept_language }} before do Capybara.current_session.driver.header('User-Agent', user_agent) Capybara.current_session.driver.header('Accept', accept_header) + Capybara.current_session.driver.header('Accept-Language', accept_language) end context 'as a user that has no last_sign_in_ip' do @@ -169,7 +172,7 @@ :session_value=>new_ip, :comparison_result=>:complete_mismatch }}, - :score=>2, + :score=>3, :user_id=>1, :user_type=>"User" ), "Unrecognised request") @@ -224,7 +227,7 @@ :session_value=>new_ip, :comparison_result=>:complete_mismatch }}, - :score=>2, + :score=>3, :user_id=>1, :user_type=>"User" ), "Unrecognised request") @@ -297,6 +300,7 @@ context 'from a device with a different Accept header value' do let!(:recognisable_session) { FactoryBot.create :recognisable_session, recognisable_session_values } let!(:new_accept_header) { 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8' } + before do recognisable_session.update!(accept_header: new_accept_header) @@ -321,14 +325,43 @@ end end + context 'from a device with a different Accept-Language header value' do + let!(:recognisable_session) { FactoryBot.create :recognisable_session, recognisable_session_values } + let!(:new_accept_language) { 'en,pl;q=0.9,pl-PL;q=0.8,en-GB-oxendict;q=0.7' } + + before do + recognisable_session.update!(accept_header: new_accept_language) + visit '/' + click_link 'Log in' + fill_in 'Email', with: user.email + fill_in 'Password', with: user.password + click_button 'Log in' + end + + it 'does not log the user in' do + expect(page).to have_content I18n.t('devise.sessions.send_new_ip_instructions') + expect(page).to_not have_content('Home sweet home') + end + + context 'visiting the link in the email' do + it 'logs the user in' do + open_email(user.email, with_subject: I18n.t('devise.mailer.new_ip.subject')) + visit_in_email('Log in') + expect(page).to have_content('Home sweet home') + end + end + end + context 'with multiple different RecognisableSessions' do let!(:different_user_agent) { 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_4) AppleWebKit/605.1.15 (KHTML, like Gecko)' } let!(:different_accept_header) { 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8' } + let!(:different_accept_language) { 'en,pl;q=0.9,pl-PL;q=0.8,en-GB-oxendict;q=0.7' } let!(:different_recognisable_session_values) {{ recognisable_id: user.id, recognisable_type: 'User', user_agent: different_user_agent, - accept_header: different_accept_header + accept_header: different_accept_header, + accept_language: different_accept_language }} before do