brevio-session-store 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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