redis-client-session-store 0.12
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/workflows/ruby.yml +42 -0
- data/.gitignore +16 -0
- data/.gitmodules +3 -0
- data/.rspec +3 -0
- data/.rubocop.yml +43 -0
- data/.rubocop_todo.yml +67 -0
- data/.simplecov +5 -0
- data/AUTHORS.md +29 -0
- data/CODE_OF_CONDUCT.md +75 -0
- data/CONTRIBUTING.md +6 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +144 -0
- data/LICENSE +22 -0
- data/README.md +99 -0
- data/Rakefile +15 -0
- data/lib/redis-client-session-store.rb +225 -0
- data/redis-client-session-store.gemspec +26 -0
- data/spec/redis_client_session_store_spec.rb +646 -0
- data/spec/spec_helper.rb +32 -0
- data/spec/support/redis_double.rb +9 -0
- data/spec/support.rb +86 -0
- metadata +186 -0
@@ -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
|