diff --git a/lib/generators/sorcery/templates/migration/single_session.rb b/lib/generators/sorcery/templates/migration/single_session.rb new file mode 100644 index 00000000..2ec35861 --- /dev/null +++ b/lib/generators/sorcery/templates/migration/single_session.rb @@ -0,0 +1,5 @@ +class SorcerySingleSession < <%= migration_class_name %> + def change + add_column :<%= model_class_name.tableize %>, :session_token, :string, default: nil + end +end diff --git a/lib/sorcery.rb b/lib/sorcery.rb index 2a0af8cc..4f5d1ecb 100644 --- a/lib/sorcery.rb +++ b/lib/sorcery.rb @@ -19,6 +19,7 @@ module Submodules require 'sorcery/model/submodules/brute_force_protection' require 'sorcery/model/submodules/external' require 'sorcery/model/submodules/magic_login' + require 'sorcery/model/submodules/single_session' end end @@ -33,6 +34,7 @@ module Submodules require 'sorcery/controller/submodules/http_basic_auth' require 'sorcery/controller/submodules/activity_logging' require 'sorcery/controller/submodules/external' + require 'sorcery/controller/submodules/single_session' end end diff --git a/lib/sorcery/controller/submodules/single_session.rb b/lib/sorcery/controller/submodules/single_session.rb new file mode 100644 index 00000000..681c5a0c --- /dev/null +++ b/lib/sorcery/controller/submodules/single_session.rb @@ -0,0 +1,47 @@ +module Sorcery + module Controller + module Submodules + module SingleSession + def self.included(base) + base.send(:include, InstanceMethods) + + Config.module_eval do + class << self + attr_accessor :verify_session_token_enabled + def merge_remember_me_defaults! + @defaults.merge!(:@verify_session_token_enabled => true) + end + end + merge_remember_me_defaults! + end + + unless Config.after_login.include?(:set_session_token) + Config.after_login << :set_session_token + end + + base.prepend_before_action :verify_session_token, if: :logged_in? + end + + module InstanceMethods + # Checks if session token matches users + # To be used as a before_action + def verify_session_token + return unless Config.verify_session_token_enabled + return if sorcery_session_token_valid? + + reset_sorcery_session + remove_instance_variable :@current_user if defined? @current_user + end + + def sorcery_session_token_valid? + session[:token] == current_user.session_token + end + + def set_session_token(user, _credentials = nil) + session[:token] = user.regenerate_session_token + end + end + end + end + end +end diff --git a/lib/sorcery/model/submodules/single_session.rb b/lib/sorcery/model/submodules/single_session.rb new file mode 100644 index 00000000..c8633784 --- /dev/null +++ b/lib/sorcery/model/submodules/single_session.rb @@ -0,0 +1,47 @@ +module Sorcery + module Model + module Submodules + # This submodule adds the ability to set unique session token per user. + # It helps enforce single session per user. + # This is the model part of the submodule, which provides configuration options. + module SingleSession + def self.included(base) + base.sorcery_config.class_eval do + # Unique session token attribute name + attr_accessor :session_token_attribute_name + end + + base.sorcery_config.instance_eval do + @defaults.merge!(:@session_token_attribute_name => :session_token) + reset! + end + + base.sorcery_config.after_config << :define_session_token_fields + + base.extend(ClassMethods) + base.send(:include, InstanceMethods) + end + + module ClassMethods + + protected + + def define_session_token_fields + class_eval do + sorcery_adapter.define_field sorcery_config.session_token_attribute_name, String + end + end + end + + module InstanceMethods + def regenerate_session_token + token = TemporaryToken.generate_random_token + sorcery_adapter.update_attributes({ sorcery_config.session_token_attribute_name => token }) + + token + end + end + end + end + end +end diff --git a/lib/sorcery/test_helpers/internal/rails.rb b/lib/sorcery/test_helpers/internal/rails.rb index a9eb4365..0110411e 100644 --- a/lib/sorcery/test_helpers/internal/rails.rb +++ b/lib/sorcery/test_helpers/internal/rails.rb @@ -8,6 +8,7 @@ module Rails register_last_activity_time_to_db deny_banned_user validate_session + verify_session_token ].freeze def sorcery_reload!(submodules = [], options = {}) diff --git a/spec/active_record/user_single_session_spec.rb b/spec/active_record/user_single_session_spec.rb new file mode 100644 index 00000000..5aa5fae1 --- /dev/null +++ b/spec/active_record/user_single_session_spec.rb @@ -0,0 +1,15 @@ +require 'spec_helper' +require 'shared_examples/user_single_session_shared_examples' + +describe User, 'with single_session submodule', active_record: true do + before(:all) do + MigrationHelper.migrate("#{Rails.root}/db/migrate/single_session") + User.reset_column_information + end + + after(:all) do + MigrationHelper.rollback("#{Rails.root}/db/migrate/single_session") + end + + it_behaves_like 'rails_single_session_model' +end diff --git a/spec/controllers/controller_single_session_spec.rb b/spec/controllers/controller_single_session_spec.rb new file mode 100644 index 00000000..e1109602 --- /dev/null +++ b/spec/controllers/controller_single_session_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +describe SorceryController, type: :controller do + let!(:user) { double('user', id: 42) } + + context 'with session token features' do + before(:all) do + sorcery_reload!([:single_session]) + end + + after(:all) do + sorcery_controller_property_set(:verify_session_token_enabled, false) + end + + before(:each) do + allow(user).to receive(:session_token) { 'valid-session-token' } + allow(user).to receive(:regenerate_session_token) { 'valid-session-token' } + + allow(user).to receive(:email) + allow(user).to receive_message_chain(:sorcery_config, :username_attribute_names, :first) { :email } + end + + it 'does not reset session if token is valid' do + login_user user + session[:token] = 'valid-session-token' + + get :test_should_be_logged_in + + expect(session[:user_id]).not_to be_nil + expect(response).to be_successful + end + + it 'does reset session if token is invalid' do + login_user user + session[:token] = 'invalid-session-token' + + get :test_should_be_logged_in + + expect(session[:user_id]).to be_nil + expect(response).not_to be_successful + end + + it 'regenerates token on login' do + expect(user).to receive(:regenerate_session_token) + login_user user + end + end +end diff --git a/spec/rails_app/db/migrate/single_session/20191221223622_add_session_token_to_users.rb b/spec/rails_app/db/migrate/single_session/20191221223622_add_session_token_to_users.rb new file mode 100644 index 00000000..395616e7 --- /dev/null +++ b/spec/rails_app/db/migrate/single_session/20191221223622_add_session_token_to_users.rb @@ -0,0 +1,5 @@ +class AddSessionTokenToUsers < ActiveRecord::CompatibleLegacyMigration.migration_class + def change + add_column :users, :session_token, :string, default: nil + end +end diff --git a/spec/shared_examples/user_single_session_shared_examples.rb b/spec/shared_examples/user_single_session_shared_examples.rb new file mode 100644 index 00000000..5b763036 --- /dev/null +++ b/spec/shared_examples/user_single_session_shared_examples.rb @@ -0,0 +1,42 @@ +shared_examples_for 'rails_single_session_model' do + # ----------------- PLUGIN CONFIGURATION ----------------------- + let(:user) { create_new_user } + + describe 'loaded plugin configuration' do + before(:all) do + sorcery_reload!([:single_session]) + end + + after(:each) do + User.sorcery_config.reset! + end + + context 'API' do + specify { expect(user).to respond_to :session_token } + + specify { expect(user).to respond_to :regenerate_session_token } + end + + it "allows configuration option 'session_token_attribute_name'" do + sorcery_model_property_set(:session_token_attribute_name, :random_token) + + expect(User.sorcery_config.session_token_attribute_name).to eq :random_token + end + end + + describe 'when activated with sorcery' do + before(:all) do + sorcery_reload!([:single_session]) + end + + describe '#regenerate_session_token' do + it 'generates and updates user record with new random session token' do + expect(user.session_token).to be_nil + + token = user.regenerate_session_token + + expect(user.session_token).to eq token + end + end + end +end