diff --git a/app/models/concerns/session_manageable.rb b/app/models/concerns/session_manageable.rb new file mode 100644 index 000000000..b7143084e --- /dev/null +++ b/app/models/concerns/session_manageable.rb @@ -0,0 +1,78 @@ +# Provides session management functionality for Devise Token Auth tokens. +# Handles logout operations, session limits, and token cleanup. +module SessionManageable + extend ActiveSupport::Concern + + # Clears all active sessions for the user. + # @return [void] + def logout_all_sessions! + self.tokens = {} + save! + end + + # Logs out a specific session by client ID. + # @param client_id [String] the client identifier to logout + # @return [Boolean] true if session was found and removed + def logout_session!(client_id) + return false unless client_id.present? && tokens.present? + + removed = tokens.delete(client_id) + save! if removed + + removed.present? + end + + # Removes tokens that expired before the given timestamp. + # @param timestamp [Time, Integer] cutoff time for token cleanup + # @return [void] + def reset_tokens_before!(timestamp) + return unless tokens.present? + + self.tokens = tokens.select do |_client_id, token_data| + (token_data['expiry'] || 0) >= timestamp.to_i + end + + save! + end + + # Returns count of non-expired active sessions. + # @return [Integer] number of active sessions + def active_session_count + return 0 unless tokens.present? + + current_time = Time.current.to_i + tokens.count { |_client_id, token_data| (token_data['expiry'] || 0) > current_time } + end + + # Checks if user has exceeded configured session limit. + # @return [Boolean] true if session limit is exceeded + def session_limit_exceeded? + active_session_count >= session_limit + end + + # Returns session information for all active tokens. + # @return [Array] array of session info, sorted by expiry (newest first) + 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 + + # Returns configured session limit from GlobalConfig. + # Defaults to infinity if not configured. + # @return [Integer, Float] session limit or Float::INFINITY + def session_limit + @session_limit ||= GlobalConfig.get( + 'USER_SESSION_LIMIT', + 'USER_SESSION_LIMIT_PER_USER', + account: Current.account + )&.to_i || Float::INFINITY + end +end diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb index 8f6c31c78..1ad9b5b8a 100644 --- a/config/initializers/omniauth.rb +++ b/config/initializers/omniauth.rb @@ -2,6 +2,19 @@ # SAML authentication is sensitive to URL mismatches, so OmniAuth needs the correct host OmniAuth.config.full_host = ENV.fetch('FRONTEND_URL', 'http://localhost:3000') +# Handle IdP-initiated Single Logout (SLO) session destruction +def handle_saml_slo_session_destroy(env, _session) + # Extract user info from SAML logout request + logout_request = env['omniauth.strategy'].response_object + name_id = logout_request.name_id if logout_request.respond_to?(:name_id) + + return unless name_id + + # Find user by email and verify it's a SAML user + user = User.from_email(name_id) + user&.logout_all_sessions! if user&.provider == 'saml' +end + Rails.application.config.middleware.use OmniAuth::Builder do provider :google_oauth2, ENV.fetch('GOOGLE_OAUTH_CLIENT_ID', nil), ENV.fetch('GOOGLE_OAUTH_CLIENT_SECRET', nil), { provider_ignores_state: true @@ -34,6 +47,7 @@ Rails.application.config.middleware.use OmniAuth::Builder do env['omniauth.strategy'].options[:idp_sso_service_url] = settings.sso_url env['omniauth.strategy'].options[:idp_cert] = settings.certificate env['omniauth.strategy'].options[:name_identifier_format] = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress' + env['omniauth.strategy'].options[:idp_slo_session_destroy] = method(:handle_saml_slo_session_destroy) else # Set a dummy certificate to avoid the error env['omniauth.strategy'].options[:idp_cert] = 'DUMMY' diff --git a/enterprise/app/controllers/enterprise/devise_overrides/sessions_controller.rb b/enterprise/app/controllers/enterprise/devise_overrides/sessions_controller.rb index fae0298cc..12900773b 100644 --- a/enterprise/app/controllers/enterprise/devise_overrides/sessions_controller.rb +++ b/enterprise/app/controllers/enterprise/devise_overrides/sessions_controller.rb @@ -16,6 +16,10 @@ module Enterprise::DeviseOverrides::SessionsController def destroy create_audit_event('sign_out') + + # Logout all sessions for SAML users + @resource.logout_all_sessions! if @resource&.provider == 'saml' + super end diff --git a/spec/models/concerns/session_manageable_spec.rb b/spec/models/concerns/session_manageable_spec.rb new file mode 100644 index 000000000..36a733fc3 --- /dev/null +++ b/spec/models/concerns/session_manageable_spec.rb @@ -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