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