brevio-session-store 0.1.0

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