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 +5 -8
- data/has_global_session.gemspec +2 -2
- data/lib/has_global_session.rb +5 -1
- data/lib/has_global_session/directory.rb +14 -11
- data/lib/has_global_session/encoding.rb +37 -0
- data/lib/has_global_session/global_session.rb +133 -90
- data/lib/has_global_session/integrated_session.rb +10 -0
- data/rails/action_controller_instance_methods.rb +19 -9
- data/rails/init.rb +1 -1
- metadata +5 -4
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,
|
146
|
-
return non-nil and #
|
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
|
-
*
|
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
|
data/has_global_session.gemspec
CHANGED
@@ -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.
|
11
|
-
s.date = '2010-06-
|
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'
|
data/lib/has_global_session.rb
CHANGED
@@ -1,10 +1,14 @@
|
|
1
1
|
module HasGlobalSession
|
2
2
|
class MissingConfiguration < Exception; end
|
3
|
-
class
|
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, :
|
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
|
15
|
+
raise ConfigurationError, "Expected #{basename} to contain an RSA public key" unless @authorities[authority].public?
|
15
16
|
end
|
16
17
|
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
@
|
21
|
-
raise
|
22
|
-
@
|
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'].
|
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
|
-
|
19
|
-
|
20
|
-
|
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
|
-
|
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
|
67
|
-
(@expires_at
|
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
|
38
|
+
'ds'=>@signed}
|
84
39
|
|
85
|
-
if @signature && !@
|
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
|
-
|
45
|
+
authority_check
|
46
|
+
authority = @directory.local_authority_name
|
91
47
|
hash['a'] = authority
|
92
48
|
digest = digest(hash)
|
93
|
-
signature = Base64.
|
49
|
+
signature = Encoding::Base64.dump(@directory.private_key.private_encrypt(digest))
|
94
50
|
end
|
95
51
|
|
96
|
-
hash['
|
97
|
-
hash['
|
98
|
-
|
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.
|
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
|
-
|
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
|
120
|
-
|
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
|
-
@
|
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
|
134
|
-
|
104
|
+
def expire!
|
105
|
+
authority_check
|
106
|
+
@expires_at = Time.at(0)
|
107
|
+
@dirty_secure = true
|
135
108
|
end
|
136
109
|
|
137
|
-
def
|
138
|
-
|
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
|
-
|
142
|
-
@signed.values + @insecure.values
|
143
|
-
end
|
116
|
+
private
|
144
117
|
|
145
|
-
def
|
146
|
-
@
|
147
|
-
|
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)
|
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
|
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
|
-
|
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
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
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:
|
4
|
+
hash: 57
|
5
5
|
prerelease: false
|
6
6
|
segments:
|
7
7
|
- 0
|
8
8
|
- 8
|
9
|
-
-
|
10
|
-
version: 0.8.
|
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-
|
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
|