lib/global_session/session.rb in global_session-1.1.0 vs lib/global_session/session.rb in global_session-2.0.0
- old
+ new
@@ -1,402 +1,38 @@
-# Copyright (c) 2012 RightScale Inc
+module GlobalSession
+ module Session
+ end
+end
+
+require 'global_session/session/abstract'
+require 'global_session/session/v1'
+require 'global_session/session/v2'
+
+# Ladies and gentlemen: the one and only, star of the show, GLOBAL SESSION!
#
-# Permission is hereby granted, free of charge, to any person obtaining
-# a copy of this software and associated documentation files (the
-# "Software"), to deal in the Software without restriction, including
-# without limitation the rights to use, copy, modify, merge, publish,
-# distribute, sublicense, and/or sell copies of the Software, and to
-# permit persons to whom the Software is furnished to do so, subject to
-# the following conditions:
+# Session is designed to act as much like a Hash as possible. You can use
+# most of the methods you would use with Hash: [], has_key?, each, etc. It has a
+# few additional methods that are specific to itself, mostly involving whether
+# it's expired, valid, supports a certain key, etc.
#
-# The above copyright notice and this permission notice shall be
-# included in all copies or substantial portions of the Software.
+# Global sessions are versioned, and each version may have its own encoding
+# strategy. This module acts as a namespace for the different versions, each
+# of which is represented by a class in the module. They all inherit
+# from the abstract base class in order to ensure that they are internally
+# compatible with other components of this gem.
#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
-# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
-# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
-# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+# This module also acts as a façade for reading global session cookies generated
+# by the different versions; it is responsible for detecting the version of
+# a given cookie, then instantiating a suitable session object.
+module GlobalSession::Session
+ def self.new(*args)
+ V2.new(*args)
+ rescue GlobalSession::MalformedCookie => e
+ V1.new(*args)
+ end
-# Standard library dependencies
-require 'set'
-require 'zlib'
-
-module GlobalSession
- # Ladies and gentlemen: the one and only, star of the show, GLOBAL SESSION!
- #
- # Session is designed to act as much like a Hash as possible. You can use
- # most of the methods you would use with Hash: [], has_key?, each, etc. It has a
- # few additional methods that are specific to itself, mostly involving whether
- # it's expired, valid, supports a certain key, etc.
- #
- class Session
- attr_reader :id, :authority, :created_at, :expired_at, :directory
-
- # Utility method to decode a cookie; good for console debugging. This performs no
- # validation or security check of any sort.
- #
- # === Parameters
- # cookie(String):: well-formed global session cookie
- def self.decode_cookie(cookie)
- zbin = Encoding::Base64Cookie.load(cookie)
- json = Zlib::Inflate.inflate(zbin)
- return Encoding::JSON.load(json)
- end
-
- # @return a representation of the object suitable for printing to the console
- def inspect
- "<#{self.class.name} @id=#{@id.inspect}>"
- end
-
- # Create a new global session object.
- #
- # === Parameters
- # directory(Directory):: directory implementation that the session should use for various operations
- # cookie(String):: Optional, serialized global session cookie. If none is supplied, a new session is created.
- # 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.
- #
- # ===Raise
- # InvalidSession:: if the session contained in the cookie has been invalidated
- # ExpiredSession:: if the session contained in the cookie has expired
- # MalformedCookie:: if the cookie was corrupt or malformed
- # SecurityError:: if signature is invalid or cookie is not signed by a trusted authority
- def initialize(directory, cookie=nil, valid_signature_digest=nil)
- @configuration = directory.configuration
- @schema_signed = Set.new((@configuration['attributes']['signed']))
- @schema_insecure = Set.new((@configuration['attributes']['insecure']))
- @directory = directory
-
- if cookie && !cookie.empty?
- load_from_cookie(cookie, valid_signature_digest)
- elsif @directory.local_authority_name
- create_from_scratch
- else
- create_invalid
- end
- end
-
- # @return [true,false] true if this session was created in-process, false if it was initialized from a cookie
- def new_record?
- @cookie.nil?
- end
-
- # @return a Hash representation of the session with three subkeys: :metadata, :signed and :insecure
- # @raise nothing -- does not raise; returns empty hash if there is a failure
- def to_hash
- hash = {}
-
- md = {}
- signed = {}
- insecure = {}
-
- hash[:metadata] = md
- hash[:signed] = signed
- hash[:insecure] = insecure
-
- md[:id] = @id
- md[:authority] = @authority
- md[:created_at] = @created_at
- md[:expired_at] = @expired_at
- @signed.each_pair { |k, v| signed[k] = v }
- @insecure.each_pair { |k, v| insecure[k] = v }
-
- hash
- rescue Exception => e
- {}
- end
-
- # Determine whether the session is valid. This method simply delegates to the
- # directory associated with this session.
- #
- # === Return
- # valid(true|false):: True if the session is valid, false otherwise
- def valid?
- @directory.valid_session?(@id, @expired_at)
- end
-
- # Serialize the session to a form suitable for use with HTTP cookies. If any
- # secure attributes have changed since the session was instantiated, compute
- # a fresh RSA signature.
- #
- # === Return
- # cookie(String):: The B64cookie-encoded Zlib-compressed JSON-serialized global session hash
- def to_s
- if @cookie && !@dirty_insecure && !@dirty_secure
- #use cached cookie if nothing has changed
- return @cookie
- end
-
- hash = {'id'=>@id,
- 'tc'=>@created_at.to_i, 'te'=>@expired_at.to_i,
- 'ds'=>@signed}
-
- if @signature && !@dirty_secure
- #use cached signature unless we've changed secure state
- authority = @authority
- else
- authority_check
- authority = @directory.local_authority_name
- hash['a'] = authority
- digest = canonical_digest(hash)
- @signature = Encoding::Base64Cookie.dump(@directory.private_key.private_encrypt(digest))
- end
-
- hash['dx'] = @insecure
- hash['s'] = @signature
- hash['a'] = authority
-
- json = Encoding::JSON.dump(hash)
- zbin = Zlib::Deflate.deflate(json, Zlib::BEST_COMPRESSION)
- return Encoding::Base64Cookie.dump(zbin)
- end
-
- # Determine whether the global session schema allows a given key to be placed
- # in the global session.
- #
- # === Parameters
- # key(String):: The name of the key
- #
- # === Return
- # supported(true|false):: Whether the specified key is supported
- def supports_key?(key)
- @schema_signed.include?(key) || @schema_insecure.include?(key)
- end
-
- # Determine whether this session contains a value with the specified key.
- #
- # === Parameters
- # key(String):: The name of the key
- #
- # === Return
- # contained(true|false):: Whether the session currently has a value for the specified key.
- def has_key?(key)
- @signed.has_key?(key) || @insecure.has_key?(key)
- end
-
- alias :key? :has_key?
-
- # Return the keys that are currently present in the global session.
- #
- # === Return
- # keys(Array):: List of keys contained in the global session
- def keys
- @signed.keys + @insecure.keys
- end
-
- # Return the values that are currently present in the global session.
- #
- # === Return
- # values(Array):: List of values contained in the global session
- def values
- @signed.values + @insecure.values
- end
-
- # Iterate over each key/value pair
- #
- # === Block
- # An iterator which will be called with each key/value pair
- #
- # === Return
- # Returns the value of the last expression evaluated by the block
- def each_pair(&block) # :yields: |key, value|
- @signed.each_pair(&block)
- @insecure.each_pair(&block)
- end
-
- # Lookup a value by its key.
- #
- # === Parameters
- # key(String):: the key
- #
- # === Return
- # value(Object):: The value associated with +key+, or nil if +key+ is not present
- def [](key)
- key = key.to_s #take care of symbol-style keys
- @signed[key] || @insecure[key]
- end
-
- # Set a value in the global session hash. If the supplied key is denoted as
- # secure by the global session schema, causes a new signature to be computed
- # when the session is next serialized.
- #
- # === Parameters
- # key(String):: The key to set
- # value(Object):: The value to set
- #
- # === Return
- # value(Object):: Always returns the value that was set
- #
- # ===Raise
- # InvalidSession:: if the session has been invalidated (and therefore can't be written to)
- # ArgumentError:: if the configuration doesn't define the specified key as part of the global session
- # NoAuthority:: if the specified key is secure and the local node is not an authority
- # UnserializableType:: if the specified value can't be serialized as JSON
- def []=(key, value)
- key = key.to_s #take care of symbol-style keys
- raise InvalidSession unless valid?
-
- #Ensure that the value is serializable (will raise if not)
- canonicalize(value)
-
- if @schema_signed.include?(key)
- authority_check
- @signed[key] = value
- @dirty_secure = true
- elsif @schema_insecure.include?(key)
- @insecure[key] = value
- @dirty_insecure = true
- else
- raise ArgumentError, "Attribute '#{key}' is not specified in global session configuration"
- end
-
- return value
- end
-
- # Invalidate this session by reporting its UUID to the Directory.
- #
- # === Return
- # unknown(Object):: Returns whatever the Directory returns
- def invalidate!
- @directory.report_invalid_session(@id, @expired_at)
- end
-
- # Renews this global session, changing its expiry timestamp into the future.
- # Causes a new signature will be computed when the session is next serialized.
- #
- # === Return
- # true:: Always returns true
- def renew!(expired_at=nil)
- authority_check
- minutes = Integer(@configuration['timeout'])
- expired_at ||= Time.at(Time.now.utc + 60 * minutes)
- @expired_at = expired_at
- @created_at = Time.now.utc
- @dirty_secure = true
- end
-
- # Return the SHA1 hash of the most recently-computed RSA signature of this session.
- # This isn't really intended for the end user; it exists so the Web framework integration
- # code can optimize request speed by caching the most recently verified signature in the
- # local session and avoid re-verifying it on every request.
- #
- # === Return
- # digest(String):: SHA1 hex-digest of most-recently-computed signature
- def signature_digest
- @signature ? digest(@signature) : nil
- end
-
- private
-
- def authority_check # :nodoc:
- unless @directory.local_authority_name
- raise NoAuthority, 'Cannot change secure session attributes; we are not an authority'
- end
- end
-
- def canonical_digest(input) # :nodoc:
- canonical = Encoding::JSON.dump(canonicalize(input))
- return digest(canonical)
- end
-
- def digest(input) # :nodoc:
- return Digest::SHA1.new().update(input).hexdigest
- end
-
- def canonicalize(input) # :nodoc:
- case input
- when Hash
- output = Array.new
- ordered_keys = input.keys.sort
- ordered_keys.each do |key|
- output << [ canonicalize(key), canonicalize(input[key]) ]
- end
- when Array
- output = input.collect { |x| canonicalize(x) }
- when Numeric, String, NilClass
- output = input
- else
- raise UnserializableType, "Objects of type #{input.class.name} cannot be serialized in the global session"
- end
-
- return output
- end
-
- def load_from_cookie(cookie, valid_signature_digest) # :nodoc:
- begin
- zbin = Encoding::Base64Cookie.load(cookie)
- json = Zlib::Inflate.inflate(zbin)
- hash = Encoding::JSON.load(json)
- rescue Exception => e
- mc = MalformedCookie.new("Caused by #{e.class.name}: #{e.message}")
- mc.set_backtrace(e.backtrace)
- raise mc
- end
-
- id = hash['id']
- authority = hash['a']
- created_at = Time.at(hash['tc'].to_i).utc
- expired_at = Time.at(hash['te'].to_i).utc
- signed = hash['ds']
- insecure = hash.delete('dx')
- signature = hash.delete('s')
-
- unless valid_signature_digest == digest(signature)
- #Check signature
- expected = canonical_digest(hash)
- signer = @directory.authorities[authority]
- raise SecurityError, "Unknown signing authority #{authority}" unless signer
- got = signer.public_decrypt(Encoding::Base64Cookie.load(signature))
- unless (got == expected)
- raise SecurityError, "Signature mismatch on global session cookie; tampering suspected"
- end
- end
-
- #Check trust in signing authority
- unless @directory.trusted_authority?(authority)
- raise SecurityError, "Global sessions signed by #{authority} are not trusted"
- end
-
- #Check expiration
- unless expired_at > Time.now.utc
- raise ExpiredSession, "Session expired at #{expired_at}"
- end
-
- #Check other validity (delegate to directory)
- unless @directory.valid_session?(id, expired_at)
- raise InvalidSession, "Global session has been invalidated"
- end
-
- #If all validation stuff passed, assign our instance variables.
- @id = id
- @authority = authority
- @created_at = created_at
- @expired_at = expired_at
- @signed = signed
- @insecure = insecure
- @signature = signature
- @cookie = cookie
- end
-
- def create_from_scratch # :nodoc:
- authority_check
-
- @signed = {}
- @insecure = {}
- @created_at = Time.now.utc
- @authority = @directory.local_authority_name
- @id = RightSupport::Data::UUID.generate
- renew!
- end
-
- def create_invalid # :nodoc:
- @id = nil
- @created_at = Time.now.utc
- @expired_at = created_at
- @signed = {}
- @insecure = {}
- @authority = nil
- end
- end
+ def self.decode_cookie(*args)
+ V2.decode_cookie(*args)
+ rescue GlobalSession::MalformedCookie => e
+ V1.decode_cookie(*args)
+ end
end