global_session 0.9.0

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.
@@ -0,0 +1,343 @@
1
+ # Standard library dependencies
2
+ require 'set'
3
+ require 'zlib'
4
+
5
+ # Gem dependencies
6
+ require 'uuidtools'
7
+
8
+ module GlobalSession
9
+ # Ladies and gentlemen: the one and only, star of the show, GLOBAL SESSION!
10
+ #
11
+ # Session is designed to act as much like a Hash as possible. You can use
12
+ # most of the methods you would use with Hash: [], has_key?, each, etc. It has a
13
+ # few additional methods that are specific to itself, mostly involving whether
14
+ # it's expired, valid, supports a certain key, etc.
15
+ #
16
+ class Session
17
+ attr_reader :id, :authority, :created_at, :expired_at, :directory
18
+
19
+ # Create a new global session object.
20
+ #
21
+ # === Parameters
22
+ # directory(Directory):: directory implementation that the session should use for various operations
23
+ # cookie(String):: Optional, serialized global session cookie. If none is supplied, a new session is created.
24
+ # valid_signature_digest(String):: Optional, already-trusted signature. If supplied, the expensive RSA-verify operation will be skipped if the cookie's signature matches the value supplied.
25
+ #
26
+ # ===Raise
27
+ # InvalidSession:: if the session contained in the cookie has been invalidated
28
+ # ExpiredSession:: if the session contained in the cookie has expired
29
+ # MalformedCookie:: if the cookie was corrupt or malformed
30
+ # SecurityError:: if signature is invalid or cookie is not signed by a trusted authority
31
+ def initialize(directory, cookie=nil, valid_signature_digest=nil)
32
+ @configuration = directory.configuration
33
+ @schema_signed = Set.new((@configuration['attributes']['signed']))
34
+ @schema_insecure = Set.new((@configuration['attributes']['insecure']))
35
+ @directory = directory
36
+
37
+ if cookie && !cookie.empty?
38
+ load_from_cookie(cookie, valid_signature_digest)
39
+ elsif @directory.local_authority_name
40
+ create_from_scratch
41
+ else
42
+ create_invalid
43
+ end
44
+ end
45
+
46
+ # Determine whether the session is valid. This method simply delegates to the
47
+ # directory associated with this session.
48
+ #
49
+ # === Return
50
+ # valid(true|false):: True if the session is valid, false otherwise
51
+ def valid?
52
+ @directory.valid_session?(@id, @expired_at)
53
+ end
54
+
55
+ # Serialize the session to a form suitable for use with HTTP cookies. If any
56
+ # secure attributes have changed since the session was instantiated, compute
57
+ # a fresh RSA signature.
58
+ #
59
+ # === Return
60
+ # cookie(String):: The B64cookie-encoded Zlib-compressed JSON-serialized global session hash
61
+ def to_s
62
+ if @cookie && !@dirty_insecure && !@dirty_secure
63
+ #use cached cookie if nothing has changed
64
+ return @cookie
65
+ end
66
+
67
+ hash = {'id'=>@id,
68
+ 'tc'=>@created_at.to_i, 'te'=>@expired_at.to_i,
69
+ 'ds'=>@signed}
70
+
71
+ if @signature && !@dirty_secure
72
+ #use cached signature unless we've changed secure state
73
+ authority = @authority
74
+ else
75
+ authority_check
76
+ authority = @directory.local_authority_name
77
+ hash['a'] = authority
78
+ digest = canonical_digest(hash)
79
+ @signature = Encoding::Base64Cookie.dump(@directory.private_key.private_encrypt(digest))
80
+ end
81
+
82
+ hash['dx'] = @insecure
83
+ hash['s'] = @signature
84
+ hash['a'] = authority
85
+
86
+ json = Encoding::JSON.dump(hash)
87
+ zbin = Zlib::Deflate.deflate(json, Zlib::BEST_COMPRESSION)
88
+ return Encoding::Base64Cookie.dump(zbin)
89
+ end
90
+
91
+ # Determine whether the global session schema allows a given key to be placed
92
+ # in the global session.
93
+ #
94
+ # === Parameters
95
+ # key(String):: The name of the key
96
+ #
97
+ # === Return
98
+ # supported(true|false):: Whether the specified key is supported
99
+ def supports_key?(key)
100
+ @schema_signed.include?(key) || @schema_insecure.include?(key)
101
+ end
102
+
103
+ # Determine whether this session contains a value with the specified key.
104
+ #
105
+ # === Parameters
106
+ # key(String):: The name of the key
107
+ #
108
+ # === Return
109
+ # contained(true|false):: Whether the session currently has a value for the specified key.
110
+ def has_key?(key)
111
+ @signed.has_key?(key) || @insecure.has_key?(key)
112
+ end
113
+
114
+ # Return the keys that are currently present in the global session.
115
+ #
116
+ # === Return
117
+ # keys(Array):: List of keys contained in the global session
118
+ def keys
119
+ @signed.keys + @insecure.keys
120
+ end
121
+
122
+ # Return the values that are currently present in the global session.
123
+ #
124
+ # === Return
125
+ # values(Array):: List of values contained in the global session
126
+ def values
127
+ @signed.values + @insecure.values
128
+ end
129
+
130
+ # Iterate over each key/value pair
131
+ #
132
+ # === Block
133
+ # An iterator which will be called with each key/value pair
134
+ #
135
+ # === Return
136
+ # Returns the value of the last expression evaluated by the block
137
+ def each_pair(&block) # :yields: |key, value|
138
+ @signed.each_pair(&block)
139
+ @insecure.each_pair(&block)
140
+ end
141
+
142
+ # Lookup a value by its key.
143
+ #
144
+ # === Parameters
145
+ # key(String):: the key
146
+ #
147
+ # === Return
148
+ # value(Object):: The value associated with +key+, or nil if +key+ is not present
149
+ def [](key)
150
+ key = key.to_s #take care of symbol-style keys
151
+ @signed[key] || @insecure[key]
152
+ end
153
+
154
+ # Set a value in the global session hash. If the supplied key is denoted as
155
+ # secure by the global session schema, causes a new signature to be computed
156
+ # when the session is next serialized.
157
+ #
158
+ # === Parameters
159
+ # key(String):: The key to set
160
+ # value(Object):: The value to set
161
+ #
162
+ # === Return
163
+ # value(Object):: Always returns the value that was set
164
+ #
165
+ # ===Raise
166
+ # InvalidSession:: if the session has been invalidated (and therefore can't be written to)
167
+ # ArgumentError:: if the configuration doesn't define the specified key as part of the global session
168
+ # NoAuthority:: if the specified key is secure and the local node is not an authority
169
+ # UnserializableType:: if the specified value can't be serialized as JSON
170
+ def []=(key, value)
171
+ key = key.to_s #take care of symbol-style keys
172
+ raise InvalidSession unless valid?
173
+
174
+ #Ensure that the value is serializable (will raise if not)
175
+ canonicalize(value)
176
+
177
+ if @schema_signed.include?(key)
178
+ authority_check
179
+ @signed[key] = value
180
+ @dirty_secure = true
181
+ elsif @schema_insecure.include?(key)
182
+ @insecure[key] = value
183
+ @dirty_insecure = true
184
+ else
185
+ raise ArgumentError, "Attribute '#{key}' is not specified in global session configuration"
186
+ end
187
+
188
+ return value
189
+ end
190
+
191
+ # Invalidate this session by reporting its UUID to the Directory.
192
+ #
193
+ # === Return
194
+ # unknown(Object):: Returns whatever the Directory returns
195
+ def invalidate!
196
+ @directory.report_invalid_session(@id, @expired_at)
197
+ end
198
+
199
+ # Renews this global session, changing its expiry timestamp into the future.
200
+ # Causes a new signature will be computed when the session is next serialized.
201
+ #
202
+ # === Return
203
+ # true:: Always returns true
204
+ def renew!(expired_at=nil)
205
+ authority_check
206
+ minutes = @configuration['timeout'].to_i
207
+ expired_at ||= Time.at(Time.now.utc + 60 * minutes)
208
+ @expired_at = expired_at
209
+ @dirty_secure = true
210
+ end
211
+
212
+ # Return the SHA1 hash of the most recently-computed RSA signature of this session.
213
+ # This isn't really intended for the end user; it exists so the Web framework integration
214
+ # code can optimize request speed by caching the most recently verified signature in the
215
+ # local session and avoid re-verifying it on every request.
216
+ #
217
+ # === Return
218
+ # digest(String):: SHA1 hex-digest of most-recently-computed signature
219
+ def signature_digest
220
+ @signature ? digest(@signature) : nil
221
+ end
222
+
223
+ private
224
+
225
+ def authority_check # :nodoc:
226
+ unless @directory.local_authority_name
227
+ raise NoAuthority, 'Cannot change secure session attributes; we are not an authority'
228
+ end
229
+ end
230
+
231
+ def canonical_digest(input) # :nodoc:
232
+ canonical = Encoding::JSON.dump(canonicalize(input))
233
+ return digest(canonical)
234
+ end
235
+
236
+ def digest(input) # :nodoc:
237
+ return Digest::SHA1.new().update(input).hexdigest
238
+ end
239
+
240
+ def canonicalize(input) # :nodoc:
241
+ case input
242
+ when Hash
243
+ output = Array.new
244
+ ordered_keys = input.keys.sort
245
+ ordered_keys.each do |key|
246
+ output << [ canonicalize(key), canonicalize(input[key]) ]
247
+ end
248
+ when Array
249
+ output = input.collect { |x| canonicalize(x) }
250
+ when Numeric, String, NilClass
251
+ output = input
252
+ else
253
+ raise UnserializableType, "Objects of type #{input.class.name} cannot be serialized in the global session"
254
+ end
255
+
256
+ return output
257
+ end
258
+
259
+ def load_from_cookie(cookie, valid_signature_digest) # :nodoc:
260
+ begin
261
+ zbin = Encoding::Base64Cookie.load(cookie)
262
+ json = Zlib::Inflate.inflate(zbin)
263
+ hash = Encoding::JSON.load(json)
264
+ rescue Exception => e
265
+ mc = MalformedCookie.new("Caused by #{e.class.name}: #{e.message}")
266
+ mc.set_backtrace(e.backtrace)
267
+ raise mc
268
+ end
269
+
270
+ id = hash['id']
271
+ authority = hash['a']
272
+ created_at = Time.at(hash['tc'].to_i).utc
273
+ expired_at = Time.at(hash['te'].to_i).utc
274
+ signed = hash['ds']
275
+ insecure = hash.delete('dx')
276
+ signature = hash.delete('s')
277
+
278
+ unless valid_signature_digest == digest(signature)
279
+ #Check signature
280
+ expected = canonical_digest(hash)
281
+ signer = @directory.authorities[authority]
282
+ raise SecurityError, "Unknown signing authority #{authority}" unless signer
283
+ got = signer.public_decrypt(Encoding::Base64Cookie.load(signature))
284
+ unless (got == expected)
285
+ raise SecurityError, "Signature mismatch on global session cookie; tampering suspected"
286
+ end
287
+ end
288
+
289
+ #Check trust in signing authority
290
+ unless @directory.trusted_authority?(authority)
291
+ raise SecurityError, "Global sessions signed by #{authority} are not trusted"
292
+ end
293
+
294
+ #Check expiration
295
+ unless expired_at > Time.now.utc
296
+ raise ExpiredSession, "Session expired at #{expired_at}"
297
+ end
298
+
299
+ #Check other validity (delegate to directory)
300
+ unless @directory.valid_session?(id, expired_at)
301
+ raise InvalidSession, "Global session has been invalidated"
302
+ end
303
+
304
+ #If all validation stuff passed, assign our instance variables.
305
+ @id = id
306
+ @authority = authority
307
+ @created_at = created_at
308
+ @expired_at = expired_at
309
+ @signed = signed
310
+ @insecure = insecure
311
+ @signature = signature
312
+ @cookie = cookie
313
+ end
314
+
315
+ def create_from_scratch # :nodoc:
316
+ authority_check
317
+
318
+ @signed = {}
319
+ @insecure = {}
320
+ @created_at = Time.now.utc
321
+ @authority = @directory.local_authority_name
322
+
323
+ if defined?(::UUIDTools) # UUIDTools v2
324
+ @id = ::UUIDTools::UUID.timestamp_create.to_s
325
+ elsif defined?(::UUID) # UUIDTools v1
326
+ @id = ::UUID.timestamp_create.to_s
327
+ else
328
+ raise TypeError, "Neither UUIDTools nor UUID defined; unsupported UUIDTools version?"
329
+ end
330
+
331
+ renew!
332
+ end
333
+
334
+ def create_invalid # :nodoc:
335
+ @id = nil
336
+ @created_at = Time.now.utc
337
+ @expired_at = created_at
338
+ @signed = {}
339
+ @insecure = {}
340
+ @authority = nil
341
+ end
342
+ end
343
+ end
@@ -0,0 +1,63 @@
1
+ module GlobalSession
2
+ # Indicates that the global session configuration file is malformatted or missing
3
+ # required fields. Also used as a base class for other errors.
4
+ class ConfigurationError < Exception; end
5
+
6
+ # The general category of client-side errors. Used solely as a base class.
7
+ class ClientError < Exception; end
8
+
9
+ # Indicates that the global session configuration file is missing from disk.
10
+ #
11
+ class MissingConfiguration < ConfigurationError; end
12
+
13
+ # Indicates that a client submitted a request with a valid session cookie, but the
14
+ # session ID was reported as invalid by the Directory.
15
+ #
16
+ # See Directory#valid_session? for more information.
17
+ #
18
+ class InvalidSession < ClientError; end
19
+
20
+ # Indicates that a client submitted a request with a valid session cookie, but the
21
+ # session has expired.
22
+ #
23
+ class ExpiredSession < ClientError; end
24
+
25
+ # Indicates that a client submitted a request with a session cookie that could not
26
+ # be decoded or decompressed.
27
+ #
28
+ class MalformedCookie < ClientError; end
29
+
30
+ # Indicates that application code tried to put an unserializable object into the glboal
31
+ # session hash. Because the global session is serialized as JSON and not all Ruby types
32
+ # can be easily round-tripped to JSON and back without data loss, we constrain the types
33
+ # that can be serialized.
34
+ #
35
+ # See GlobalSession::Encoding::JSON for more information on serializable types.
36
+ #
37
+ class UnserializableType < ConfigurationError; end
38
+
39
+ # Indicates that the application code tried to write a secure session attribute or
40
+ # renew the global session. Both of these operations require a local authority
41
+ # because they require a new signature to be computed on the global session.
42
+ #
43
+ # See GlobalSession::Configuration and GlobalSession::Directory for more
44
+ # information.
45
+ #
46
+ class NoAuthority < ConfigurationError; end
47
+ end
48
+
49
+ #Make sure gem dependencies are activated.
50
+ require 'uuidtools'
51
+ require 'json'
52
+ require 'active_support'
53
+
54
+ #Require Ruby library dependencies
55
+ require 'openssl'
56
+
57
+ #Require the core suite of GlobalSession classes and modules
58
+ basedir = File.dirname(__FILE__)
59
+ require File.join(basedir, 'global_session', 'configuration')
60
+ require File.join(basedir, 'global_session', 'directory')
61
+ require File.join(basedir, 'global_session', 'encoding')
62
+ require File.join(basedir, 'global_session', 'session')
63
+ require File.join(basedir, 'global_session', 'integrated_session')
data/rails/init.rb ADDED
@@ -0,0 +1,3 @@
1
+ #
2
+ # Empty placeholder file so Rails 2.x will recognize us as a plugin
3
+ #
@@ -0,0 +1 @@
1
+ ./script/generate global_session authority <name of authority>
@@ -0,0 +1,32 @@
1
+ class GlobalSessionAuthorityGenerator < Rails::Generator::Base
2
+ def initialize(runtime_args, runtime_options = {})
3
+ super
4
+
5
+ @app_name = File.basename(::Rails.root)
6
+ @auth_name = args.shift
7
+ raise ArgumentError, "Must specify name for global session authority, e.g. 'mycoolapp'" unless @auth_name
8
+ end
9
+
10
+ def manifest
11
+ record do |m|
12
+ new_key = OpenSSL::PKey::RSA.generate( 1024 )
13
+ new_public = new_key.public_key.to_pem
14
+ new_private = new_key.to_pem
15
+
16
+ dest_dir = File.join(::Rails.root, 'config', 'authorities')
17
+ FileUtils.mkdir_p(dest_dir)
18
+
19
+ File.open(File.join(dest_dir, @auth_name + ".pub"), 'w') do |f|
20
+ f.puts new_public
21
+ end
22
+
23
+ File.open(File.join(dest_dir, @auth_name + ".key"), 'w') do |f|
24
+ f.puts new_private
25
+ end
26
+
27
+ puts "***"
28
+ puts "*** Don't forget to delete config/authorities/#{@auth_name}.key"
29
+ puts "***"
30
+ end
31
+ end
32
+ end
@@ -0,0 +1 @@
1
+ ./script/generate global_session config <DNS domain for production cookie>
@@ -0,0 +1,19 @@
1
+ class GlobalSessionConfigGenerator < Rails::Generator::Base
2
+ def initialize(runtime_args, runtime_options = {})
3
+ super
4
+
5
+ @app_name = File.basename(::Rails.root)
6
+ @app_domain = args.shift
7
+ raise ArgumentError, "Must specify DNS domain for global session cookie, e.g. 'example.com'" unless @app_domain
8
+ end
9
+
10
+ def manifest
11
+ record do |m|
12
+
13
+ m.template 'global_session.yml.erb',
14
+ 'config/global_session.yml',
15
+ :assigns=>{:app_name=>@app_name,
16
+ :app_domain=>@app_domain}
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,49 @@
1
+ # Common settings of the global session (that apply to all Rails environments)
2
+ # are listed here. These may be overidden in the environment-specific section.
3
+ common:
4
+ attributes:
5
+ # Signed attributes of the global session
6
+ signed:
7
+ - user
8
+ # Untrusted attributes of the global session
9
+ insecure:
10
+ - account
11
+ # Enable local session integration in order to use the ActionController
12
+ # method #session to access both local AND global session state, with
13
+ # global attributes always taking precedence over local attributes.
14
+ integrated: true
15
+ ephemeral: false
16
+
17
+ # Test/spec runs
18
+ test:
19
+ timeout: 15 #minutes
20
+ renew: 5 #minutes before expiration
21
+ cookie:
22
+ name: _session_gbl
23
+ #the name of the local authority (optional)
24
+ authority: test
25
+ #which authorities this app will trust
26
+ trust:
27
+ - test
28
+
29
+ # Development mode
30
+ development:
31
+ timeout: 60
32
+ renew: 15
33
+ cookie:
34
+ name: _session_gbl
35
+ authority: development
36
+ trust:
37
+ - development
38
+ - production
39
+
40
+ # Production mode
41
+ production:
42
+ timeout: 60
43
+ renew: 15
44
+ cookie:
45
+ name: _session_gbl
46
+ domain: <%= app_domain %>
47
+ authority: production
48
+ trust:
49
+ - production