Class: ActiveSession

Inherits:
Object
  • Object
show all
Includes:
ActiveModel::Model
Defined in:
app/models/active_session.rb

Constant Summary collapse

SESSION_BATCH_SIZE =
200
ALLOWED_NUMBER_OF_ACTIVE_SESSIONS =
100

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#browserObject

Returns the value of attribute browser


9
10
11
# File 'app/models/active_session.rb', line 9

def browser
  @browser
end

#created_atObject

Returns the value of attribute created_at


9
10
11
# File 'app/models/active_session.rb', line 9

def created_at
  @created_at
end

#device_nameObject

Returns the value of attribute device_name


9
10
11
# File 'app/models/active_session.rb', line 9

def device_name
  @device_name
end

#device_typeObject

Returns the value of attribute device_type


9
10
11
# File 'app/models/active_session.rb', line 9

def device_type
  @device_type
end

#ip_addressObject

Returns the value of attribute ip_address


9
10
11
# File 'app/models/active_session.rb', line 9

def ip_address
  @ip_address
end

#is_impersonatedObject

Returns the value of attribute is_impersonated


9
10
11
# File 'app/models/active_session.rb', line 9

def is_impersonated
  @is_impersonated
end

#osObject

Returns the value of attribute os


9
10
11
# File 'app/models/active_session.rb', line 9

def os
  @os
end

#session_idObject

Returns the value of attribute session_id


9
10
11
# File 'app/models/active_session.rb', line 9

def session_id
  @session_id
end

#updated_atObject

Returns the value of attribute updated_at


9
10
11
# File 'app/models/active_session.rb', line 9

def updated_at
  @updated_at
end

Class Method Details

.active_session_entries(session_ids, user_id, redis) ⇒ Object


204
205
206
207
208
209
210
211
212
# File 'app/models/active_session.rb', line 204

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
end

.clean_up_old_sessions(redis, user) ⇒ Object


214
215
216
217
218
219
220
221
222
223
224
225
# File 'app/models/active_session.rb', line 214

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.map { |session| session.session_id }
  destroy_sessions(redis, user, destroyable_session_ids) if destroyable_session_ids.any?
end

.cleaned_up_lookup_entries(redis, user) ⇒ Object

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.


230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
# File 'app/models/active_session.rb', line 230

def self.cleaned_up_lookup_entries(redis, user)
  session_ids = session_ids_for_user(user.id)
  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)
    end
  end

  entries.compact
end

.cleanup(user) ⇒ Object


101
102
103
104
105
106
# File 'app/models/active_session.rb', line 101

def self.cleanup(user)
  Gitlab::Redis::SharedState.with do |redis|
    clean_up_old_sessions(redis, user)
    cleaned_up_lookup_entries(redis, user)
  end
end

.destroy(user, session_id) ⇒ Object


73
74
75
76
77
78
79
# File 'app/models/active_session.rb', line 73

def self.destroy(user, session_id)
  return unless session_id

  Gitlab::Redis::SharedState.with do |redis|
    destroy_sessions(redis, user, [session_id])
  end
end

.destroy_all_but_current(user, current_session) ⇒ Object


108
109
110
111
112
113
114
115
# File 'app/models/active_session.rb', line 108

def self.destroy_all_but_current(user, current_session)
  session_ids = not_impersonated(user)
  session_ids.reject! { |session| session.current?(current_session) } if current_session

  Gitlab::Redis::SharedState.with do |redis|
    destroy_sessions(redis, user, session_ids.map(&:session_id)) if session_ids.any?
  end
end

.destroy_sessions(redis, user, session_ids) ⇒ Object


90
91
92
93
94
95
96
97
98
99
# File 'app/models/active_session.rb', line 90

def self.destroy_sessions(redis, user, session_ids)
  key_names = session_ids.map { |session_id| key_name(user.id, session_id.public_id) }

  redis.srem(lookup_key_name(user.id), session_ids.map(&:public_id))

  Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
    redis.del(key_names)
    redis.del(rack_session_keys(session_ids))
  end
end

.destroy_with_public_id(user, public_id) ⇒ Object


81
82
83
84
85
86
87
88
# File 'app/models/active_session.rb', line 81

def self.destroy_with_public_id(user, public_id)
  decrypted_id = decrypt_public_id(public_id)

  return if decrypted_id.nil?

  session_id = Rack::Session::SessionId.new(decrypted_id)
  destroy(user, session_id)
end

.key_name(user_id, session_id = '*') ⇒ Object


121
122
123
# File 'app/models/active_session.rb', line 121

def self.key_name(user_id, session_id = '*')
  "#{Gitlab::Redis::SharedState::USER_SESSIONS_NAMESPACE}:#{user_id}:#{session_id}"
end

.list(user) ⇒ Object


65
66
67
68
69
70
71
# File 'app/models/active_session.rb', line 65

def self.list(user)
  Gitlab::Redis::SharedState.with do |redis|
    cleaned_up_lookup_entries(redis, user).map do |raw_session|
      load_raw_session(raw_session)
    end
  end
end

.list_sessions(user) ⇒ Object


129
130
131
# File 'app/models/active_session.rb', line 129

def self.list_sessions(user)
  sessions_from_ids(session_ids_for_user(user.id))
end

.load_raw_session(raw_session) ⇒ Object

Deserializes a session Hash object from Redis.

raw_session - Raw bytes from Redis

Returns an ActiveSession object


169
170
171
172
173
174
175
176
177
178
179
# File 'app/models/active_session.rb', line 169

def self.load_raw_session(raw_session)
  # rubocop:disable Security/MarshalLoad
  session = Marshal.load(raw_session)
  # rubocop:enable Security/MarshalLoad

  # Older ActiveSession models serialize `session_id` as strings, To
  # avoid breaking older sessions, we keep backwards compatibility
  # with older Redis keys and initiate Rack::Session::SessionId here.
  session.session_id = Rack::Session::SessionId.new(session.session_id) if session.try(:session_id).is_a?(String)
  session
end

.lookup_key_name(user_id) ⇒ Object


125
126
127
# File 'app/models/active_session.rb', line 125

def self.lookup_key_name(user_id)
  "#{Gitlab::Redis::SharedState::USER_SESSIONS_LOOKUP_NAMESPACE}:#{user_id}"
end

.not_impersonated(user) ⇒ Object


117
118
119
# File 'app/models/active_session.rb', line 117

def self.not_impersonated(user)
  list(user).reject(&:is_impersonated)
end

.rack_session_keys(session_ids) ⇒ Object


181
182
183
184
185
186
187
188
189
190
191
192
# File 'app/models/active_session.rb', line 181

def self.rack_session_keys(session_ids)
  session_ids.each_with_object([]) do |session_id, arr|
    # This is a redis-rack implementation detail
    # (https://github.com/redis-store/redis-rack/blob/master/lib/rack/session/redis.rb#L88)
    #
    # We need to delete session keys based on the legacy public key name
    # and the newer private ID keys, but there's no well-defined interface
    # so we have to do it directly.
    arr << "#{Gitlab::Redis::SharedState::SESSION_NAMESPACE}:#{session_id.public_id}"
    arr << "#{Gitlab::Redis::SharedState::SESSION_NAMESPACE}:#{session_id.private_id}"
  end
end

.raw_active_session_entries(redis, session_ids, user_id) ⇒ Object


194
195
196
197
198
199
200
201
202
# File 'app/models/active_session.rb', line 194

def self.raw_active_session_entries(redis, session_ids, user_id)
  return [] if session_ids.empty?

  entry_keys = session_ids.map { |session_id| key_name(user_id, session_id) }

  Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
    redis.mget(entry_keys)
  end
end

.session_ids_for_user(user_id) ⇒ Object

Lists the relevant session IDs for the user.

Returns an array of Rack::Session::SessionId objects


136
137
138
139
140
141
# File 'app/models/active_session.rb', line 136

def self.session_ids_for_user(user_id)
  Gitlab::Redis::SharedState.with do |redis|
    session_ids = redis.smembers(lookup_key_name(user_id))
    session_ids.map { |id| Rack::Session::SessionId.new(id) }
  end
end

.sessions_from_ids(session_ids) ⇒ Object

Lists the session Hash objects for the given session IDs.

session_ids - An array of Rack::Session::SessionId objects

Returns an array of ActiveSession objects


148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
# File 'app/models/active_session.rb', line 148

def self.sessions_from_ids(session_ids)
  return [] if session_ids.empty?

  Gitlab::Redis::SharedState.with do |redis|
    session_keys = rack_session_keys(session_ids)

    session_keys.each_slice(SESSION_BATCH_SIZE).flat_map do |session_keys_batch|
      Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
        redis.mget(session_keys_batch).compact.map do |raw_session|
          load_raw_session(raw_session)
        end
      end
    end
  end
end

.set(user, request) ⇒ Object


32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# File 'app/models/active_session.rb', line 32

def self.set(user, request)
  Gitlab::Redis::SharedState.with do |redis|
    session_id = request.session.id.public_id
    client = DeviceDetector.new(request.user_agent)
    timestamp = Time.current

    active_user_session = new(
      ip_address: request.remote_ip,
      browser: client.name,
      os: client.os_name,
      device_name: client.device_name,
      device_type: client.device_type,
      created_at: user. || timestamp,
      updated_at: timestamp,
      session_id: session_id,
      is_impersonated: request.session[:impersonator_id].present?
    )

    redis.pipelined do
      redis.setex(
        key_name(user.id, session_id),
        Settings.gitlab['session_expire_delay'] * 60,
        Marshal.dump(active_user_session)
      )

      redis.sadd(
        lookup_key_name(user.id),
        session_id
      )
    end
  end
end

Instance Method Details

#current?(session) ⇒ Boolean

Returns:

  • (Boolean)

14
15
16
17
18
19
20
# File 'app/models/active_session.rb', line 14

def current?(session)
  return false if session_id.nil? || session.id.nil?

  # Rack v2.0.8+ added private_id, which uses the hash of the
  # public_id to avoid timing attacks.
  session_id.private_id == session.id.private_id
end

#human_device_typeObject


22
23
24
# File 'app/models/active_session.rb', line 22

def human_device_type
  device_type&.titleize
end

#public_idObject

This is not the same as Rack::Session::SessionId#public_id, but we need to preserve this for backwards compatibility.


28
29
30
# File 'app/models/active_session.rb', line 28

def public_id
  Gitlab::CryptoHelper.aes256_gcm_encrypt(session_id.public_id)
end