Read about our upcoming Code of Conduct on this issue

active_session.rb 4.95 KB
Newer Older
1
2
# frozen_string_literal: true

3
4
5
class ActiveSession
  include ActiveModel::Model

6
  SESSION_BATCH_SIZE = 200
7
  ALLOWED_NUMBER_OF_ACTIVE_SESSIONS = 100
8

9
10
  attr_accessor :created_at, :updated_at,
    :session_id, :ip_address,
11
12
    :browser, :os, :device_name, :device_type,
    :is_impersonated
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

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

    session_id == session.id
  end

  def human_device_type
    device_type&.titleize
  end

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

      active_user_session = new(
        ip_address: request.ip,
        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,
38
39
        session_id: session_id,
        is_impersonated: request.session[:impersonator_id].present?
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
      )

      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

  def self.list(user)
    Gitlab::Redis::SharedState.with do |redis|
59
      cleaned_up_lookup_entries(redis, user).map do |entry|
60
61
62
63
64
65
66
67
68
        # rubocop:disable Security/MarshalLoad
        Marshal.load(entry)
        # rubocop:enable Security/MarshalLoad
      end
    end
  end

  def self.destroy(user, session_id)
    Gitlab::Redis::SharedState.with do |redis|
69
70
71
      destroy_sessions(redis, user, [session_id])
    end
  end
72

73
74
75
  def self.destroy_sessions(redis, user, session_ids)
    key_names = session_ids.map {|session_id| key_name(user.id, session_id) }
    session_names = session_ids.map {|session_id| "#{Gitlab::Redis::SharedState::SESSION_NAMESPACE}:#{session_id}" }
76

77
78
79
    redis.srem(lookup_key_name(user.id), session_ids)
    redis.del(key_names)
    redis.del(session_names)
80
81
82
83
  end

  def self.cleanup(user)
    Gitlab::Redis::SharedState.with do |redis|
84
      clean_up_old_sessions(redis, user)
85
      cleaned_up_lookup_entries(redis, user)
86
87
88
89
90
91
92
93
94
95
96
    end
  end

  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

97
  def self.list_sessions(user)
98
    sessions_from_ids(session_ids_for_user(user.id))
99
  end
100

101
  def self.session_ids_for_user(user_id)
102
    Gitlab::Redis::SharedState.with do |redis|
103
      redis.smembers(lookup_key_name(user_id))
104
105
    end
  end
106

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

110
111
    Gitlab::Redis::SharedState.with do |redis|
      session_keys = session_ids.map { |session_id| "#{Gitlab::Redis::SharedState::SESSION_NAMESPACE}:#{session_id}" }
112

113
114
115
116
117
118
      session_keys.each_slice(SESSION_BATCH_SIZE).flat_map do |session_keys_batch|
        redis.mget(session_keys_batch).compact.map do |raw_session|
          # rubocop:disable Security/MarshalLoad
          Marshal.load(raw_session)
          # rubocop:enable Security/MarshalLoad
        end
119
120
121
122
      end
    end
  end

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

126
127
128
129
    entry_keys = session_ids.map { |session_id| key_name(user_id, session_id) }

    redis.mget(entry_keys)
  end
130

131
132
133
134
135
  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)

136
    entry_keys.compact.map do |raw_session|
137
      Marshal.load(raw_session) # rubocop:disable Security/MarshalLoad
138
139
140
    end
  end

141
142
143
144
145
146
147
148
149
150
151
152
  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!
    sessions = sessions[ALLOWED_NUMBER_OF_ACTIVE_SESSIONS..-1].map { |session| session.session_id }
    destroy_sessions(redis, user, sessions)
  end

153
  def self.cleaned_up_lookup_entries(redis, user)
154
    session_ids = session_ids_for_user(user.id)
155
    entries = raw_active_session_entries(redis, session_ids, user.id)
156
157
158
159

    # remove expired keys.
    # only the single key entries are automatically expired by redis, the
    # lookup entries in the set need to be removed manually.
160
    session_ids_and_entries = session_ids.zip(entries)
161
162
163
164
    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
165
166
    end

167
    entries.compact
168
169
  end
end