redis-client-session-store 0.12

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'redis-client'
4
+
5
+ # Redis session storage for Rails, and for Rails only. Derived from
6
+ # the MemCacheStore code, simply dropping in Redis instead.
7
+ class RedisSessionStore < ActionDispatch::Session::AbstractSecureStore
8
+ VERSION = '0.12'
9
+ # Rails 3.1 and beyond defines the constant elsewhere
10
+ unless defined?(ENV_SESSION_OPTIONS_KEY)
11
+ ENV_SESSION_OPTIONS_KEY = if Rack.release.split('.').first.to_i > 1
12
+ Rack::RACK_SESSION_OPTIONS
13
+ else
14
+ Rack::Session::Abstract::ENV_SESSION_OPTIONS_KEY
15
+ end
16
+ end
17
+
18
+ USE_INDIFFERENT_ACCESS = defined?(ActiveSupport).freeze
19
+ # ==== Options
20
+ # * +:key+ - Same as with the other cookie stores, key name
21
+ # * +:redis+ - A hash with redis-specific options
22
+ # * +:url+ - Redis url, default is redis://localhost:6379/0
23
+ # * +:key_prefix+ - Prefix for keys used in Redis, e.g. +myapp:+
24
+ # * +:expire_after+ - A number in seconds for session timeout
25
+ # * +:client+ - Connect to Redis with given object rather than create one
26
+ # * +:on_redis_down:+ - Called with err, env, and SID on Errno::ECONNREFUSED
27
+ # * +:on_session_load_error:+ - Called with err and SID on Marshal.load fail
28
+ # * +:serializer:+ - Serializer to use on session data, default is :marshal.
29
+ #
30
+ # ==== Examples
31
+ #
32
+ # Rails.application.config.session_store :redis_client_session_store,
33
+ # key: 'your_session_key',
34
+ # redis: {
35
+ # expire_after: 120.minutes,
36
+ # key_prefix: 'myapp:session:',
37
+ # url: 'redis://localhost:6379/0'
38
+ # },
39
+ # on_redis_down: ->(*a) { logger.error("Redis down! #{a.inspect}") },
40
+ # serializer: :hybrid # migrate from Marshal to JSON
41
+ #
42
+ def initialize(app, options = {})
43
+ super
44
+
45
+ @default_options[:namespace] = 'rack:session'
46
+ @default_options.merge!(options[:redis] || {})
47
+ init_options = options[:redis]&.reject do |k, _v|
48
+ %i[expire_after key_prefix ttl].include?(k)
49
+ end || {}
50
+ @redis = init_options[:client] || RedisClient.new(init_options)
51
+ @on_redis_down = options[:on_redis_down]
52
+ @serializer = determine_serializer(options[:serializer])
53
+ @on_session_load_error = options[:on_session_load_error]
54
+ verify_handlers!
55
+ end
56
+
57
+ attr_accessor :on_redis_down, :on_session_load_error
58
+
59
+ private
60
+
61
+ attr_reader :redis, :key, :default_options, :serializer
62
+
63
+ # overrides method defined in rack to actually verify session existence
64
+ # Prevents needless new sessions from being created in scenario where
65
+ # user HAS session id, but it already expired, or is invalid for some
66
+ # other reason, and session was accessed only for reading.
67
+ def session_exists?(env)
68
+ value = current_session_id(env)
69
+
70
+ !!(
71
+ value && !value.empty? &&
72
+ key_exists_with_fallback?(value)
73
+ )
74
+ rescue Errno::ECONNREFUSED, RedisClient::CannotConnectError => e
75
+ on_redis_down.call(e, env, value) if on_redis_down
76
+
77
+ true
78
+ end
79
+
80
+ def key_exists_with_fallback?(value)
81
+ return false if private_session_id?(value.public_id)
82
+
83
+ key_exists?(value.private_id) || key_exists?(value.public_id)
84
+ end
85
+
86
+ def key_exists?(value)
87
+ redis.call('EXISTS', prefixed(value)).positive?
88
+ end
89
+
90
+ def private_session_id?(value)
91
+ value.match?(/\A\d+::/)
92
+ end
93
+
94
+ def verify_handlers!
95
+ %w(on_redis_down on_session_load_error).each do |h|
96
+ next unless (handler = public_send(h)) && !handler.respond_to?(:call)
97
+
98
+ raise ArgumentError, "#{h} handler is not callable"
99
+ end
100
+ end
101
+
102
+ def prefixed(sid)
103
+ "#{default_options[:key_prefix]}#{sid}"
104
+ end
105
+
106
+ def session_default_values
107
+ [generate_sid, USE_INDIFFERENT_ACCESS ? {}.with_indifferent_access : {}]
108
+ end
109
+
110
+ def get_session(env, sid)
111
+ sid && (session = load_session_with_fallback(sid)) ? [sid, session] : session_default_values
112
+ rescue Errno::ECONNREFUSED, RedisClient::CannotConnectError => e
113
+ on_redis_down.call(e, env, sid) if on_redis_down
114
+ session_default_values
115
+ end
116
+ alias find_session get_session
117
+
118
+ def load_session_with_fallback(sid)
119
+ return nil if private_session_id?(sid.public_id)
120
+
121
+ load_session_from_redis(
122
+ key_exists?(sid.private_id) ? sid.private_id : sid.public_id
123
+ )
124
+ end
125
+
126
+ def load_session_from_redis(sid)
127
+ data = redis.call('GET', prefixed(sid))
128
+ begin
129
+ data ? decode(data) : nil
130
+ rescue StandardError => e
131
+ destroy_session_from_sid(sid, drop: true)
132
+ on_session_load_error.call(e, sid) if on_session_load_error
133
+ nil
134
+ end
135
+ end
136
+
137
+ def decode(data)
138
+ session = serializer.load(data)
139
+ USE_INDIFFERENT_ACCESS ? session.with_indifferent_access : session
140
+ end
141
+
142
+ def set_session(env, sid, session_data, options = nil)
143
+ expiry = get_expiry(env, options)
144
+ if expiry
145
+ redis.call('SETEX', prefixed(sid.private_id), expiry, encode(session_data))
146
+ else
147
+ redis.call('SET', prefixed(sid.private_id), encode(session_data))
148
+ end
149
+ sid
150
+ rescue Errno::ECONNREFUSED, RedisClient::CannotConnectError => e
151
+ on_redis_down.call(e, env, sid) if on_redis_down
152
+ false
153
+ end
154
+ alias write_session set_session
155
+
156
+ def get_expiry(env, options)
157
+ session_storage_options = options || env.fetch(ENV_SESSION_OPTIONS_KEY, {})
158
+ session_storage_options[:ttl] || session_storage_options[:expire_after]
159
+ end
160
+
161
+ def encode(session_data)
162
+ serializer.dump(session_data)
163
+ end
164
+
165
+ def destroy_session(env, sid, options)
166
+ destroy_session_from_sid(sid.public_id, (options || {}).to_hash.merge(env:, drop: true))
167
+ destroy_session_from_sid(sid.private_id, (options || {}).to_hash.merge(env:))
168
+ end
169
+ alias delete_session destroy_session
170
+
171
+ def destroy(env)
172
+ if env['rack.request.cookie_hash'] &&
173
+ (sid = env['rack.request.cookie_hash'][key])
174
+ sid = Rack::Session::SessionId.new(sid)
175
+ destroy_session_from_sid(sid.private_id, drop: true, env:)
176
+ destroy_session_from_sid(sid.public_id, drop: true, env:)
177
+ end
178
+ false
179
+ end
180
+
181
+ def destroy_session_from_sid(sid, options = {})
182
+ redis.call('DEL', prefixed(sid))
183
+ (options || {})[:drop] ? nil : generate_sid
184
+ rescue Errno::ECONNREFUSED, RedisClient::CannotConnectError => e
185
+ on_redis_down.call(e, options[:env] || {}, sid) if on_redis_down
186
+ end
187
+
188
+ def determine_serializer(serializer)
189
+ serializer ||= :marshal
190
+ case serializer
191
+ when :marshal then Marshal
192
+ when :json then JsonSerializer
193
+ when :hybrid then HybridSerializer
194
+ else serializer
195
+ end
196
+ end
197
+
198
+ # Uses built-in JSON library to encode/decode session
199
+ class JsonSerializer
200
+ def self.load(value)
201
+ JSON.parse(value, quirks_mode: true)
202
+ end
203
+
204
+ def self.dump(value)
205
+ JSON.generate(value, quirks_mode: true)
206
+ end
207
+ end
208
+
209
+ # Transparently migrates existing session values from Marshal to JSON
210
+ class HybridSerializer < JsonSerializer
211
+ MARSHAL_SIGNATURE = "\x04\x08"
212
+
213
+ def self.load(value)
214
+ if needs_migration?(value)
215
+ Marshal.load(value)
216
+ else
217
+ super
218
+ end
219
+ end
220
+
221
+ def self.needs_migration?(value)
222
+ value.start_with?(MARSHAL_SIGNATURE)
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,26 @@
1
+ Gem::Specification.new do |gem|
2
+ gem.name = 'redis-client-session-store'
3
+ gem.authors = ['Andrey "Zed" Zaikin']
4
+ gem.email = ['zed.0xff@gmail.com']
5
+ gem.summary = 'Rails session store using low-level redis-client for Redis 6+'
6
+ gem.description = "#{gem.summary} For great glory!"
7
+ gem.homepage = 'https://github.com/zed-0xff/redis-client-session-store'
8
+ gem.license = 'MIT'
9
+
10
+ gem.extra_rdoc_files = %w(LICENSE AUTHORS.md CONTRIBUTING.md)
11
+
12
+ gem.files = `git ls-files -z`.split("\x0")
13
+ gem.require_paths = %w(lib)
14
+ gem.version = File.read('lib/redis-client-session-store.rb')
15
+ .match(/^ VERSION = '(.*)'/)[1]
16
+
17
+ gem.add_runtime_dependency 'actionpack', '>= 5.2.4.1', '< 8'
18
+ gem.add_runtime_dependency 'redis-client'
19
+
20
+ gem.add_development_dependency 'rake', '~> 13'
21
+ gem.add_development_dependency 'rspec', '~> 3'
22
+ gem.add_development_dependency 'rubocop', '~> 1.25'
23
+ gem.add_development_dependency 'rubocop-rake', '~> 0.6'
24
+ gem.add_development_dependency 'rubocop-rspec', '~> 2.8'
25
+ gem.add_development_dependency 'simplecov', '~> 0.21'
26
+ end