redis-client-session-store 0.12
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.
- 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
|