has_global_session 0.8.2 → 0.8.3

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