2018-07-25 05:30:33 -04:00
|
|
|
# frozen_string_literal: true
|
|
|
|
|
2018-05-02 04:08:16 -04:00
|
|
|
class ActiveSession
|
|
|
|
include ActiveModel::Model
|
|
|
|
|
2019-07-17 07:07:26 -04:00
|
|
|
SESSION_BATCH_SIZE = 200
|
2019-11-27 13:06:30 -05:00
|
|
|
ALLOWED_NUMBER_OF_ACTIVE_SESSIONS = 100
|
2019-07-17 07:07:26 -04:00
|
|
|
|
2018-05-02 04:08:16 -04:00
|
|
|
attr_accessor :created_at, :updated_at,
|
2020-01-03 04:07:33 -05:00
|
|
|
:ip_address, :browser, :os,
|
|
|
|
:device_name, :device_type,
|
2020-10-01 14:10:20 -04:00
|
|
|
:is_impersonated, :session_id, :session_private_id
|
2018-05-02 04:08:16 -04:00
|
|
|
|
2020-10-01 14:10:20 -04:00
|
|
|
def current?(rack_session)
|
|
|
|
return false if session_private_id.nil? || rack_session.id.nil?
|
2018-05-02 04:08:16 -04:00
|
|
|
|
2020-04-14 23:09:11 -04:00
|
|
|
# Rack v2.0.8+ added private_id, which uses the hash of the
|
|
|
|
# public_id to avoid timing attacks.
|
2020-10-01 14:10:20 -04:00
|
|
|
session_private_id == rack_session.id.private_id
|
2018-05-02 04:08:16 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
def human_device_type
|
|
|
|
device_type&.titleize
|
|
|
|
end
|
|
|
|
|
2020-04-14 23:09:11 -04:00
|
|
|
# This is not the same as Rack::Session::SessionId#public_id, but we
|
|
|
|
# need to preserve this for backwards compatibility.
|
2020-10-01 14:10:20 -04:00
|
|
|
# TODO: remove in 13.7
|
2020-01-03 04:07:33 -05:00
|
|
|
def public_id
|
2020-10-01 14:10:20 -04:00
|
|
|
Gitlab::CryptoHelper.aes256_gcm_encrypt(session_id)
|
2020-01-03 04:07:33 -05:00
|
|
|
end
|
|
|
|
|
2018-05-02 04:08:16 -04:00
|
|
|
def self.set(user, request)
|
|
|
|
Gitlab::Redis::SharedState.with do |redis|
|
2020-10-01 14:10:20 -04:00
|
|
|
session_private_id = request.session.id.private_id
|
2018-05-02 04:08:16 -04:00
|
|
|
client = DeviceDetector.new(request.user_agent)
|
|
|
|
timestamp = Time.current
|
|
|
|
|
|
|
|
active_user_session = new(
|
2020-06-01 14:08:07 -04:00
|
|
|
ip_address: request.remote_ip,
|
2018-05-02 04:08:16 -04:00
|
|
|
browser: client.name,
|
|
|
|
os: client.os_name,
|
|
|
|
device_name: client.device_name,
|
|
|
|
device_type: client.device_type,
|
|
|
|
created_at: user.current_sign_in_at || timestamp,
|
|
|
|
updated_at: timestamp,
|
2020-10-01 14:10:20 -04:00
|
|
|
# TODO: remove in 13.7
|
|
|
|
session_id: request.session.id.public_id,
|
|
|
|
session_private_id: session_private_id,
|
2019-02-23 13:18:44 -05:00
|
|
|
is_impersonated: request.session[:impersonator_id].present?
|
2018-05-02 04:08:16 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
redis.pipelined do
|
|
|
|
redis.setex(
|
2020-10-01 14:10:20 -04:00
|
|
|
key_name(user.id, session_private_id),
|
2018-05-02 04:08:16 -04:00
|
|
|
Settings.gitlab['session_expire_delay'] * 60,
|
|
|
|
Marshal.dump(active_user_session)
|
|
|
|
)
|
|
|
|
|
|
|
|
redis.sadd(
|
|
|
|
lookup_key_name(user.id),
|
2020-10-01 14:10:20 -04:00
|
|
|
session_private_id
|
2018-05-02 04:08:16 -04:00
|
|
|
)
|
2020-10-01 14:10:20 -04:00
|
|
|
|
|
|
|
# We remove the ActiveSession stored by using public_id to avoid
|
|
|
|
# duplicate entries
|
|
|
|
remove_deprecated_active_sessions_with_public_id(redis, user.id, request.session.id.public_id)
|
2018-05-02 04:08:16 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2020-10-01 14:10:20 -04:00
|
|
|
# TODO: remove in 13.7
|
|
|
|
private_class_method def self.remove_deprecated_active_sessions_with_public_id(redis, user_id, rack_session_public_id)
|
|
|
|
redis.srem(lookup_key_name(user_id), rack_session_public_id)
|
|
|
|
redis.del(key_name(user_id, rack_session_public_id))
|
|
|
|
end
|
|
|
|
|
2018-05-02 04:08:16 -04:00
|
|
|
def self.list(user)
|
|
|
|
Gitlab::Redis::SharedState.with do |redis|
|
2020-04-14 23:09:11 -04:00
|
|
|
cleaned_up_lookup_entries(redis, user).map do |raw_session|
|
|
|
|
load_raw_session(raw_session)
|
2018-05-02 04:08:16 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2020-10-01 14:10:20 -04:00
|
|
|
def self.cleanup(user)
|
2018-05-02 04:08:16 -04:00
|
|
|
Gitlab::Redis::SharedState.with do |redis|
|
2020-10-01 14:10:20 -04:00
|
|
|
clean_up_old_sessions(redis, user)
|
|
|
|
cleaned_up_lookup_entries(redis, user)
|
2019-11-27 13:06:30 -05:00
|
|
|
end
|
|
|
|
end
|
2018-05-02 04:08:16 -04:00
|
|
|
|
2020-10-01 14:10:20 -04:00
|
|
|
# TODO: remove in 13.7
|
|
|
|
# After upgrade there might be a duplicate ActiveSessions:
|
|
|
|
# - one with the public_id stored in #session_id
|
|
|
|
# - another with private_id stored in #session_private_id
|
|
|
|
def self.destroy_with_rack_session_id(user, rack_session_id)
|
|
|
|
return unless rack_session_id
|
2020-04-14 23:09:11 -04:00
|
|
|
|
2020-10-01 14:10:20 -04:00
|
|
|
Gitlab::Redis::SharedState.with do |redis|
|
|
|
|
destroy_sessions(redis, user, [rack_session_id.public_id, rack_session_id.private_id])
|
|
|
|
end
|
2020-01-03 04:07:33 -05:00
|
|
|
end
|
|
|
|
|
2019-11-27 13:06:30 -05:00
|
|
|
def self.destroy_sessions(redis, user, session_ids)
|
2020-10-01 14:10:20 -04:00
|
|
|
key_names = session_ids.map { |session_id| key_name(user.id, session_id) }
|
2018-05-02 04:08:16 -04:00
|
|
|
|
2020-10-01 14:10:20 -04:00
|
|
|
redis.srem(lookup_key_name(user.id), session_ids)
|
2020-06-24 17:08:46 -04:00
|
|
|
|
|
|
|
Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
|
|
|
|
redis.del(key_names)
|
|
|
|
redis.del(rack_session_keys(session_ids))
|
|
|
|
end
|
2018-05-02 04:08:16 -04:00
|
|
|
end
|
|
|
|
|
2020-10-01 14:10:20 -04:00
|
|
|
# TODO: remove in 13.7
|
|
|
|
# After upgrade, .destroy might be called with the session id encrypted
|
|
|
|
# by .public_id.
|
|
|
|
def self.destroy_with_deprecated_encryption(user, session_id)
|
|
|
|
return unless session_id
|
|
|
|
|
|
|
|
decrypted_session_id = decrypt_public_id(session_id)
|
|
|
|
rack_session_private_id = if decrypted_session_id
|
|
|
|
Rack::Session::SessionId.new(decrypted_session_id).private_id
|
|
|
|
end
|
|
|
|
|
2018-05-02 04:08:16 -04:00
|
|
|
Gitlab::Redis::SharedState.with do |redis|
|
2020-10-01 14:10:20 -04:00
|
|
|
destroy_sessions(redis, user, [session_id, decrypted_session_id, rack_session_private_id].compact)
|
2018-05-02 04:08:16 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2020-10-01 14:10:20 -04:00
|
|
|
def self.destroy_all_but_current(user, current_rack_session)
|
|
|
|
sessions = not_impersonated(user)
|
|
|
|
sessions.reject! { |session| session.current?(current_rack_session) } if current_rack_session
|
2020-09-02 11:10:54 -04:00
|
|
|
|
|
|
|
Gitlab::Redis::SharedState.with do |redis|
|
2020-10-01 14:10:20 -04:00
|
|
|
session_ids = (sessions.map(&:session_id) | sessions.map(&:session_private_id)).compact
|
|
|
|
destroy_sessions(redis, user, session_ids) if session_ids.any?
|
2020-09-02 11:10:54 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def self.not_impersonated(user)
|
|
|
|
list(user).reject(&:is_impersonated)
|
|
|
|
end
|
|
|
|
|
2018-05-02 04:08:16 -04:00
|
|
|
def self.key_name(user_id, session_id = '*')
|
|
|
|
"#{Gitlab::Redis::SharedState::USER_SESSIONS_NAMESPACE}:#{user_id}:#{session_id}"
|
|
|
|
end
|
|
|
|
|
|
|
|
def self.lookup_key_name(user_id)
|
|
|
|
"#{Gitlab::Redis::SharedState::USER_SESSIONS_LOOKUP_NAMESPACE}:#{user_id}"
|
|
|
|
end
|
|
|
|
|
2019-05-14 04:01:49 -04:00
|
|
|
def self.list_sessions(user)
|
2019-07-12 08:25:12 -04:00
|
|
|
sessions_from_ids(session_ids_for_user(user.id))
|
2019-05-14 04:01:49 -04:00
|
|
|
end
|
2018-05-02 04:08:16 -04:00
|
|
|
|
2020-04-14 23:09:11 -04:00
|
|
|
# Lists the relevant session IDs for the user.
|
|
|
|
#
|
2020-10-01 14:10:20 -04:00
|
|
|
# Returns an array of strings
|
2019-07-12 08:25:12 -04:00
|
|
|
def self.session_ids_for_user(user_id)
|
2019-05-14 04:01:49 -04:00
|
|
|
Gitlab::Redis::SharedState.with do |redis|
|
2020-10-01 14:10:20 -04:00
|
|
|
redis.smembers(lookup_key_name(user_id))
|
2019-05-14 04:01:49 -04:00
|
|
|
end
|
|
|
|
end
|
2018-05-02 04:08:16 -04:00
|
|
|
|
2020-04-30 08:09:45 -04:00
|
|
|
# Lists the session Hash objects for the given session IDs.
|
2020-04-14 23:09:11 -04:00
|
|
|
#
|
2020-10-01 14:10:20 -04:00
|
|
|
# session_ids - An array of strings
|
2020-04-14 23:09:11 -04:00
|
|
|
#
|
|
|
|
# Returns an array of ActiveSession objects
|
2019-05-14 04:01:49 -04:00
|
|
|
def self.sessions_from_ids(session_ids)
|
|
|
|
return [] if session_ids.empty?
|
2018-05-02 04:08:16 -04:00
|
|
|
|
2019-05-14 04:01:49 -04:00
|
|
|
Gitlab::Redis::SharedState.with do |redis|
|
2020-04-14 23:09:11 -04:00
|
|
|
session_keys = rack_session_keys(session_ids)
|
2018-05-02 04:08:16 -04:00
|
|
|
|
2019-07-17 07:07:26 -04:00
|
|
|
session_keys.each_slice(SESSION_BATCH_SIZE).flat_map do |session_keys_batch|
|
2020-06-24 17:08:46 -04:00
|
|
|
Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
|
|
|
|
redis.mget(session_keys_batch).compact.map do |raw_session|
|
|
|
|
load_raw_session(raw_session)
|
|
|
|
end
|
2019-07-17 07:07:26 -04:00
|
|
|
end
|
2019-05-14 04:01:49 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2020-04-30 08:09:45 -04:00
|
|
|
# Deserializes a session Hash object from Redis.
|
2020-04-14 23:09:11 -04:00
|
|
|
#
|
|
|
|
# raw_session - Raw bytes from Redis
|
|
|
|
#
|
|
|
|
# Returns an ActiveSession object
|
|
|
|
def self.load_raw_session(raw_session)
|
|
|
|
# rubocop:disable Security/MarshalLoad
|
2020-10-01 14:10:20 -04:00
|
|
|
Marshal.load(raw_session)
|
2020-04-14 23:09:11 -04:00
|
|
|
# rubocop:enable Security/MarshalLoad
|
2020-10-01 14:10:20 -04:00
|
|
|
end
|
2020-04-14 23:09:11 -04:00
|
|
|
|
2020-10-01 14:10:20 -04:00
|
|
|
def self.rack_session_keys(rack_session_ids)
|
|
|
|
rack_session_ids.map { |session_id| "#{Gitlab::Redis::SharedState::SESSION_NAMESPACE}:#{session_id}" }
|
2020-04-14 23:09:11 -04:00
|
|
|
end
|
|
|
|
|
2019-11-27 13:06:30 -05:00
|
|
|
def self.raw_active_session_entries(redis, session_ids, user_id)
|
2019-05-14 04:01:49 -04:00
|
|
|
return [] if session_ids.empty?
|
|
|
|
|
2019-11-27 13:06:30 -05:00
|
|
|
entry_keys = session_ids.map { |session_id| key_name(user_id, session_id) }
|
|
|
|
|
2020-06-24 17:08:46 -04:00
|
|
|
Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
|
|
|
|
redis.mget(entry_keys)
|
|
|
|
end
|
2019-11-27 13:06:30 -05:00
|
|
|
end
|
2019-05-14 04:01:49 -04:00
|
|
|
|
2019-11-27 13:06:30 -05:00
|
|
|
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)
|
|
|
|
|
2019-12-02 16:06:51 -05:00
|
|
|
entry_keys.compact.map do |raw_session|
|
2020-04-14 23:09:11 -04:00
|
|
|
load_raw_session(raw_session)
|
2019-05-14 04:01:49 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2019-11-27 13:06:30 -05:00
|
|
|
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!
|
2020-01-03 04:07:33 -05:00
|
|
|
destroyable_sessions = sessions.drop(ALLOWED_NUMBER_OF_ACTIVE_SESSIONS)
|
2020-10-01 14:10:20 -04:00
|
|
|
destroyable_session_ids = destroyable_sessions.flat_map { |session| [session.session_id, session.session_private_id] }.compact
|
2020-01-03 04:07:33 -05:00
|
|
|
destroy_sessions(redis, user, destroyable_session_ids) if destroyable_session_ids.any?
|
2019-11-27 13:06:30 -05:00
|
|
|
end
|
|
|
|
|
2020-04-14 23:09:11 -04:00
|
|
|
# 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.
|
2019-05-14 04:01:49 -04:00
|
|
|
def self.cleaned_up_lookup_entries(redis, user)
|
2019-07-12 08:25:12 -04:00
|
|
|
session_ids = session_ids_for_user(user.id)
|
2019-11-27 13:06:30 -05:00
|
|
|
entries = raw_active_session_entries(redis, session_ids, user.id)
|
2018-05-02 04:08:16 -04:00
|
|
|
|
|
|
|
# remove expired keys.
|
|
|
|
# only the single key entries are automatically expired by redis, the
|
|
|
|
# lookup entries in the set need to be removed manually.
|
2019-05-14 04:01:49 -04:00
|
|
|
session_ids_and_entries = session_ids.zip(entries)
|
2019-08-07 05:32:05 -04:00
|
|
|
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)
|
|
|
|
end
|
2018-05-02 04:08:16 -04:00
|
|
|
end
|
|
|
|
|
2019-05-14 04:01:49 -04:00
|
|
|
entries.compact
|
2018-05-02 04:08:16 -04:00
|
|
|
end
|
2020-01-03 04:07:33 -05:00
|
|
|
|
2020-10-01 14:10:20 -04:00
|
|
|
# TODO: remove in 13.7
|
2020-01-03 04:07:33 -05:00
|
|
|
private_class_method def self.decrypt_public_id(public_id)
|
2020-04-14 23:09:11 -04:00
|
|
|
Gitlab::CryptoHelper.aes256_gcm_decrypt(public_id)
|
2020-01-03 04:07:33 -05:00
|
|
|
rescue
|
|
|
|
nil
|
|
|
|
end
|
2018-05-02 04:08:16 -04:00
|
|
|
end
|