diff options
Diffstat (limited to 'app/models/active_session.rb')
-rw-r--r-- | app/models/active_session.rb | 174 |
1 files changed, 122 insertions, 52 deletions
diff --git a/app/models/active_session.rb b/app/models/active_session.rb index a0e74c7f48e..0094d98fb73 100644 --- a/app/models/active_session.rb +++ b/app/models/active_session.rb @@ -4,7 +4,7 @@ # # The raw session information is stored by the Rails session store # (config/initializers/session_store.rb). These entries are accessible by the -# rack_key_name class method and consistute the base of the session data +# rack_key_name class method and constitute the base of the session data # entries. All other entries in the session store can be traced back to these # entries. # @@ -21,14 +21,24 @@ # class ActiveSession include ActiveModel::Model + include ::Gitlab::Redis::SessionsStoreHelper SESSION_BATCH_SIZE = 200 ALLOWED_NUMBER_OF_ACTIVE_SESSIONS = 100 - attr_accessor :created_at, :updated_at, - :ip_address, :browser, :os, - :device_name, :device_type, - :is_impersonated, :session_id, :session_private_id + attr_accessor :ip_address, :browser, :os, + :device_name, :device_type, + :is_impersonated, :session_id, :session_private_id + + attr_reader :created_at, :updated_at + + def created_at=(time) + @created_at = time.is_a?(String) ? Time.zone.parse(time) : time + end + + def updated_at=(time) + @updated_at = time.is_a?(String) ? Time.zone.parse(time) : time + end def current?(rack_session) return false if session_private_id.nil? || rack_session.id.nil? @@ -38,15 +48,29 @@ class ActiveSession session_private_id == rack_session.id.private_id end + def eql?(other) + other.is_a?(self.class) && id == other.id + end + alias_method :==, :eql? + + def id + session_private_id.presence || session_id + end + + def ids + [session_private_id, session_id].compact + end + def human_device_type device_type&.titleize end def self.set(user, request) - Gitlab::Redis::SharedState.with do |redis| + redis_store_class.with do |redis| session_private_id = request.session.id.private_id client = DeviceDetector.new(request.user_agent) timestamp = Time.current + expiry = Settings.gitlab['session_expire_delay'] * 60 active_user_session = new( ip_address: request.remote_ip, @@ -63,7 +87,14 @@ class ActiveSession redis.pipelined do redis.setex( key_name(user.id, session_private_id), - Settings.gitlab['session_expire_delay'] * 60, + expiry, + active_user_session.dump + ) + + # Deprecated legacy format - temporary to support mixed deployments + redis.setex( + key_name_v1(user.id, session_private_id), + expiry, Marshal.dump(active_user_session) ) @@ -76,7 +107,7 @@ class ActiveSession end def self.list(user) - Gitlab::Redis::SharedState.with do |redis| + redis_store_class.with do |redis| cleaned_up_lookup_entries(redis, user).map do |raw_session| load_raw_session(raw_session) end @@ -84,14 +115,17 @@ class ActiveSession end def self.cleanup(user) - Gitlab::Redis::SharedState.with do |redis| + redis_store_class.with do |redis| clean_up_old_sessions(redis, user) cleaned_up_lookup_entries(redis, user) end end def self.destroy_sessions(redis, user, session_ids) + return if session_ids.empty? + key_names = session_ids.map { |session_id| key_name(user.id, session_id) } + key_names += session_ids.map { |session_id| key_name_v1(user.id, session_id) } redis.srem(lookup_key_name(user.id), session_ids) @@ -104,7 +138,7 @@ class ActiveSession def self.destroy_session(user, session_id) return unless session_id - Gitlab::Redis::SharedState.with do |redis| + redis_store_class.with do |redis| destroy_sessions(redis, user, [session_id].compact) end end @@ -113,26 +147,31 @@ class ActiveSession sessions = not_impersonated(user) sessions.reject! { |session| session.current?(current_rack_session) } if current_rack_session - Gitlab::Redis::SharedState.with do |redis| - session_ids = (sessions.map(&:session_id) | sessions.map(&:session_private_id)).compact + redis_store_class.with do |redis| + session_ids = sessions.flat_map(&:ids) destroy_sessions(redis, user, session_ids) if session_ids.any? end end - def self.not_impersonated(user) + private_class_method def self.not_impersonated(user) list(user).reject(&:is_impersonated) end - def self.rack_key_name(session_id) - "#{Gitlab::Redis::SharedState::SESSION_NAMESPACE}:#{session_id}" + private_class_method def self.rack_key_name(session_id) + "#{Gitlab::Redis::Sessions::SESSION_NAMESPACE}:#{session_id}" end def self.key_name(user_id, session_id = '*') - "#{Gitlab::Redis::SharedState::USER_SESSIONS_NAMESPACE}:#{user_id}:#{session_id}" + "#{Gitlab::Redis::Sessions::USER_SESSIONS_NAMESPACE}::v2:#{user_id}:#{session_id}" + end + + # Deprecated + def self.key_name_v1(user_id, session_id = '*') + "#{Gitlab::Redis::Sessions::USER_SESSIONS_NAMESPACE}:#{user_id}:#{session_id}" end def self.lookup_key_name(user_id) - "#{Gitlab::Redis::SharedState::USER_SESSIONS_LOOKUP_NAMESPACE}:#{user_id}" + "#{Gitlab::Redis::Sessions::USER_SESSIONS_LOOKUP_NAMESPACE}:#{user_id}" end def self.list_sessions(user) @@ -143,7 +182,7 @@ class ActiveSession # # Returns an array of strings def self.session_ids_for_user(user_id) - Gitlab::Redis::SharedState.with do |redis| + redis_store_class.with do |redis| redis.smembers(lookup_key_name(user_id)) end end @@ -156,7 +195,7 @@ class ActiveSession def self.sessions_from_ids(session_ids) return [] if session_ids.empty? - Gitlab::Redis::SharedState.with do |redis| + redis_store_class.with do |redis| session_keys = rack_session_keys(session_ids) session_keys.each_slice(SESSION_BATCH_SIZE).flat_map do |session_keys_batch| @@ -169,71 +208,102 @@ class ActiveSession end end - # Deserializes a session Hash object from Redis. - # + def dump + "v2:#{Gitlab::Json.dump(self)}" + end + + # Private: + # raw_session - Raw bytes from Redis # - # Returns an ActiveSession object - def self.load_raw_session(raw_session) - # rubocop:disable Security/MarshalLoad - Marshal.load(raw_session) - # rubocop:enable Security/MarshalLoad + # Returns an instance of this class + private_class_method def self.load_raw_session(raw_session) + return unless raw_session + + if raw_session.start_with?('v2:') + session_data = Gitlab::Json.parse(raw_session[3..]).symbolize_keys + new(**session_data) + else + # Deprecated legacy format. To be removed in 15.0 + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/30516 + # Explanation of why this Marshal.load call is OK: + # https://gitlab.com/gitlab-com/gl-security/appsec/appsec-reviews/-/issues/124#note_744576714 + # rubocop:disable Security/MarshalLoad + Marshal.load(raw_session) + # rubocop:enable Security/MarshalLoad + end end - def self.rack_session_keys(rack_session_ids) - rack_session_ids.map { |session_id| rack_key_name(session_id)} + private_class_method def self.rack_session_keys(rack_session_ids) + rack_session_ids.map { |session_id| rack_key_name(session_id) } end - def self.raw_active_session_entries(redis, session_ids, user_id) - return [] if session_ids.empty? + private_class_method def self.raw_active_session_entries(redis, session_ids, user_id) + return {} if session_ids.empty? + + found = Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do + entry_keys = session_ids.map { |session_id| key_name(user_id, session_id) } + session_ids.zip(redis.mget(entry_keys)).to_h + end - entry_keys = session_ids.map { |session_id| key_name(user_id, session_id) } + found.compact! + missing = session_ids - found.keys + return found if missing.empty? - Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do - redis.mget(entry_keys) + fallbacks = Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do + entry_keys = missing.map { |session_id| key_name_v1(user_id, session_id) } + missing.zip(redis.mget(entry_keys)).to_h end + + fallbacks.merge(found.compact) end - def self.active_session_entries(session_ids, user_id, redis) + private_class_method def self.active_session_entries(session_ids, user_id, redis) return [] if session_ids.empty? - entry_keys = raw_active_session_entries(redis, session_ids, user_id) - - entry_keys.compact.map do |raw_session| - load_raw_session(raw_session) - end + raw_active_session_entries(redis, session_ids, user_id) + .values + .compact + .map { load_raw_session(_1) } end - def self.clean_up_old_sessions(redis, user) + private_class_method def self.clean_up_old_sessions(redis, user) session_ids = session_ids_for_user(user.id) return if session_ids.count <= ALLOWED_NUMBER_OF_ACTIVE_SESSIONS - # remove sessions if there are more than ALLOWED_NUMBER_OF_ACTIVE_SESSIONS. sessions = active_session_entries(session_ids, user.id, redis) - sessions.sort_by! {|session| session.updated_at }.reverse! - destroyable_sessions = sessions.drop(ALLOWED_NUMBER_OF_ACTIVE_SESSIONS) - destroyable_session_ids = destroyable_sessions.flat_map { |session| [session.session_id, session.session_private_id] }.compact - destroy_sessions(redis, user, destroyable_session_ids) if destroyable_session_ids.any? + sessions.sort_by!(&:updated_at).reverse! + + # remove sessions if there are more than ALLOWED_NUMBER_OF_ACTIVE_SESSIONS. + destroyable_session_ids = sessions + .drop(ALLOWED_NUMBER_OF_ACTIVE_SESSIONS) + .flat_map(&:ids) + + destroy_sessions(redis, user, destroyable_session_ids) end # Cleans up the lookup set by removing any session IDs that are no longer present. # # Returns an array of marshalled ActiveModel objects that are still active. - def self.cleaned_up_lookup_entries(redis, user) + # Records removed keys in the optional `removed` argument array. + def self.cleaned_up_lookup_entries(redis, user, removed = []) + lookup_key = lookup_key_name(user.id) session_ids = session_ids_for_user(user.id) - entries = raw_active_session_entries(redis, session_ids, user.id) + session_ids_and_entries = raw_active_session_entries(redis, session_ids, user.id) # remove expired keys. # only the single key entries are automatically expired by redis, the # lookup entries in the set need to be removed manually. - session_ids_and_entries = session_ids.zip(entries) - redis.pipelined do - session_ids_and_entries.reject { |_session_id, entry| entry }.each do |session_id, _entry| - redis.srem(lookup_key_name(user.id), session_id) + redis.pipelined do |pipeline| + session_ids_and_entries.each do |session_id, entry| + next if entry + + pipeline.srem(lookup_key, session_id) + removed << session_id end end - entries.compact + session_ids_and_entries.values.compact end end |