global_session 0.9.0

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