rack-state 0.0.1
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.
- data/README.rdoc +187 -0
- data/lib/rack/state.rb +392 -0
- data/spec/spec_state.rb +174 -0
- data/spec/spec_state_store_file.rb +23 -0
- data/spec/spec_state_store_memory.rb +10 -0
- data/spec/spec_state_store_postgres.rb +43 -0
- data/spec/state_store.rb +36 -0
- metadata +103 -0
data/README.rdoc
ADDED
@@ -0,0 +1,187 @@
|
|
1
|
+
= Rack::State -- Secure & Modular State Management
|
2
|
+
|
3
|
+
Rack::State is middleware that securely stores and manages object state.
|
4
|
+
|
5
|
+
Applications use the Manager API to _set_, _get_, and _delete_ state of
|
6
|
+
any object that requires persistence in a stateless environment.
|
7
|
+
|
8
|
+
== The Core Components
|
9
|
+
|
10
|
+
* Rack::State
|
11
|
+
* Rack::State::Manager
|
12
|
+
* Rack::State::Store
|
13
|
+
|
14
|
+
== Diagram of All the Players
|
15
|
+
|
16
|
+
State (middleware)
|
17
|
+
|
|
18
|
+
+--> Manager (env[rack.state.KEY]) <-- API (get/set/delete object)
|
19
|
+
|
|
20
|
+
+--> client-side: cookie (KEY = token)
|
21
|
+
|
|
22
|
+
+--> server-side: Store (token, object)
|
23
|
+
|
|
24
|
+
+--> Memory
|
25
|
+
+--> File
|
26
|
+
+--> Postgres
|
27
|
+
+--> PreparedPostgres
|
28
|
+
|
29
|
+
The *KEY* and Store *adapter* may be set for each instance of middleware.
|
30
|
+
|
31
|
+
== Design & Motivation
|
32
|
+
|
33
|
+
=== Primary goal
|
34
|
+
|
35
|
+
State management for multiple objects with independent control of the
|
36
|
+
visibility, security, and storage of each object's state.
|
37
|
+
|
38
|
+
=== But we have Rack::Session
|
39
|
+
|
40
|
+
Rack::Session provides state management, but does so using only a single
|
41
|
+
SessionHash, which is statically located in Rack's environment. The drawback is
|
42
|
+
the lack of fine-grained control, efficiency, and multiple instances. This
|
43
|
+
single hash-like object is always available to the entire application whether it
|
44
|
+
is needed or not.
|
45
|
+
|
46
|
+
I attempted to modify and bend Rack::Session to meet my design goal, but there
|
47
|
+
seemed to be no clean and easy solution. Rack::State was born...
|
48
|
+
|
49
|
+
=== Use Case
|
50
|
+
|
51
|
+
An application has the following areas that require data persistence:
|
52
|
+
|
53
|
+
1. site-wide personalization -- <i>real name, theme, etc.</i>
|
54
|
+
2. blog activity tracking -- <i>articles read, favorites, shares</i>
|
55
|
+
3. secure store -- <i>TLS for entire store, shopping cart, and checkout</i>
|
56
|
+
|
57
|
+
Three instances of Rack::State can be used. The domain, path, expiration, token
|
58
|
+
security (i.e., HttpOnly & Secure cookie flags) and storage backend can be set
|
59
|
+
appropriately for each area.
|
60
|
+
|
61
|
+
For example, site-wide personalization may set path to "/" with a long
|
62
|
+
expiration, but the secure store could use "/store" with expiration at end of
|
63
|
+
session and set the HttpOnly and Secure flags as well. Additionally, site-wide
|
64
|
+
personalization and blog activity tracking states could be stored in the
|
65
|
+
database while the secure store state could be saved in files.
|
66
|
+
|
67
|
+
== Usage Examples
|
68
|
+
|
69
|
+
=== Manage a single state in memory
|
70
|
+
|
71
|
+
Here's a simple rackup (config.ru) file that uses the Rack::State middleware
|
72
|
+
with default options.
|
73
|
+
|
74
|
+
require 'rack'
|
75
|
+
require 'rack/state'
|
76
|
+
|
77
|
+
use Rack::State
|
78
|
+
|
79
|
+
run MyApp
|
80
|
+
|
81
|
+
The application can access the State::Manager via the environment. The following
|
82
|
+
code sets up Rack::State to function similarly to Rack::Session.
|
83
|
+
|
84
|
+
def session
|
85
|
+
env['rack.state.token'].get or env['rack.state.token'].set Hash.new
|
86
|
+
end
|
87
|
+
|
88
|
+
session['username']
|
89
|
+
|
90
|
+
|
91
|
+
=== Multiple state management with different storage adapters
|
92
|
+
|
93
|
+
Below, the _store_ state will use the default Memory store and _blog_ will store
|
94
|
+
state in files under "tmp/sessions" in the project's root. Additionally, if
|
95
|
+
"/store" requires encryption it is wise to secure the token.
|
96
|
+
|
97
|
+
use Rack::State, key: 'store', path: '/store', secure: true
|
98
|
+
use Rack::State, key: 'blog', path: '/blog', max_age: 60*60*24*30,
|
99
|
+
store: Rack::State::Store::File.new('tmp/sessions')
|
100
|
+
|
101
|
+
Then on the application side you may have some helper methods like this:
|
102
|
+
|
103
|
+
def store
|
104
|
+
env['rack.state.store']
|
105
|
+
end
|
106
|
+
|
107
|
+
store.set SecureStore.new # start shopping session
|
108
|
+
store.get.cart.add :item7 # buy some stuff
|
109
|
+
store.delete # remove from client and server after checkout
|
110
|
+
|
111
|
+
def blog
|
112
|
+
env['rack.state.blog'].get or env['rack.state.blog'].set BlogTracker.new
|
113
|
+
end
|
114
|
+
|
115
|
+
blog.viewed << :article9
|
116
|
+
blog.shared << :article5
|
117
|
+
|
118
|
+
|
119
|
+
=== Choose a specific storage adapter depending on the environment
|
120
|
+
|
121
|
+
Here we use the Postgres store for production, otherwise use the default Memory
|
122
|
+
store for development and testing.
|
123
|
+
|
124
|
+
if ENV['RACK_ENV'] == 'production'
|
125
|
+
DB = PG::Connection.new
|
126
|
+
use Rack::State, store: Rack::State::Store::Postgres.new(DB)
|
127
|
+
else
|
128
|
+
use Rack::State
|
129
|
+
end
|
130
|
+
|
131
|
+
|
132
|
+
== Install, Test & Contribute
|
133
|
+
|
134
|
+
Install the gem:
|
135
|
+
|
136
|
+
$ sudo gem install rack-state
|
137
|
+
|
138
|
+
Or clone the project:
|
139
|
+
|
140
|
+
TODO
|
141
|
+
|
142
|
+
State is tested with Christian Neukirchen's awesome test framework,
|
143
|
+
{Bacon}[https://github.com/chneukirchen/bacon].
|
144
|
+
Get some bacon and start cooking:
|
145
|
+
|
146
|
+
$ sudo gem install bacon
|
147
|
+
|
148
|
+
Run the entire test suite from the project's root:
|
149
|
+
|
150
|
+
$ bacon -a
|
151
|
+
|
152
|
+
To test a specific component, such as a new storage adapter, run:
|
153
|
+
|
154
|
+
$ bacon -Ilib spec/spec_COMPONENT.rb
|
155
|
+
|
156
|
+
After you fix a bug or develop a storage adapter, submit your code and tests.
|
157
|
+
|
158
|
+
TODO
|
159
|
+
|
160
|
+
== Compatibility
|
161
|
+
|
162
|
+
Rack::State was developed and tested on
|
163
|
+
OpenBSD[http://www.openbsd.org] 5.3
|
164
|
+
using Ruby[https://www.ruby-lang.org] 1.9.3
|
165
|
+
and Rack[https://github.com/rack/rack] 1.5.
|
166
|
+
|
167
|
+
== History
|
168
|
+
|
169
|
+
* September 5, 2013: Initial design, development and testing.
|
170
|
+
|
171
|
+
* September 22, 2013: First public release 0.0.1.
|
172
|
+
|
173
|
+
== Copyright
|
174
|
+
|
175
|
+
Copyright (c) 2013, Clint Pachl <http://clint.pachl.us>
|
176
|
+
|
177
|
+
Permission to use, copy, modify, and/or distribute this software for any purpose
|
178
|
+
with or without fee is hereby granted, provided that the above copyright notice
|
179
|
+
and this permission notice appear in all copies.
|
180
|
+
|
181
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
182
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
183
|
+
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
184
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
|
185
|
+
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
|
186
|
+
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
|
187
|
+
THIS SOFTWARE.
|
data/lib/rack/state.rb
ADDED
@@ -0,0 +1,392 @@
|
|
1
|
+
# :title: Rack::State -- Secure & Modular State Management
|
2
|
+
# :main: README.rdoc
|
3
|
+
|
4
|
+
require 'securerandom'
|
5
|
+
|
6
|
+
module Rack
|
7
|
+
|
8
|
+
#
|
9
|
+
# == The Middleware Interface
|
10
|
+
#
|
11
|
+
# Stores and manages the state of a single object on the server and sets the
|
12
|
+
# state token in a client cookie. Multiple State instances may be utilized to
|
13
|
+
# manage multiple states, each with different storage adapters and options.
|
14
|
+
#
|
15
|
+
# State initializes Manager, which contains the API.
|
16
|
+
#
|
17
|
+
class State
|
18
|
+
|
19
|
+
VERSION = '0.0.1'
|
20
|
+
|
21
|
+
#
|
22
|
+
# == Middleware Options
|
23
|
+
#
|
24
|
+
# [store]
|
25
|
+
# Instance of the storage adapter. See Rack::State::Store for available
|
26
|
+
# adapters. The default is Store::Memory.
|
27
|
+
# [key]
|
28
|
+
# Client cookie name and Rack environment key suffix. For example, the key
|
29
|
+
# "token" sets a cookie named "token" and the environment key to
|
30
|
+
# "rack.state.token". Therefore, the application can access the
|
31
|
+
# State::Manager instance at <tt>env["rack.state.token"]</tt>.
|
32
|
+
# The default is "token".
|
33
|
+
# [domain, path, max_age, expires, secure, httponly]
|
34
|
+
# Standard cookie options supported by Rack. None are set by default;
|
35
|
+
# therefore, the state will be valid for the session only on the domain
|
36
|
+
# and path in which it was set and available over unsecured connections.
|
37
|
+
#
|
38
|
+
def initialize(app, options = {})
|
39
|
+
@app = app
|
40
|
+
@options = options
|
41
|
+
@store = options.delete(:store) || Store::Memory.new
|
42
|
+
@key = options.delete(:key) || 'token'
|
43
|
+
@skey = "rack.state.#{@key}"
|
44
|
+
end
|
45
|
+
|
46
|
+
def call(env)
|
47
|
+
token = Request.new(env).cookies.fetch(@key, nil)
|
48
|
+
state = env[@skey] = Manager.new(@store, token, @key, @options)
|
49
|
+
status, headers, body = @app.call(env)
|
50
|
+
resp = Response.new(body, status, headers)
|
51
|
+
state.finish(resp)
|
52
|
+
resp.finish
|
53
|
+
end
|
54
|
+
|
55
|
+
#
|
56
|
+
# == The Application Interface
|
57
|
+
#
|
58
|
+
# Orchestrates the state management of an object. The API is very simple:
|
59
|
+
# you can #get, #set, and #delete an object.
|
60
|
+
#
|
61
|
+
# The application can access Manager via the Rack environment.
|
62
|
+
# See the *key* middleware option in State.new.
|
63
|
+
#
|
64
|
+
class Manager
|
65
|
+
|
66
|
+
#
|
67
|
+
# Instantiated in middleware-land.
|
68
|
+
#
|
69
|
+
# [store]
|
70
|
+
# Storage instance that implements CRUD methods for storing state.
|
71
|
+
# [token]
|
72
|
+
# Used to reference the state object in store. The token is stored as
|
73
|
+
# the cookie value on the client. If nil, token not set on client.
|
74
|
+
# [key]
|
75
|
+
# Cookie name.
|
76
|
+
# [options]
|
77
|
+
# Cookie options.
|
78
|
+
#
|
79
|
+
def initialize(store, token, key, options) #:nodoc:
|
80
|
+
@object = nil
|
81
|
+
@key = key
|
82
|
+
@store = store
|
83
|
+
@token = token
|
84
|
+
@options = options
|
85
|
+
end
|
86
|
+
|
87
|
+
# Return object if it exists in store, otherwise nil.
|
88
|
+
def get
|
89
|
+
object
|
90
|
+
end
|
91
|
+
|
92
|
+
# Save object in store and set associated token on client.
|
93
|
+
def set(object)
|
94
|
+
@object = object
|
95
|
+
end
|
96
|
+
|
97
|
+
# Remove object from store and delete associated token on client.
|
98
|
+
def delete
|
99
|
+
set false
|
100
|
+
end
|
101
|
+
|
102
|
+
#
|
103
|
+
# Create, update, or delete the state in the store and set or delete the
|
104
|
+
# client's cookie accordingly.
|
105
|
+
#
|
106
|
+
# Note: middleware method not intended for application use.
|
107
|
+
#
|
108
|
+
# [resp] Rack::Response used to set and delete the client cookie.
|
109
|
+
#
|
110
|
+
def finish(resp) #:nodoc:
|
111
|
+
if token? && object?
|
112
|
+
update_state(resp)
|
113
|
+
elsif object?
|
114
|
+
create_state(resp)
|
115
|
+
elsif token? && deleted?
|
116
|
+
delete_state(resp)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
private
|
121
|
+
|
122
|
+
attr_reader :store, :key, :options
|
123
|
+
|
124
|
+
def token(generate = false)
|
125
|
+
@token = generate ? SecureRandom.urlsafe_base64 : @token
|
126
|
+
end
|
127
|
+
|
128
|
+
def token?
|
129
|
+
! @token.nil?
|
130
|
+
end
|
131
|
+
|
132
|
+
# Load object from store on initial access only.
|
133
|
+
def object
|
134
|
+
@object ||= token? ? store.read(token) : nil
|
135
|
+
end
|
136
|
+
|
137
|
+
# Returns true if object has been set directly or loaded from store.
|
138
|
+
def object?
|
139
|
+
!!@object
|
140
|
+
end
|
141
|
+
|
142
|
+
# The object has been marked for deletion.
|
143
|
+
def deleted?
|
144
|
+
@object == false
|
145
|
+
end
|
146
|
+
|
147
|
+
def create_state(resp)
|
148
|
+
store.create(token(true), object)
|
149
|
+
resp.set_cookie(key, options.merge(value: token))
|
150
|
+
rescue KeyError # token exists
|
151
|
+
retry
|
152
|
+
end
|
153
|
+
|
154
|
+
def update_state(resp)
|
155
|
+
store.update(token, object)
|
156
|
+
resp.set_cookie(key, options.merge(value: token)) # update timestamp
|
157
|
+
rescue KeyError # token nonexistent
|
158
|
+
create_state(resp)
|
159
|
+
end
|
160
|
+
|
161
|
+
def delete_state(resp)
|
162
|
+
store.delete(token)
|
163
|
+
resp.delete_cookie(key, options)
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
#
|
168
|
+
# Storage adapters are used by Manager for storing object state.
|
169
|
+
#
|
170
|
+
# == Available Adapters
|
171
|
+
#
|
172
|
+
# * File
|
173
|
+
# * Memory
|
174
|
+
# * Postgres
|
175
|
+
# * PreparedPostgres
|
176
|
+
#
|
177
|
+
# == Adapters Must Implement CRUD Methods
|
178
|
+
#
|
179
|
+
# [create(token, object)]
|
180
|
+
# Create +object+ at +token+. Raise KeyError if token exists.
|
181
|
+
#
|
182
|
+
# [read(token)]
|
183
|
+
# Read +object+ at +token+. Return nil if token is nonexistent.
|
184
|
+
#
|
185
|
+
# [update(token, object)]
|
186
|
+
# Update +object+ at +token+. Raise KeyError if token is nonexistent.
|
187
|
+
#
|
188
|
+
# [delete(token)]
|
189
|
+
# Delete +object+ at +token+. Return nil if token is nonexistent.
|
190
|
+
#
|
191
|
+
# == Adapters Must Conform to Specification
|
192
|
+
#
|
193
|
+
# All adapters must pass the specifications in spec/state_store.rb to
|
194
|
+
# guarantee compatibility with Manager.
|
195
|
+
#
|
196
|
+
module Store
|
197
|
+
|
198
|
+
#
|
199
|
+
# The default state storage adapter: a slight subclassing of Hash.
|
200
|
+
# Memory is only suitable for development and single process apps.
|
201
|
+
#
|
202
|
+
class Memory < Hash
|
203
|
+
def create(token, object)
|
204
|
+
raise KeyError if has_key? token
|
205
|
+
store(token, object)
|
206
|
+
end
|
207
|
+
|
208
|
+
def read(token)
|
209
|
+
fetch(token, nil)
|
210
|
+
end
|
211
|
+
|
212
|
+
def update(token, object)
|
213
|
+
raise KeyError unless has_key? token
|
214
|
+
store(token, object)
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
#
|
219
|
+
# File-based state storage adapter. The state token is the file name
|
220
|
+
# and the state object is Marshaled and stored in the file.
|
221
|
+
#
|
222
|
+
class File
|
223
|
+
|
224
|
+
require 'tmpdir'
|
225
|
+
|
226
|
+
#
|
227
|
+
# [directory]
|
228
|
+
# Storage directory for state files. It must exist and have proper
|
229
|
+
# permissions.
|
230
|
+
#
|
231
|
+
def initialize(directory = Dir.tmpdir)
|
232
|
+
@directory = directory
|
233
|
+
end
|
234
|
+
|
235
|
+
def create(token, object)
|
236
|
+
flags = ::File::WRONLY|::File::CREAT|::File::EXCL
|
237
|
+
|
238
|
+
::File.open(state_file(token), flags, 0600) do |f|
|
239
|
+
f.flock ::File::LOCK_EX
|
240
|
+
f.write ::Marshal.dump(object)
|
241
|
+
end
|
242
|
+
rescue Errno::EEXIST
|
243
|
+
raise KeyError
|
244
|
+
end
|
245
|
+
|
246
|
+
def read(token)
|
247
|
+
::File.open(state_file(token)) do |f|
|
248
|
+
f.flock ::File::LOCK_SH
|
249
|
+
::Marshal.load(f) if f.size > 0
|
250
|
+
end rescue nil
|
251
|
+
end
|
252
|
+
|
253
|
+
def update(token, object)
|
254
|
+
flags = ::File::WRONLY|::File::TRUNC
|
255
|
+
|
256
|
+
::File.open(state_file(token), flags) do |f|
|
257
|
+
f.flock ::File::LOCK_EX
|
258
|
+
f.write ::Marshal.dump(object)
|
259
|
+
end
|
260
|
+
rescue Errno::ENOENT
|
261
|
+
raise KeyError
|
262
|
+
end
|
263
|
+
|
264
|
+
def delete(token)
|
265
|
+
::File.unlink state_file(token) rescue nil
|
266
|
+
end
|
267
|
+
|
268
|
+
private
|
269
|
+
|
270
|
+
def state_file(token)
|
271
|
+
::File.join(@directory, token)
|
272
|
+
end
|
273
|
+
end
|
274
|
+
|
275
|
+
#
|
276
|
+
# Postgres table-based state storage adapter.
|
277
|
+
#
|
278
|
+
# The following statement will create the required database table:
|
279
|
+
#
|
280
|
+
# CREATE UNLOGGED TABLE state (
|
281
|
+
# token varchar PRIMARY KEY,
|
282
|
+
# object bytea NOT NULL,
|
283
|
+
# mtime timestamp DEFAULT current_timestamp
|
284
|
+
# )
|
285
|
+
#
|
286
|
+
# The _mtime_ timestamp column may be used for implementing state expiry
|
287
|
+
# external to this class. For example, the following statement could be
|
288
|
+
# piped to psql(1) daily via cron(8):
|
289
|
+
#
|
290
|
+
# DELETE FROM state WHERE (mtime + interval '30d') < current_timestamp
|
291
|
+
#
|
292
|
+
class Postgres
|
293
|
+
|
294
|
+
# Indicate binary data; the object is stored in binary format.
|
295
|
+
BINARY = 1
|
296
|
+
|
297
|
+
#
|
298
|
+
# [connection]
|
299
|
+
# Instance of the database connection, e.g., PG::Connection.new
|
300
|
+
# [table]
|
301
|
+
# Table name where state is stored.
|
302
|
+
#
|
303
|
+
def initialize(connection, table = 'state')
|
304
|
+
@db = connection
|
305
|
+
@table = table
|
306
|
+
end
|
307
|
+
|
308
|
+
def create(token, object)
|
309
|
+
sql = "INSERT INTO #{@table} (token,object) VALUES ($1,$2)"
|
310
|
+
params = [token, {value: ::Marshal.dump(object), format: BINARY}]
|
311
|
+
@db.exec_params(sql, params).clear
|
312
|
+
rescue PG::UniqueViolation
|
313
|
+
raise KeyError
|
314
|
+
end
|
315
|
+
|
316
|
+
def read(token)
|
317
|
+
sql = "SELECT object FROM #{@table} WHERE token = $1"
|
318
|
+
@db.exec_params(sql, [token], BINARY) do |result|
|
319
|
+
::Marshal.load result.getvalue(0,0)
|
320
|
+
end rescue nil
|
321
|
+
end
|
322
|
+
|
323
|
+
def update(token, object)
|
324
|
+
sql = "UPDATE #{@table} SET object = $2 WHERE token = $1"
|
325
|
+
params = [token, {value: ::Marshal.dump(object), format: BINARY}]
|
326
|
+
@db.exec_params(sql, params) do |result|
|
327
|
+
raise KeyError if result.cmd_tuples == 0
|
328
|
+
end
|
329
|
+
end
|
330
|
+
|
331
|
+
def delete(token)
|
332
|
+
sql = "DELETE FROM #{@table} WHERE token = $1"
|
333
|
+
@db.exec_params(sql, [token]).clear
|
334
|
+
end
|
335
|
+
end
|
336
|
+
|
337
|
+
#
|
338
|
+
# Postgres table-based state storage adapter using prepared statements.
|
339
|
+
# Because accessing state is a highly repetitive activity, prepared
|
340
|
+
# statements may offer some optimization.
|
341
|
+
#
|
342
|
+
# See Postgres for details.
|
343
|
+
#
|
344
|
+
class PreparedPostgres
|
345
|
+
BINARY = 1
|
346
|
+
|
347
|
+
def initialize(connection, table = 'state')
|
348
|
+
@db = connection
|
349
|
+
begin
|
350
|
+
@db.prepare 'create_state',
|
351
|
+
"INSERT INTO #{table} (token,object) VALUES ($1,$2)"
|
352
|
+
@db.prepare 'read_state',
|
353
|
+
"SELECT object FROM #{table} WHERE token=$1"
|
354
|
+
@db.prepare 'update_state',
|
355
|
+
"UPDATE #{table} SET object=$2, mtime=NOW() WHERE token=$1"
|
356
|
+
@db.prepare 'delete_state',
|
357
|
+
"DELETE FROM #{table} WHERE token=$1"
|
358
|
+
rescue PG::DuplicatePstatement
|
359
|
+
end
|
360
|
+
end
|
361
|
+
|
362
|
+
def create(token, object)
|
363
|
+
@db.exec_prepared('create_state', params(token, object)).clear
|
364
|
+
rescue PG::UniqueViolation
|
365
|
+
raise KeyError
|
366
|
+
end
|
367
|
+
|
368
|
+
def read(token)
|
369
|
+
@db.exec_prepared('read_state', params(token), BINARY) do |result|
|
370
|
+
::Marshal.load result.getvalue(0,0)
|
371
|
+
end rescue nil
|
372
|
+
end
|
373
|
+
|
374
|
+
def update(token, object)
|
375
|
+
@db.exec_prepared('update_state', params(token, object)) do |result|
|
376
|
+
raise KeyError if result.cmd_tuples == 0
|
377
|
+
end
|
378
|
+
end
|
379
|
+
|
380
|
+
def delete(token)
|
381
|
+
@db.exec_prepared('delete_state', params(token)).clear
|
382
|
+
end
|
383
|
+
|
384
|
+
private
|
385
|
+
|
386
|
+
def params(token, obj = nil)
|
387
|
+
obj ? [token, {value: ::Marshal.dump(obj), format:BINARY}] : [token]
|
388
|
+
end
|
389
|
+
end
|
390
|
+
end
|
391
|
+
end
|
392
|
+
end
|
data/spec/spec_state.rb
ADDED
@@ -0,0 +1,174 @@
|
|
1
|
+
require 'rack/state'
|
2
|
+
require 'rack/mock'
|
3
|
+
|
4
|
+
module StateMiddlewareHelpers #:nodoc:
|
5
|
+
def valid_token
|
6
|
+
lambda { |obj|
|
7
|
+
name = obj.split('=')[0]
|
8
|
+
obj.match /^#{name}=[^;]{22,}$/
|
9
|
+
}
|
10
|
+
end
|
11
|
+
|
12
|
+
def expired_token
|
13
|
+
lambda { |obj|
|
14
|
+
name = obj.split('=')[0]
|
15
|
+
obj.match /^#{name}=; max-age=0; expires=Thu, 01 Jan 1970/
|
16
|
+
}
|
17
|
+
end
|
18
|
+
|
19
|
+
def get_state(key, token)
|
20
|
+
@req.get("/get_state?key=#{key}", {'HTTP_COOKIE' => token})['Set-Cookie']
|
21
|
+
end
|
22
|
+
|
23
|
+
def set_state(key, token = nil)
|
24
|
+
cookie = token ? {'HTTP_COOKIE' => token} : {}
|
25
|
+
@req.get("/set_state?key=#{key}", cookie)['Set-Cookie']
|
26
|
+
end
|
27
|
+
|
28
|
+
def del_state(key, token)
|
29
|
+
@req.get("/del_state?key=#{key}", {'HTTP_COOKIE' => token})['Set-Cookie']
|
30
|
+
end
|
31
|
+
|
32
|
+
def mock_app
|
33
|
+
Proc.new do |env|
|
34
|
+
req = Rack::Request.new(env)
|
35
|
+
key = req.params['key'] || 'token'
|
36
|
+
skey = 'rack.state.' + key
|
37
|
+
data = nil
|
38
|
+
|
39
|
+
case req.path
|
40
|
+
when '/get_state'
|
41
|
+
data = env[skey].get
|
42
|
+
when '/set_state'
|
43
|
+
data = env[skey].set(key + 'data')
|
44
|
+
when '/del_state'
|
45
|
+
env[skey].delete
|
46
|
+
end
|
47
|
+
|
48
|
+
Rack::Response.new(data.to_s).to_a
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
describe Rack::State do
|
54
|
+
|
55
|
+
describe 'Default Options' do
|
56
|
+
extend StateMiddlewareHelpers
|
57
|
+
|
58
|
+
before do
|
59
|
+
@key = 'token' # default key name
|
60
|
+
@req = Rack::MockRequest.new(Rack::State.new(mock_app))
|
61
|
+
end
|
62
|
+
|
63
|
+
should 'not return token if state object not accessed' do
|
64
|
+
@req.get('/')['Set-Cookie'].should.be.nil
|
65
|
+
end
|
66
|
+
|
67
|
+
should 'return token when setting state' do
|
68
|
+
set_state(@key).should.be.a valid_token
|
69
|
+
end
|
70
|
+
|
71
|
+
should 'return previous token' do
|
72
|
+
token1 = set_state(@key)
|
73
|
+
token2 = get_state(@key, token1)
|
74
|
+
token1.should.equal token2
|
75
|
+
end
|
76
|
+
|
77
|
+
should 'not return token after deleting state' do
|
78
|
+
token1 = set_state(@key)
|
79
|
+
token2 = del_state(@key, token1)
|
80
|
+
token3 = get_state(@key, token1)
|
81
|
+
token1.should.be.a valid_token
|
82
|
+
token2.should.be.an expired_token
|
83
|
+
token3.should.be.nil
|
84
|
+
end
|
85
|
+
|
86
|
+
should 'return no token when getting state with nonexistent token' do
|
87
|
+
get_state(@key, "#{@key}=nonexistent").should.be.nil
|
88
|
+
end
|
89
|
+
|
90
|
+
should 'return new token when setting state with nonexistent token' do
|
91
|
+
token = set_state(@key, "#{@key}=nonexistent")
|
92
|
+
token.should.not.equal "#{@key}=nonexistent"
|
93
|
+
token.should.be.a valid_token
|
94
|
+
end
|
95
|
+
|
96
|
+
should 'expire token when deleting state with nonexistent token' do
|
97
|
+
token = del_state(@key, "#{@key}=nonexistent")
|
98
|
+
token.should.not.equal "#{@key}=nonexistent"
|
99
|
+
token.should.be.an expired_token
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
describe 'Two Instances Each With Different Stores' do
|
104
|
+
require 'tmpdir'
|
105
|
+
extend StateMiddlewareHelpers
|
106
|
+
|
107
|
+
before do
|
108
|
+
fstore = Rack::State::Store::File.new(@dir = Dir.mktmpdir)
|
109
|
+
mstore = Rack::State::Store::Memory.new
|
110
|
+
app = Rack::State.new(mock_app, key:'f', store:fstore)
|
111
|
+
app = Rack::State.new(app, key:'m', store:mstore)
|
112
|
+
@req = Rack::MockRequest.new(app)
|
113
|
+
end
|
114
|
+
|
115
|
+
after do
|
116
|
+
FileUtils.remove_dir @dir
|
117
|
+
end
|
118
|
+
|
119
|
+
should 'return persistent data via response body on subsequent requests' do
|
120
|
+
fres = @req.get('/set_state?key=f')
|
121
|
+
mres = @req.get('/set_state?key=m')
|
122
|
+
fres.body.should.equal 'fdata'
|
123
|
+
mres.body.should.equal 'mdata'
|
124
|
+
fres = @req.get('/get_state?key=f', 'HTTP_COOKIE' => fres['Set-Cookie'])
|
125
|
+
mres = @req.get('/get_state?key=m', 'HTTP_COOKIE' => mres['Set-Cookie'])
|
126
|
+
fres.body.should.equal 'fdata'
|
127
|
+
mres.body.should.equal 'mdata'
|
128
|
+
fres = @req.get('/del_state?key=f', 'HTTP_COOKIE' => fres['Set-Cookie'])
|
129
|
+
mres = @req.get('/del_state?key=m', 'HTTP_COOKIE' => mres['Set-Cookie'])
|
130
|
+
fres.body.should.be.empty
|
131
|
+
mres.body.should.be.empty
|
132
|
+
end
|
133
|
+
|
134
|
+
should 'return tokens when setting state for two different keys' do
|
135
|
+
ftoken = set_state('f')
|
136
|
+
mtoken = set_state('m')
|
137
|
+
ftoken.should.be.a valid_token
|
138
|
+
mtoken.should.be.a valid_token
|
139
|
+
ftoken.should.not.equal mtoken
|
140
|
+
end
|
141
|
+
|
142
|
+
should 'set states and delete one at a time verifing no token returned' do
|
143
|
+
ftoken = set_state('f')
|
144
|
+
mtoken = set_state('m')
|
145
|
+
del_state('f', ftoken).should.be.an expired_token
|
146
|
+
get_state('f', ftoken).should.be.nil
|
147
|
+
get_state('m', mtoken).should.be.a valid_token
|
148
|
+
del_state('m', mtoken).should.be.an expired_token
|
149
|
+
get_state('m', mtoken).should.be.nil
|
150
|
+
end
|
151
|
+
|
152
|
+
should 'return previous token for each state' do
|
153
|
+
ft1 = set_state('f')
|
154
|
+
mt1 = set_state('m')
|
155
|
+
ft2 = get_state('f', ft1)
|
156
|
+
mt2 = get_state('m', mt1)
|
157
|
+
ft1.should.equal ft2
|
158
|
+
mt1.should.equal mt2
|
159
|
+
ft2.should.not.equal mt2
|
160
|
+
end
|
161
|
+
|
162
|
+
should 'return new tokens when setting states with nonexistent tokens' do
|
163
|
+
ftoken= 'f=nonexistent'
|
164
|
+
mtoken= 'm=nonexistent'
|
165
|
+
ft = set_state('f', ftoken)
|
166
|
+
mt = set_state('m', mtoken)
|
167
|
+
ft.should.not.equal ftoken
|
168
|
+
ft.should.be.a valid_token
|
169
|
+
mt.should.not.equal mtoken
|
170
|
+
mt.should.be.a valid_token
|
171
|
+
mt.should.not.equal ft
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
require_relative 'state_store'
|
2
|
+
require 'tmpdir'
|
3
|
+
|
4
|
+
describe Rack::State::Store::File do
|
5
|
+
|
6
|
+
before do
|
7
|
+
@dir = Dir.mktmpdir
|
8
|
+
@store = Rack::State::Store::File.new(@dir)
|
9
|
+
end
|
10
|
+
|
11
|
+
after do
|
12
|
+
FileUtils.remove_dir @dir
|
13
|
+
end
|
14
|
+
|
15
|
+
behaves_like Rack::State::Store
|
16
|
+
|
17
|
+
it 'creates and deletes token file when creating and deleting state' do
|
18
|
+
@store.create(@token, @data)
|
19
|
+
File.should.exist File.join(@dir, @token)
|
20
|
+
@store.delete(@token)
|
21
|
+
File.should.not.exist File.join(@dir, @token)
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
#
|
2
|
+
# This is more of an integration test that requires a proper environment to run.
|
3
|
+
# Therefore, these specs are encapsulated by exception handling to contain a
|
4
|
+
# potential explosion!
|
5
|
+
#
|
6
|
+
# To run these specs, simply:
|
7
|
+
# 1. Set appropriate environment variables: PGDATABASE, PGHOST, PGPORT, PGUSER
|
8
|
+
# 2. Specify your database password in ~/.pgpass
|
9
|
+
#
|
10
|
+
|
11
|
+
begin
|
12
|
+
require 'pg'
|
13
|
+
require_relative 'state_store'
|
14
|
+
|
15
|
+
PGTable = 'state'
|
16
|
+
PGDB = PG::Connection.new
|
17
|
+
PGDB.exec 'SET client_min_messages TO WARNING'
|
18
|
+
PGDB.exec <<-SQL
|
19
|
+
CREATE TEMPORARY TABLE #{PGTable} (
|
20
|
+
token varchar PRIMARY KEY,
|
21
|
+
object bytea NOT NULL,
|
22
|
+
mtime timestamp DEFAULT current_timestamp
|
23
|
+
)
|
24
|
+
SQL
|
25
|
+
|
26
|
+
describe Rack::State::Store::Postgres do
|
27
|
+
before { @store = Rack::State::Store::Postgres.new(PGDB, PGTable) }
|
28
|
+
after { PGDB.exec "TRUNCATE TABLE #{PGTable}" }
|
29
|
+
behaves_like Rack::State::Store
|
30
|
+
end
|
31
|
+
|
32
|
+
describe Rack::State::Store::PreparedPostgres do
|
33
|
+
before { @store = Rack::State::Store::PreparedPostgres.new(PGDB, PGTable) }
|
34
|
+
after { PGDB.exec "TRUNCATE TABLE #{PGTable}" }
|
35
|
+
behaves_like Rack::State::Store
|
36
|
+
end
|
37
|
+
|
38
|
+
PGDB.close
|
39
|
+
rescue LoadError
|
40
|
+
$stderr.puts 'Not running Postgres specs: cannot load the "pg" library.'
|
41
|
+
rescue PG::ConnectionBad
|
42
|
+
$stderr.puts 'Not running Postgres specs: cannot connect to the database.'
|
43
|
+
end
|
data/spec/state_store.rb
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
require 'rack/state'
|
2
|
+
|
3
|
+
#
|
4
|
+
# All Rack::State::Store adapters must include these specifications.
|
5
|
+
# Passing these specs guarantees compatibility with StateObject.
|
6
|
+
#
|
7
|
+
shared Rack::State::Store do
|
8
|
+
@token = 'a-valid-token-name'
|
9
|
+
@data = 'example state data'
|
10
|
+
|
11
|
+
it 'returns nil when reading state for a nonexistent token' do
|
12
|
+
@store.read(@token).should.be.nil
|
13
|
+
end
|
14
|
+
|
15
|
+
it 'returns nil when deleting state for a nonexistent token' do
|
16
|
+
@store.delete(@token).should.be.nil
|
17
|
+
end
|
18
|
+
|
19
|
+
it 'raises KeyError when updating state for nonexistent token' do
|
20
|
+
should.raise(KeyError) { @store.update(@token, @data) }
|
21
|
+
end
|
22
|
+
|
23
|
+
it 'raises KeyError when creating state for existing token' do
|
24
|
+
should.not.raise(KeyError) { @store.create(@token, @data) }
|
25
|
+
should. raise(KeyError) { @store.create(@token, @data) }
|
26
|
+
end
|
27
|
+
|
28
|
+
it 'executes the standard CRUD process as expected' do
|
29
|
+
should.not.raise(KeyError) { @store.create(@token, @data) }
|
30
|
+
@store.read(@token).should.equal @data
|
31
|
+
should.not.raise(KeyError) { @store.update(@token, @data+'more') }
|
32
|
+
@store.read(@token).should.equal @data+'more'
|
33
|
+
@store.delete(@token)
|
34
|
+
@store.read(@token).should.be.nil
|
35
|
+
end
|
36
|
+
end
|
metadata
ADDED
@@ -0,0 +1,103 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: rack-state
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Clint Pachl
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2013-09-22 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: rack
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ! '>='
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '1.5'
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ! '>='
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '1.5'
|
30
|
+
- !ruby/object:Gem::Dependency
|
31
|
+
name: bacon
|
32
|
+
requirement: !ruby/object:Gem::Requirement
|
33
|
+
none: false
|
34
|
+
requirements:
|
35
|
+
- - ! '>='
|
36
|
+
- !ruby/object:Gem::Version
|
37
|
+
version: '1.2'
|
38
|
+
type: :development
|
39
|
+
prerelease: false
|
40
|
+
version_requirements: !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ! '>='
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: '1.2'
|
46
|
+
description: ! 'Rack::State is middleware that securely stores and manages object
|
47
|
+
state.
|
48
|
+
|
49
|
+
|
50
|
+
It is similar to Rack::Session, but provides state management for multiple
|
51
|
+
|
52
|
+
objects with independent control of the visibility, security, and storage of
|
53
|
+
|
54
|
+
each object''s state.
|
55
|
+
|
56
|
+
|
57
|
+
Applications use the Manager API to _set_, _get_, and _delete_ state of
|
58
|
+
|
59
|
+
any object that requires persistence in a stateless environment.
|
60
|
+
|
61
|
+
'
|
62
|
+
email: pachl@ecentryx.com
|
63
|
+
executables: []
|
64
|
+
extensions: []
|
65
|
+
extra_rdoc_files: []
|
66
|
+
files:
|
67
|
+
- README.rdoc
|
68
|
+
- lib/rack/state.rb
|
69
|
+
- spec/state_store.rb
|
70
|
+
- spec/spec_state.rb
|
71
|
+
- spec/spec_state_store_file.rb
|
72
|
+
- spec/spec_state_store_memory.rb
|
73
|
+
- spec/spec_state_store_postgres.rb
|
74
|
+
homepage: https://rubygems.org/gems/rack-state
|
75
|
+
licenses:
|
76
|
+
- ISC
|
77
|
+
post_install_message:
|
78
|
+
rdoc_options: []
|
79
|
+
require_paths:
|
80
|
+
- lib
|
81
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
82
|
+
none: false
|
83
|
+
requirements:
|
84
|
+
- - ! '>='
|
85
|
+
- !ruby/object:Gem::Version
|
86
|
+
version: 1.9.2
|
87
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
88
|
+
none: false
|
89
|
+
requirements:
|
90
|
+
- - ! '>='
|
91
|
+
- !ruby/object:Gem::Version
|
92
|
+
version: '0'
|
93
|
+
requirements: []
|
94
|
+
rubyforge_project:
|
95
|
+
rubygems_version: 1.8.23
|
96
|
+
signing_key:
|
97
|
+
specification_version: 3
|
98
|
+
summary: Secure & Modular State Management
|
99
|
+
test_files:
|
100
|
+
- spec/spec_state.rb
|
101
|
+
- spec/spec_state_store_file.rb
|
102
|
+
- spec/spec_state_store_memory.rb
|
103
|
+
- spec/spec_state_store_postgres.rb
|