Commit 0acbdfa9 authored by Stan Hu's avatar Stan Hu

Merge branch 'ajk-30516-change-session-serialization' into 'master'

Use Gitlab::Json to serialize sessions

See merge request gitlab-org/gitlab!72701
parents 8de5854f 34a65da9
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
# #
# The raw session information is stored by the Rails session store # The raw session information is stored by the Rails session store
# (config/initializers/session_store.rb). These entries are accessible by the # (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. All other entries in the session store can be traced back to these
# entries. # entries.
# #
...@@ -26,10 +26,19 @@ class ActiveSession ...@@ -26,10 +26,19 @@ class ActiveSession
SESSION_BATCH_SIZE = 200 SESSION_BATCH_SIZE = 200
ALLOWED_NUMBER_OF_ACTIVE_SESSIONS = 100 ALLOWED_NUMBER_OF_ACTIVE_SESSIONS = 100
attr_accessor :created_at, :updated_at, attr_accessor :ip_address, :browser, :os,
:ip_address, :browser, :os, :device_name, :device_type,
:device_name, :device_type, :is_impersonated, :session_id, :session_private_id
: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) def current?(rack_session)
return false if session_private_id.nil? || rack_session.id.nil? return false if session_private_id.nil? || rack_session.id.nil?
...@@ -39,6 +48,19 @@ class ActiveSession ...@@ -39,6 +48,19 @@ class ActiveSession
session_private_id == rack_session.id.private_id session_private_id == rack_session.id.private_id
end 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 def human_device_type
device_type&.titleize device_type&.titleize
end end
...@@ -48,6 +70,7 @@ class ActiveSession ...@@ -48,6 +70,7 @@ class ActiveSession
session_private_id = request.session.id.private_id session_private_id = request.session.id.private_id
client = DeviceDetector.new(request.user_agent) client = DeviceDetector.new(request.user_agent)
timestamp = Time.current timestamp = Time.current
expiry = Settings.gitlab['session_expire_delay'] * 60
active_user_session = new( active_user_session = new(
ip_address: request.remote_ip, ip_address: request.remote_ip,
...@@ -64,7 +87,14 @@ class ActiveSession ...@@ -64,7 +87,14 @@ class ActiveSession
redis.pipelined do redis.pipelined do
redis.setex( redis.setex(
key_name(user.id, session_private_id), 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) Marshal.dump(active_user_session)
) )
...@@ -92,7 +122,10 @@ class ActiveSession ...@@ -92,7 +122,10 @@ class ActiveSession
end end
def self.destroy_sessions(redis, user, session_ids) 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(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) redis.srem(lookup_key_name(user.id), session_ids)
...@@ -115,20 +148,25 @@ class ActiveSession ...@@ -115,20 +148,25 @@ class ActiveSession
sessions.reject! { |session| session.current?(current_rack_session) } if current_rack_session sessions.reject! { |session| session.current?(current_rack_session) } if current_rack_session
redis_store_class.with do |redis| redis_store_class.with do |redis|
session_ids = (sessions.map(&:session_id) | sessions.map(&:session_private_id)).compact session_ids = sessions.flat_map(&:ids)
destroy_sessions(redis, user, session_ids) if session_ids.any? destroy_sessions(redis, user, session_ids) if session_ids.any?
end end
end end
def self.not_impersonated(user) private_class_method def self.not_impersonated(user)
list(user).reject(&:is_impersonated) list(user).reject(&:is_impersonated)
end end
def self.rack_key_name(session_id) private_class_method def self.rack_key_name(session_id)
"#{Gitlab::Redis::Sessions::SESSION_NAMESPACE}:#{session_id}" "#{Gitlab::Redis::Sessions::SESSION_NAMESPACE}:#{session_id}"
end end
def self.key_name(user_id, session_id = '*') def self.key_name(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}" "#{Gitlab::Redis::Sessions::USER_SESSIONS_NAMESPACE}:#{user_id}:#{session_id}"
end end
...@@ -170,73 +208,102 @@ class ActiveSession ...@@ -170,73 +208,102 @@ class ActiveSession
end end
end end
# Deserializes a session Hash object from Redis. def dump
# "v2:#{Gitlab::Json.dump(self)}"
end
# Private:
# raw_session - Raw bytes from Redis # raw_session - Raw bytes from Redis
# #
# Returns an ActiveSession object # Returns an instance of this class
def self.load_raw_session(raw_session) private_class_method def self.load_raw_session(raw_session)
# rubocop:disable Security/MarshalLoad return unless raw_session
# Explanation of why this Marshal.load call is OK:
# https://gitlab.com/gitlab-com/gl-security/appsec/appsec-reviews/-/issues/124#note_744576714 if raw_session.start_with?('v2:')
Marshal.load(raw_session) session_data = Gitlab::Json.parse(raw_session[3..]).symbolize_keys
# rubocop:enable Security/MarshalLoad 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 end
def self.rack_session_keys(rack_session_ids) private_class_method def self.rack_session_keys(rack_session_ids)
rack_session_ids.map { |session_id| rack_key_name(session_id)} rack_session_ids.map { |session_id| rack_key_name(session_id) }
end end
def self.raw_active_session_entries(redis, session_ids, user_id) private_class_method def self.raw_active_session_entries(redis, session_ids, user_id)
return [] if session_ids.empty? 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 fallbacks = Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
redis.mget(entry_keys) entry_keys = missing.map { |session_id| key_name_v1(user_id, session_id) }
missing.zip(redis.mget(entry_keys)).to_h
end end
fallbacks.merge(found.compact)
end 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? return [] if session_ids.empty?
entry_keys = raw_active_session_entries(redis, session_ids, user_id) raw_active_session_entries(redis, session_ids, user_id)
.values
entry_keys.compact.map do |raw_session| .compact
load_raw_session(raw_session) .map { load_raw_session(_1) }
end
end 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) session_ids = session_ids_for_user(user.id)
return if session_ids.count <= ALLOWED_NUMBER_OF_ACTIVE_SESSIONS 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 = active_session_entries(session_ids, user.id, redis)
sessions.sort_by! {|session| session.updated_at }.reverse! sessions.sort_by!(&: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 # remove sessions if there are more than ALLOWED_NUMBER_OF_ACTIVE_SESSIONS.
destroy_sessions(redis, user, destroyable_session_ids) if destroyable_session_ids.any? destroyable_session_ids = sessions
.drop(ALLOWED_NUMBER_OF_ACTIVE_SESSIONS)
.flat_map(&:ids)
destroy_sessions(redis, user, destroyable_session_ids)
end end
# Cleans up the lookup set by removing any session IDs that are no longer present. # 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. # 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) 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. # remove expired keys.
# only the single key entries are automatically expired by redis, the # only the single key entries are automatically expired by redis, the
# lookup entries in the set need to be removed manually. # lookup entries in the set need to be removed manually.
session_ids_and_entries = session_ids.zip(entries)
redis.pipelined do |pipeline| redis.pipelined do |pipeline|
session_ids_and_entries.reject { |_session_id, entry| entry }.each do |session_id, _entry| session_ids_and_entries.each do |session_id, entry|
pipeline.srem(lookup_key_name(user.id), session_id) next if entry
pipeline.srem(lookup_key, session_id)
removed << session_id
end end
end end
entries.compact session_ids_and_entries.values.compact
end end
end end
...@@ -121,27 +121,16 @@ namespace :gitlab do ...@@ -121,27 +121,16 @@ namespace :gitlab do
last_save_check = Time.now last_save_check = Time.now
end end
user = Struct.new(:id)
keys.each do |key| keys.each do |key|
user_id = key.split(':').last user_id = key.split(':').last
lookup_key_count = redis.scard(key) removed = []
active = ActiveSession.cleaned_up_lookup_entries(redis, user.new(user_id), removed)
session_ids = ActiveSession.session_ids_for_user(user_id)
entries = ActiveSession.raw_active_session_entries(redis, session_ids, user_id)
session_ids_and_entries = session_ids.zip(entries)
inactive_session_ids = session_ids_and_entries.map do |session_id, session|
session_id if session.nil?
end.compact
redis.pipelined do |conn|
inactive_session_ids.each do |session_id|
conn.srem(key, session_id)
end
end
if inactive_session_ids if removed.any?
puts "deleted #{inactive_session_ids.count} out of #{lookup_key_count} lookup keys for User ##{user_id}" puts "deleted #{removed.count} out of #{active.count + removed.count} lookup keys for User ##{user_id}"
end end
end end
end while cursor.to_i != 0 end while cursor.to_i != 0
......
This diff is collapsed.
...@@ -174,9 +174,9 @@ RSpec.describe 'gitlab:cleanup rake tasks', :silence_stdout do ...@@ -174,9 +174,9 @@ RSpec.describe 'gitlab:cleanup rake tasks', :silence_stdout do
before do before do
Gitlab::Redis::Sessions.with do |redis| Gitlab::Redis::Sessions.with do |redis|
redis.set("session:user:gitlab:#{user.id}:#{existing_session_id}", redis.set(ActiveSession.key_name(user.id, existing_session_id),
Marshal.dump(true)) ActiveSession.new(session_id: 'x').dump)
redis.sadd("session:lookup:user:gitlab:#{user.id}", (1..10).to_a) redis.sadd(ActiveSession.lookup_key_name(user.id), (1..10).to_a)
end end
end end
...@@ -186,10 +186,10 @@ RSpec.describe 'gitlab:cleanup rake tasks', :silence_stdout do ...@@ -186,10 +186,10 @@ RSpec.describe 'gitlab:cleanup rake tasks', :silence_stdout do
it 'removes expired active session lookup keys' do it 'removes expired active session lookup keys' do
Gitlab::Redis::Sessions.with do |redis| Gitlab::Redis::Sessions.with do |redis|
lookup_key = "session:lookup:user:gitlab:#{user.id}" lookup_key = ActiveSession.lookup_key_name(user.id)
expect { subject }.to change { redis.scard(lookup_key) }.from(10).to(1) expect { subject }.to change { redis.scard(lookup_key) }.from(10).to(1)
expect(redis.smembers("session:lookup:user:gitlab:#{user.id}")).to( expect(redis.smembers(lookup_key)).to contain_exactly existing_session_id
eql([existing_session_id]))
end end
end end
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment