has_global_session 0.8.2 → 0.8.3

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 CHANGED
@@ -134,7 +134,7 @@ Here are some reasons you might consider dividing your systems into different
134
134
  authorities:
135
135
  * beta/staging system vs. production system
136
136
  * system hosted by a third party vs. system hosted internally
137
- * e-commerce node vs. storefront node
137
+ * e-commerce node vs. storefront node vs. admin node
138
138
 
139
139
  == The Directory
140
140
 
@@ -142,8 +142,8 @@ The Directory is a Ruby object instantiated by HasGlobalSession in order to
142
142
  perform lookups of public and private keys. Given an authority name (as found
143
143
  in a session cookie), the Directory can find the corresponding public key.
144
144
 
145
- If the local system is an authority itself, the method #my_authority_name will
146
- return non-nil and #my_private_key will return a private key suitable for
145
+ If the local system is an authority itself, #local_authority_name will
146
+ return non-nil and #private_key will return a private key suitable for
147
147
  signing session attributes.
148
148
 
149
149
  The Directory implementation included with HasGlobalSession uses the filesystem
@@ -171,18 +171,15 @@ To replace or enhance the built-in Directory, simply create a new class that
171
171
  extends Directory and put the class somewhere in your app (the lib directory
172
172
  is a good choice). In the HasGlobalSession configuration file, specify the
173
173
  class name of the directory under the 'common' section, like so:
174
+
174
175
  common:
175
176
  integrated: true
176
177
  directory: MyCoolDirectory
177
178
 
178
179
  = To-Do
179
180
 
180
- * Configurable session expiry
181
-
182
181
  * Option to auto-renew session
183
182
 
184
- * Option for non-sticky global session
185
- (e.g. cookie expires at close of browser session, not at global session
186
- expiration!)
183
+ * Implement single sign-out via redirect
187
184
 
188
185
  Copyright (c) 2010 Tony Spataro <code@tracker.xeger.net>, released under the MIT license
@@ -7,8 +7,8 @@ spec = Gem::Specification.new do |s|
7
7
  s.required_ruby_version = Gem::Requirement.new(">= 1.8.7")
8
8
 
9
9
  s.name = 'has_global_session'
10
- s.version = '0.8.2'
11
- s.date = '2010-06-09'
10
+ s.version = '0.8.3'
11
+ s.date = '2010-06-11'
12
12
 
13
13
  s.authors = ['Tony Spataro']
14
14
  s.email = 'code@tracker.xeger.net'
@@ -1,10 +1,14 @@
1
1
  module HasGlobalSession
2
2
  class MissingConfiguration < Exception; end
3
- class SessionExpired < Exception; end
3
+ class ConfigurationError < Exception; end
4
+ class InvalidSession < Exception; end
5
+ class UnserializableType < Exception; end
6
+ class NoAuthority < Exception; end
4
7
  end
5
8
 
6
9
  basedir = File.dirname(__FILE__)
7
10
  require File.join(basedir, 'has_global_session', 'configuration')
8
11
  require File.join(basedir, 'has_global_session', 'directory')
12
+ require File.join(basedir, 'has_global_session', 'encoding')
9
13
  require File.join(basedir, 'has_global_session', 'global_session')
10
14
  require File.join(basedir, 'has_global_session', 'integrated_session')
@@ -1,36 +1,39 @@
1
1
  module HasGlobalSession
2
2
  class Directory
3
- attr_reader :authorities, :my_private_key, :my_authority_name
3
+ attr_reader :authorities, :private_key, :local_authority_name
4
4
 
5
5
  def initialize(keystore_directory)
6
6
  certs = Dir[File.join(keystore_directory, '*.pub')]
7
7
  keys = Dir[File.join(keystore_directory, '*.key')]
8
+ raise ConfigurationError, "Excepted 0 or 1 key files, found #{keys.size}" unless [0, 1].include?(keys.size)
8
9
 
9
10
  @authorities = {}
10
11
  certs.each do |cert_file|
11
12
  basename = File.basename(cert_file)
12
13
  authority = basename[0...(basename.rindex('.'))] #chop trailing .ext
13
14
  @authorities[authority] = OpenSSL::PKey::RSA.new(File.read(cert_file))
14
- raise TypeError, "Expected #{basename} to contain an RSA public key" unless @authorities[authority].public?
15
+ raise ConfigurationError, "Expected #{basename} to contain an RSA public key" unless @authorities[authority].public?
15
16
  end
16
17
 
17
- raise ArgumentError, "Excepted 0 or 1 key files, found #{keys.size}" if ![0, 1].include?(keys.size)
18
- if (key_file = keys[0])
19
- basename = File.basename(key_file)
20
- @my_private_key = OpenSSL::PKey::RSA.new(File.read(key_file))
21
- raise TypeError, "Expected #{basename} to contain an RSA private key" unless @my_private_key.private?
22
- @my_authority_name = basename[0...(basename.rindex('.'))] #chop trailing .ext
18
+ if (authority_name = Configuration['authority'])
19
+ key_file = keys.detect { |kf| kf =~ /#{authority_name}.key$/ }
20
+ raise ConfigurationError, "Key file #{authority_name}.key not found" unless key_file
21
+ @private_key = OpenSSL::PKey::RSA.new(File.read(key_file))
22
+ raise ConfigurationError, "Expected #{basename} to contain an RSA private key" unless @private_key.private?
23
+ @local_authority_name = authority_name
23
24
  end
24
25
  end
25
26
 
26
27
  def trusted_authority?(authority)
27
- Configuration['trust'].blank? ||
28
- authority == my_authority_name ||
29
- Configuration['trust'].include?(authority)
28
+ Configuration['trust'].include?(authority)
30
29
  end
31
30
 
32
31
  def invalidated_session?(uuid)
33
32
  false
34
33
  end
34
+
35
+ def report_exception(exception, cookie=nil)
36
+ true
37
+ end
35
38
  end
36
39
  end
@@ -0,0 +1,37 @@
1
+ module HasGlobalSession
2
+ module Encoding
3
+ class JSON
4
+ def self.load(json)
5
+ ::JSON.load(json)
6
+ end
7
+
8
+ def self.dump(object)
9
+ return object.to_json
10
+ end
11
+ end
12
+
13
+ # Implements URL encoding, but without newlines, and using '-' and '_' as
14
+ # the 62nd and 63rd symbol instead of '+' and '/'. This makes for encoded
15
+ # values that can be easily stored in a cookie; however, they cannot
16
+ # be used in a URL query string without URL-escaping them since they
17
+ # will contain '=' characters.
18
+ #
19
+ # This scheme is almost identical to the scheme "Base 64 Encoding with URL
20
+ # and Filename Safe Alphabet," described in RFC4648, with the exception that
21
+ # this scheme preserves the '=' padding characters due to limitations of
22
+ # Ruby's built-in base64 encoding routines.
23
+ class Base64
24
+ def self.load(string)
25
+ tr = string.tr('-_', '+/')
26
+ return tr.unpack('m')[0]
27
+ end
28
+
29
+ def self.dump(object)
30
+ raw = [object].pack('m')
31
+ raw.tr!('+/', '-_')
32
+ raw.gsub!("\n", '')
33
+ return raw
34
+ end
35
+ end
36
+ end
37
+ end
@@ -15,142 +15,114 @@ module HasGlobalSession
15
15
  @directory = directory
16
16
 
17
17
  if cookie
18
- #User presented us with a cookie; let's decrypt and verify it
19
- zbin = Base64.decode64(cookie)
20
- json = Zlib::Inflate.inflate(zbin)
21
- hash = JSON.load(json)
22
- @id = hash['id']
23
- @created_at = Time.at(hash['tc'].to_i)
24
- @expires_at = Time.at(hash['te'].to_i)
25
- @signed = hash['ds']
26
- @insecure = hash['dx']
27
- @signature = hash['s']
28
- @authority = hash['a']
29
-
30
- hash.delete('s')
31
- expected = digest(hash)
32
- signer = @directory.authorities[@authority]
33
- raise SecurityError, "Unknown signing authority #{@authority}" unless signer
34
-
35
- got = signer.public_decrypt(Base64.decode64(@signature))
36
- unless (got == expected)
37
- raise SecurityError, "Signature mismatch on global session cookie; tampering suspected"
38
- end
39
-
40
- unless @directory.trusted_authority?(@authority)
41
- raise SecurityError, "Global sessions created by #{@authority} are not trusted"
42
- end
43
-
44
- if expired? || @directory.invalidated_session?(@id)
45
- raise ExpiredSession, "Global session cookie has expired"
46
- end
47
-
18
+ load_from_cookie(cookie)
19
+ elsif @directory.local_authority_name
20
+ create_from_scratch
48
21
  else
49
- @signed = {}
50
- @insecure = {}
51
-
52
- if defined?(::UUIDTools) # UUIDTools v2
53
- @id = ::UUIDTools::UUID.timestamp_create.to_s
54
- elsif defined?(::UUID) # UUIDTools v1
55
- @id = ::UUID.timestamp_create.to_s
56
- else
57
- raise TypeError, "Neither UUIDTools nor UUID defined; unsupported UUIDTools version?"
58
- end
59
-
60
- @created_at = Time.now.utc
61
- @authority = @directory.my_authority_name
62
- renew!
22
+ create_invalid
63
23
  end
64
24
  end
65
25
 
66
- def expired?
67
- (@expires_at <= Time.now)
68
- end
69
-
70
- def expire!
71
- @expires_at = Time.at(0)
72
- @dirty = true
73
- end
74
-
75
- def renew!
76
- @expires_at = Configuration['timeout'].to_i.minutes.from_now.utc || 1.hours.from_now.utc
77
- @dirty = true
26
+ def valid?
27
+ @id && (@expires_at > Time.now) && ! @directory.invalidated_session?(@id)
78
28
  end
79
29
 
80
30
  def to_s
31
+ if @cookie && !@dirty_insecure && !@dirty_secure
32
+ #use cached cookie if nothing has changed
33
+ return @cookie
34
+ end
35
+
81
36
  hash = {'id'=>@id,
82
37
  'tc'=>@created_at.to_i, 'te'=>@expires_at.to_i,
83
- 'ds'=>@signed, 'dx'=>@insecure}
38
+ 'ds'=>@signed}
84
39
 
85
- if @signature && !@dirty
40
+ if @signature && !@dirty_secure
86
41
  #use cached signature unless we've changed secure state
87
42
  authority = @authority
88
43
  signature = @signature
89
44
  else
90
- authority = @directory.my_authority_name
45
+ authority_check
46
+ authority = @directory.local_authority_name
91
47
  hash['a'] = authority
92
48
  digest = digest(hash)
93
- signature = Base64.encode64(@directory.my_private_key.private_encrypt(digest))
49
+ signature = Encoding::Base64.dump(@directory.private_key.private_encrypt(digest))
94
50
  end
95
51
 
96
- hash['s'] = signature
97
- hash['a'] = authority
98
- json = hash.to_json
52
+ hash['dx'] = @insecure
53
+ hash['s'] = signature
54
+ hash['a'] = authority
55
+
56
+ json = Encoding::JSON.dump(hash)
99
57
  zbin = Zlib::Deflate.deflate(json, Zlib::BEST_COMPRESSION)
100
- return Base64.encode64(zbin)
58
+ return Encoding::Base64.dump(zbin)
101
59
  end
102
60
 
103
61
  def supports_key?(key)
104
62
  @schema_signed.include?(key) || @schema_insecure.include?(key)
105
63
  end
106
64
 
65
+ def has_key?(key)
66
+ @signed.has_key(key) || @insecure.has_key?(key)
67
+ end
68
+
69
+ def keys
70
+ @signed.keys + @insecure.keys
71
+ end
72
+
73
+ def values
74
+ @signed.values + @insecure.values
75
+ end
76
+
77
+ def each_pair(&block)
78
+ @signed.each_pair(&block)
79
+ @insecure.each_pair(&block)
80
+ end
81
+
107
82
  def [](key)
108
83
  @signed[key] || @insecure[key]
109
84
  end
110
85
 
111
86
  def []=(key, value)
112
- case value
113
- when String, Numeric, Array
114
- #no-op
115
- else
116
- raise TypeError, "Cannot store values of type #{value.class.name} reliably"
117
- end
87
+ raise InvalidSession unless valid?
118
88
 
119
- if @schema_signed.include?(key)
120
- unless @directory.my_private_key && @directory.my_authority_name
121
- raise StandardError, 'Cannot change secure session attributes; we are not an authority'
122
- end
89
+ #Ensure that the value is serializable (will raise if not)
90
+ canonicalize(value)
123
91
 
92
+ if @schema_signed.include?(key)
93
+ authority_check
124
94
  @signed[key] = value
125
- @dirty = true
95
+ @dirty_secure = true
126
96
  elsif @schema_insecure.include?(key)
127
97
  @insecure[key] = value
98
+ @dirty_insecure = true
128
99
  else
129
100
  raise ArgumentError, "Attribute '#{key}' is not specified in global session configuration"
130
101
  end
131
102
  end
132
103
 
133
- def has_key?(key)
134
- @signed.has_key(key) || @insecure.has_key?(key)
104
+ def expire!
105
+ authority_check
106
+ @expires_at = Time.at(0)
107
+ @dirty_secure = true
135
108
  end
136
109
 
137
- def keys
138
- @signed.keys + @insecure.keys
110
+ def renew!
111
+ authority_check
112
+ @expires_at = Configuration['timeout'].to_i.minutes.from_now.utc || 1.hours.from_now.utc
113
+ @dirty_secure = true
139
114
  end
140
115
 
141
- def values
142
- @signed.values + @insecure.values
143
- end
116
+ private
144
117
 
145
- def each_pair(&block)
146
- @signed.each_pair(&block)
147
- @insecure.each_pair(&block)
118
+ def authority_check
119
+ unless @directory.local_authority_name
120
+ raise NoAuthority, 'Cannot change secure session attributes; we are not an authority'
121
+ end
148
122
  end
149
123
 
150
- private
151
-
152
124
  def digest(input)
153
- canonical = canonicalize(input).to_json
125
+ canonical = Encoding::JSON.dump(canonicalize(input))
154
126
  return Digest::SHA1.new().update(canonical).hexdigest
155
127
  end
156
128
 
@@ -164,13 +136,84 @@ module HasGlobalSession
164
136
  end
165
137
  when Array
166
138
  output = input.collect { |x| canonicalize(x) }
167
- when Numeric, String
139
+ when Numeric, String, NilClass
168
140
  output = input
169
141
  else
170
- raise TypeError, "Objects of type #{input.class.name} cannot be serialized in the global session"
142
+ raise UnserializableType, "Objects of type #{input.class.name} cannot be serialized in the global session"
171
143
  end
172
144
 
173
145
  return output
174
146
  end
147
+
148
+ def load_from_cookie(cookie)
149
+ zbin = Encoding::Base64.load(cookie)
150
+ json = Zlib::Inflate.inflate(zbin)
151
+ hash = Encoding::JSON.load(json)
152
+
153
+ id = hash['id']
154
+ authority = hash['a']
155
+ created_at = Time.at(hash['tc'].to_i)
156
+ expires_at = Time.at(hash['te'].to_i)
157
+ signed = hash['ds']
158
+ insecure = hash.delete('dx')
159
+ signature = hash.delete('s')
160
+
161
+ #Check signature
162
+ expected = digest(hash)
163
+ signer = @directory.authorities[authority]
164
+ raise SecurityError, "Unknown signing authority #{authority}" unless signer
165
+ got = signer.public_decrypt(Encoding::Base64.load(signature))
166
+ unless (got == expected)
167
+ raise SecurityError, "Signature mismatch on global session cookie; tampering suspected"
168
+ end
169
+
170
+ #Check trust in signing authority
171
+ unless @directory.trusted_authority?(authority)
172
+ raise SecurityError, "Global sessions created by #{authority} are not trusted"
173
+ end
174
+
175
+ #Check expiration
176
+ if expires_at <= Time.now || @directory.invalidated_session?(id)
177
+ raise ExpiredSession, "Global session cookie has expired"
178
+ end
179
+
180
+ #If all validation stuff passed, assign our instance variables.
181
+ @id = id
182
+ @authority = authority
183
+ @created_at = created_at
184
+ @expires_at = expires_at
185
+ @signed = signed
186
+ @insecure = insecure
187
+ @signature = signature
188
+ @cookie = cookie
189
+ end
190
+
191
+ def create_from_scratch
192
+ authority_check
193
+
194
+ @signed = {}
195
+ @insecure = {}
196
+ @created_at = Time.now.utc
197
+ @authority = @directory.local_authority_name
198
+
199
+ if defined?(::UUIDTools) # UUIDTools v2
200
+ @id = ::UUIDTools::UUID.timestamp_create.to_s
201
+ elsif defined?(::UUID) # UUIDTools v1
202
+ @id = ::UUID.timestamp_create.to_s
203
+ else
204
+ raise TypeError, "Neither UUIDTools nor UUID defined; unsupported UUIDTools version?"
205
+ end
206
+
207
+ renew!
208
+ end
209
+
210
+ def create_invalid
211
+ @id = nil
212
+ @created_at = Time.now
213
+ @expires_at = created_at
214
+ @signed = {}
215
+ @insecure = {}
216
+ @authority = nil
217
+ end
175
218
  end
176
219
  end
@@ -40,5 +40,15 @@ module HasGlobalSession
40
40
  @global_session.each_pair(&block)
41
41
  @local_session.each_pair(&block)
42
42
  end
43
+
44
+ def method_missing(meth, *args)
45
+ if @global_session.respond_to?(meth)
46
+ return @global_session.send(meth, *args)
47
+ elsif @local_session.respond_to?(meth)
48
+ return @local_session.send(meth, *args)
49
+ else
50
+ super
51
+ end
52
+ end
43
53
  end
44
54
  end
@@ -20,7 +20,8 @@ module HasGlobalSession
20
20
  rescue Exception => e
21
21
  #silently recover from any error by initializing a new global session;
22
22
  #the new session will be unauthenticated.
23
- #TODO log the error
23
+ directory.report_exception(e, cookie)
24
+ logger.error "#{e.class.name}: #{e.message} (at #{e.backtrace[0]})" if logger
24
25
  @global_session = GlobalSession.new(directory)
25
26
  end
26
27
  end
@@ -34,16 +35,25 @@ module HasGlobalSession
34
35
 
35
36
  def global_session_update_cookie
36
37
  return unless @global_session
38
+ name = Configuration['cookie']['name']
39
+ domain = Configuration['cookie']['domain']
37
40
 
38
- cookie_name = Configuration['cookie']['name']
39
- if @global_session.expired?
40
- cookies.delete cookie_name
41
- else
42
- options = {:value => @global_session.to_s,
43
- :domain => Configuration['cookie']['domain'],
44
- :expires => @global_session.expires_at}
45
- cookies[cookie_name] = options
41
+ #Default options for invalid session
42
+ options = {:value => nil,
43
+ :domain => domain,
44
+ :expires => Time.at(0)}
45
+
46
+ if @global_session.valid?
47
+ begin
48
+ value = @global_session.to_s
49
+ expires = Configuration['ephemeral'] ? nil : @global_session.expires_at
50
+ options.merge!(:value => value, :expires => expires)
51
+ rescue Exception => e
52
+ logger.error "#{e.class.name}: #{e.message} (at #{e.backtrace[0]})" if logger
53
+ end
46
54
  end
55
+
56
+ cookies[name] = options unless (cookies[name] == options[:value])
47
57
  end
48
58
 
49
59
  def log_processing
data/rails/init.rb CHANGED
@@ -8,7 +8,7 @@ if File.exist?(config_file)
8
8
  # and operating environment.
9
9
  HasGlobalSession::Configuration.config_file = config_file
10
10
  HasGlobalSession::Configuration.environment = RAILS_ENV
11
-
11
+
12
12
  require File.join(basedir, 'rails', 'action_controller_instance_methods')
13
13
 
14
14
  # Enable ActionController integration.
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: has_global_session
3
3
  version: !ruby/object:Gem::Version
4
- hash: 59
4
+ hash: 57
5
5
  prerelease: false
6
6
  segments:
7
7
  - 0
8
8
  - 8
9
- - 2
10
- version: 0.8.2
9
+ - 3
10
+ version: 0.8.3
11
11
  platform: ruby
12
12
  authors:
13
13
  - Tony Spataro
@@ -15,7 +15,7 @@ autorequire:
15
15
  bindir: bin
16
16
  cert_chain: []
17
17
 
18
- date: 2010-06-09 00:00:00 -07:00
18
+ date: 2010-06-11 00:00:00 -07:00
19
19
  default_executable:
20
20
  dependencies:
21
21
  - !ruby/object:Gem::Dependency
@@ -66,6 +66,7 @@ files:
66
66
  - lib/has_global_session.rb
67
67
  - lib/has_global_session/configuration.rb
68
68
  - lib/has_global_session/directory.rb
69
+ - lib/has_global_session/encoding.rb
69
70
  - lib/has_global_session/global_session.rb
70
71
  - lib/has_global_session/integrated_session.rb
71
72
  - rails/action_controller_instance_methods.rb