mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 19:17:48 +00:00 
			
		
		
		
	feat: add session managable concern
This commit is contained in:
		
							
								
								
									
										63
									
								
								app/models/concerns/session_manageable.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								app/models/concerns/session_manageable.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | ||||
| module SessionManageable | ||||
|   extend ActiveSupport::Concern | ||||
|  | ||||
|   def logout_all_sessions! | ||||
|     # Clear all devise token auth tokens | ||||
|     self.tokens = {} | ||||
|     save! | ||||
|   end | ||||
|  | ||||
|   def logout_session!(client_id) | ||||
|     return false unless client_id.present? && tokens.present? | ||||
|  | ||||
|     # Remove specific client token | ||||
|     removed = tokens.delete(client_id) | ||||
|     save! if removed | ||||
|  | ||||
|     removed.present? | ||||
|   end | ||||
|  | ||||
|   def reset_tokens_before!(timestamp) | ||||
|     return unless tokens.present? | ||||
|  | ||||
|     # Remove tokens that expired before the given timestamp | ||||
|     self.tokens = tokens.select do |_client_id, token_data| | ||||
|       (token_data['expiry'] || 0) >= timestamp.to_i | ||||
|     end | ||||
|  | ||||
|     save! | ||||
|   end | ||||
|  | ||||
|   def active_session_count | ||||
|     return 0 unless tokens.present? | ||||
|  | ||||
|     # Count only non-expired tokens | ||||
|     current_time = Time.current.to_i | ||||
|     tokens.count { |_client_id, token_data| (token_data['expiry'] || 0) > current_time } | ||||
|   end | ||||
|  | ||||
|   def session_limit_exceeded? | ||||
|     active_session_count >= session_limit | ||||
|   end | ||||
|  | ||||
|   def session_info | ||||
|     return [] unless tokens.present? | ||||
|  | ||||
|     tokens.map do |client_id, token_data| | ||||
|       { | ||||
|         client_id: client_id, | ||||
|         expiry: Time.zone.at(token_data['expiry'] || 0) | ||||
|       } | ||||
|     end.sort_by { |session| session[:expiry] }.reverse | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def session_limit | ||||
|     @session_limit ||= GlobalConfig.get( | ||||
|       'USER_SESSION_LIMIT', | ||||
|       'USER_SESSION_LIMIT_PER_USER', | ||||
|       account: Current.account | ||||
|     )&.to_i || Float::INFINITY | ||||
|   end | ||||
| end | ||||
| @@ -47,6 +47,7 @@ class User < ApplicationRecord | ||||
|   include Pubsubable | ||||
|   include Rails.application.routes.url_helpers | ||||
|   include Reportable | ||||
|   include SessionManageable | ||||
|   include SsoAuthenticatable | ||||
|   include UserAttributeHelpers | ||||
|  | ||||
|   | ||||
							
								
								
									
										272
									
								
								spec/models/concerns/session_manageable_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										272
									
								
								spec/models/concerns/session_manageable_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,272 @@ | ||||
| require 'rails_helper' | ||||
|  | ||||
| RSpec.describe SessionManageable do | ||||
|   let(:user) { create(:user) } | ||||
|   let(:current_time) { Time.current.to_i } | ||||
|   let(:expired_time) { 1.hour.ago.to_i } | ||||
|   let(:future_time) { 1.hour.from_now.to_i } | ||||
|  | ||||
|   before do | ||||
|     # Mock GlobalConfig for session limit | ||||
|     allow(GlobalConfig).to receive(:get).with( | ||||
|       'USER_SESSION_LIMIT', | ||||
|       'USER_SESSION_LIMIT_PER_USER', | ||||
|       account: nil | ||||
|     ).and_return('5') | ||||
|   end | ||||
|  | ||||
|   describe '#logout_all_sessions!' do | ||||
|     it 'clears all tokens and saves the user' do | ||||
|       user.tokens = { | ||||
|         'client1' => { 'token' => 'hashed_token_1', 'expiry' => future_time }, | ||||
|         'client2' => { 'token' => 'hashed_token_2', 'expiry' => future_time } | ||||
|       } | ||||
|  | ||||
|       expect(user).to receive(:save!) | ||||
|       user.logout_all_sessions! | ||||
|  | ||||
|       expect(user.tokens).to eq({}) | ||||
|     end | ||||
|  | ||||
|     it 'works with empty tokens' do | ||||
|       user.tokens = {} | ||||
|       expect(user).to receive(:save!) | ||||
|       user.logout_all_sessions! | ||||
|       expect(user.tokens).to eq({}) | ||||
|     end | ||||
|  | ||||
|     it 'works with nil tokens' do | ||||
|       user.tokens = nil | ||||
|       expect(user).to receive(:save!) | ||||
|       user.logout_all_sessions! | ||||
|       expect(user.tokens).to eq({}) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   describe '#logout_session!' do | ||||
|     before do | ||||
|       user.tokens = { | ||||
|         'client1' => { 'token' => 'hashed_token_1', 'expiry' => future_time }, | ||||
|         'client2' => { 'token' => 'hashed_token_2', 'expiry' => future_time } | ||||
|       } | ||||
|     end | ||||
|  | ||||
|     it 'removes specific client token and saves' do | ||||
|       expect(user).to receive(:save!) | ||||
|       result = user.logout_session!('client1') | ||||
|  | ||||
|       expect(result).to be true | ||||
|       expect(user.tokens).not_to have_key('client1') | ||||
|       expect(user.tokens).to have_key('client2') | ||||
|     end | ||||
|  | ||||
|     it 'returns false for non-existent client' do | ||||
|       result = user.logout_session!('non_existent_client') | ||||
|       expect(result).to be false | ||||
|     end | ||||
|  | ||||
|     it 'returns false for empty client_id' do | ||||
|       result = user.logout_session!('') | ||||
|       expect(result).to be false | ||||
|  | ||||
|       result = user.logout_session!(nil) | ||||
|       expect(result).to be false | ||||
|     end | ||||
|  | ||||
|     it 'returns false when no tokens present' do | ||||
|       user.tokens = nil | ||||
|       result = user.logout_session!('client1') | ||||
|       expect(result).to be false | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   describe '#reset_tokens_before!' do | ||||
|     before do | ||||
|       user.tokens = { | ||||
|         'expired_client' => { 'token' => 'token1', 'expiry' => expired_time }, | ||||
|         'current_client' => { 'token' => 'token2', 'expiry' => future_time }, | ||||
|         'edge_case_client' => { 'token' => 'token3', 'expiry' => current_time } | ||||
|       } | ||||
|     end | ||||
|  | ||||
|     it 'removes tokens that expired before the given timestamp' do | ||||
|       expect(user).to receive(:save!) | ||||
|       user.reset_tokens_before!(current_time) | ||||
|  | ||||
|       expect(user.tokens).not_to have_key('expired_client') | ||||
|       expect(user.tokens).to have_key('current_client') | ||||
|       expect(user.tokens).to have_key('edge_case_client') | ||||
|     end | ||||
|  | ||||
|     it 'handles timestamp as Time object' do | ||||
|       timestamp = Time.zone.at(current_time) | ||||
|       expect(user).to receive(:save!) | ||||
|       user.reset_tokens_before!(timestamp) | ||||
|  | ||||
|       expect(user.tokens).not_to have_key('expired_client') | ||||
|       expect(user.tokens).to have_key('current_client') | ||||
|     end | ||||
|  | ||||
|     it 'does nothing when no tokens present' do | ||||
|       user.tokens = nil | ||||
|       user.reset_tokens_before!(current_time) | ||||
|       # tokens gets initialized to empty hash during the process | ||||
|       expect(user.tokens).to eq({}) | ||||
|     end | ||||
|  | ||||
|     it 'handles tokens with missing expiry' do | ||||
|       user.tokens = { | ||||
|         'no_expiry_client' => { 'token' => 'token1' }, | ||||
|         'zero_expiry_client' => { 'token' => 'token2', 'expiry' => 0 } | ||||
|       } | ||||
|  | ||||
|       expect(user).to receive(:save!) | ||||
|       user.reset_tokens_before!(current_time) | ||||
|  | ||||
|       expect(user.tokens).to be_empty | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   describe '#active_session_count' do | ||||
|     it 'counts only non-expired tokens' do | ||||
|       user.tokens = { | ||||
|         'expired1' => { 'token' => 'token1', 'expiry' => expired_time }, | ||||
|         'active1' => { 'token' => 'token2', 'expiry' => future_time }, | ||||
|         'active2' => { 'token' => 'token3', 'expiry' => future_time }, | ||||
|         'expired2' => { 'token' => 'token4', 'expiry' => expired_time } | ||||
|       } | ||||
|  | ||||
|       expect(user.active_session_count).to eq(2) | ||||
|     end | ||||
|  | ||||
|     it 'returns 0 when no tokens present' do | ||||
|       user.tokens = nil | ||||
|       expect(user.active_session_count).to eq(0) | ||||
|  | ||||
|       user.tokens = {} | ||||
|       expect(user.active_session_count).to eq(0) | ||||
|     end | ||||
|  | ||||
|     it 'handles tokens with missing expiry' do | ||||
|       user.tokens = { | ||||
|         'no_expiry' => { 'token' => 'token1' }, | ||||
|         'active' => { 'token' => 'token2', 'expiry' => future_time } | ||||
|       } | ||||
|  | ||||
|       expect(user.active_session_count).to eq(1) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   describe '#session_limit_exceeded?' do | ||||
|     it 'returns true when active sessions exceed limit' do | ||||
|       # Mock 3 session limit | ||||
|       allow(GlobalConfig).to receive(:get).and_return('3') | ||||
|  | ||||
|       user.tokens = { | ||||
|         'active1' => { 'token' => 'token1', 'expiry' => future_time }, | ||||
|         'active2' => { 'token' => 'token2', 'expiry' => future_time }, | ||||
|         'active3' => { 'token' => 'token3', 'expiry' => future_time }, | ||||
|         'active4' => { 'token' => 'token4', 'expiry' => future_time } | ||||
|       } | ||||
|  | ||||
|       expect(user.session_limit_exceeded?).to be true | ||||
|     end | ||||
|  | ||||
|     it 'returns false when within limit' do | ||||
|       allow(GlobalConfig).to receive(:get).and_return('5') | ||||
|  | ||||
|       user.tokens = { | ||||
|         'active1' => { 'token' => 'token1', 'expiry' => future_time }, | ||||
|         'active2' => { 'token' => 'token2', 'expiry' => future_time } | ||||
|       } | ||||
|  | ||||
|       expect(user.session_limit_exceeded?).to be false | ||||
|     end | ||||
|  | ||||
|     it 'handles infinite limit' do | ||||
|       allow(GlobalConfig).to receive(:get).and_return(nil) | ||||
|  | ||||
|       user.tokens = {} | ||||
|       (1..100).each do |i| | ||||
|         user.tokens["client#{i}"] = { 'token' => "token#{i}", 'expiry' => future_time } | ||||
|       end | ||||
|  | ||||
|       expect(user.session_limit_exceeded?).to be false | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   describe '#session_info' do | ||||
|     it 'returns session information sorted by expiry (newest first)' do | ||||
|       earlier_time = 2.hours.from_now.to_i | ||||
|       later_time = 3.hours.from_now.to_i | ||||
|  | ||||
|       user.tokens = { | ||||
|         'client1' => { 'token' => 'token1', 'expiry' => earlier_time }, | ||||
|         'client2' => { 'token' => 'token2', 'expiry' => later_time } | ||||
|       } | ||||
|  | ||||
|       sessions = user.session_info | ||||
|  | ||||
|       expect(sessions.size).to eq(2) | ||||
|       expect(sessions[0][:client_id]).to eq('client2') | ||||
|       expect(sessions[0][:expiry]).to eq(Time.zone.at(later_time)) | ||||
|       expect(sessions[1][:client_id]).to eq('client1') | ||||
|       expect(sessions[1][:expiry]).to eq(Time.zone.at(earlier_time)) | ||||
|     end | ||||
|  | ||||
|     it 'returns empty array when no tokens' do | ||||
|       user.tokens = nil | ||||
|       expect(user.session_info).to eq([]) | ||||
|  | ||||
|       user.tokens = {} | ||||
|       expect(user.session_info).to eq([]) | ||||
|     end | ||||
|  | ||||
|     it 'handles tokens with missing expiry' do | ||||
|       user.tokens = { | ||||
|         'client1' => { 'token' => 'token1' }, | ||||
|         'client2' => { 'token' => 'token2', 'expiry' => future_time } | ||||
|       } | ||||
|  | ||||
|       sessions = user.session_info | ||||
|  | ||||
|       expect(sessions.size).to eq(2) | ||||
|       expect(sessions[0][:expiry]).to eq(Time.zone.at(future_time)) | ||||
|       expect(sessions[1][:expiry]).to eq(Time.zone.at(0)) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   describe 'private methods' do | ||||
|     describe '#session_limit' do | ||||
|       it 'returns configured limit from GlobalConfig' do | ||||
|         allow(GlobalConfig).to receive(:get).with( | ||||
|           'USER_SESSION_LIMIT', | ||||
|           'USER_SESSION_LIMIT_PER_USER', | ||||
|           account: nil | ||||
|         ).and_return('10') | ||||
|  | ||||
|         limit = user.send(:session_limit) | ||||
|         expect(limit).to eq(10) | ||||
|       end | ||||
|  | ||||
|       it 'returns infinity when no limit configured' do | ||||
|         allow(GlobalConfig).to receive(:get).and_return(nil) | ||||
|  | ||||
|         limit = user.send(:session_limit) | ||||
|         expect(limit).to eq(Float::INFINITY) | ||||
|       end | ||||
|  | ||||
|       it 'memoizes the result' do | ||||
|         allow(GlobalConfig).to receive(:get).and_return('5') | ||||
|  | ||||
|         # First call | ||||
|         limit1 = user.send(:session_limit) | ||||
|         # Second call should use memoized value | ||||
|         limit2 = user.send(:session_limit) | ||||
|  | ||||
|         expect(limit1).to eq(limit2) | ||||
|         expect(GlobalConfig).to have_received(:get).once | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
		Reference in New Issue
	
	Block a user
	 Shivam Mishra
					Shivam Mishra