session.rb   [plain text]


#
# cgi/session.rb - session support for cgi scripts
#
# Copyright (C) 2001  Yukihiro "Matz" Matsumoto
# Copyright (C) 2000  Network Applied Communication Laboratory, Inc.
# Copyright (C) 2000  Information-technology Promotion Agency, Japan
#
# Author: Yukihiro "Matz" Matsumoto
#
# Documentation: William Webber (william@williamwebber.com)
#
# == Overview
#
# This file provides the +CGI::Session+ class, which provides session
# support for CGI scripts.  A session is a sequence of HTTP requests
# and responses linked together and associated with a single client.  
# Information associated with the session is stored
# on the server between requests.  A session id is passed between client
# and server with every request and response, transparently
# to the user.  This adds state information to the otherwise stateless
# HTTP request/response protocol.
#
# See the documentation to the +CGI::Session+ class for more details
# and examples of usage.  See cgi.rb for the +CGI+ class itself.

require 'cgi'
require 'tmpdir'

class CGI

  # Class representing an HTTP session.  See documentation for the file 
  # cgi/session.rb for an introduction to HTTP sessions.
  #
  # == Lifecycle
  #
  # A CGI::Session instance is created from a CGI object.  By default,
  # this CGI::Session instance will start a new session if none currently
  # exists, or continue the current session for this client if one does
  # exist.  The +new_session+ option can be used to either always or
  # never create a new session.  See #new() for more details.
  #
  # #delete() deletes a session from session storage.  It
  # does not however remove the session id from the client.  If the client
  # makes another request with the same id, the effect will be to start
  # a new session with the old session's id.
  #
  # == Setting and retrieving session data.
  #
  # The Session class associates data with a session as key-value pairs.
  # This data can be set and retrieved by indexing the Session instance 
  # using '[]', much the same as hashes (although other hash methods
  # are not supported).
  #
  # When session processing has been completed for a request, the
  # session should be closed using the close() method.  This will
  # store the session's state to persistent storage.  If you want
  # to store the session's state to persistent storage without
  # finishing session processing for this request, call the update()
  # method.
  #
  # == Storing session state
  #
  # The caller can specify what form of storage to use for the session's 
  # data with the +database_manager+ option to CGI::Session::new.  The
  # following storage classes are provided as part of the standard library:
  #
  # CGI::Session::FileStore:: stores data as plain text in a flat file.  Only 
  #                           works with String data.  This is the default 
  #                           storage type.
  # CGI::Session::MemoryStore:: stores data in an in-memory hash.  The data 
  #                             only persists for as long as the current ruby 
  #                             interpreter instance does.
  # CGI::Session::PStore:: stores data in Marshalled format.  Provided by
  #                        cgi/session/pstore.rb.  Supports data of any type, 
  #                        and provides file-locking and transaction support.
  #
  # Custom storage types can also be created by defining a class with 
  # the following methods:
  #
  #    new(session, options)
  #    restore  # returns hash of session data.
  #    update
  #    close
  #    delete
  #
  # Changing storage type mid-session does not work.  Note in particular
  # that by default the FileStore and PStore session data files have the
  # same name.  If your application switches from one to the other without
  # making sure that filenames will be different
  # and clients still have old sessions lying around in cookies, then
  # things will break nastily!
  #
  # == Maintaining the session id.
  #
  # Most session state is maintained on the server.  However, a session
  # id must be passed backwards and forwards between client and server
  # to maintain a reference to this session state.
  #
  # The simplest way to do this is via cookies.  The CGI::Session class
  # provides transparent support for session id communication via cookies
  # if the client has cookies enabled.
  # 
  # If the client has cookies disabled, the session id must be included
  # as a parameter of all requests sent by the client to the server.  The
  # CGI::Session class in conjunction with the CGI class will transparently
  # add the session id as a hidden input field to all forms generated
  # using the CGI#form() HTML generation method.  No built-in support is
  # provided for other mechanisms, such as URL re-writing.  The caller is
  # responsible for extracting the session id from the session_id 
  # attribute and manually encoding it in URLs and adding it as a hidden
  # input to HTML forms created by other mechanisms.  Also, session expiry
  # is not automatically handled.
  #
  # == Examples of use
  #
  # === Setting the user's name
  #
  #   require 'cgi'
  #   require 'cgi/session'
  #   require 'cgi/session/pstore'     # provides CGI::Session::PStore
  #
  #   cgi = CGI.new("html4")
  #
  #   session = CGI::Session.new(cgi,
  #       'database_manager' => CGI::Session::PStore,  # use PStore
  #       'session_key' => '_rb_sess_id',              # custom session key
  #       'session_expires' => Time.now + 30 * 60,     # 30 minute timeout 
  #       'prefix' => 'pstore_sid_')                   # PStore option
  #   if cgi.has_key?('user_name') and cgi['user_name'] != ''
  #       # coerce to String: cgi[] returns the 
  #       # string-like CGI::QueryExtension::Value
  #       session['user_name'] = cgi['user_name'].to_s
  #   elsif !session['user_name']
  #       session['user_name'] = "guest"
  #   end
  #   session.close
  #
  # === Creating a new session safely
  #
  #   require 'cgi'
  #   require 'cgi/session'
  #
  #   cgi = CGI.new("html4")
  #
  #   # We make sure to delete an old session if one exists,
  #   # not just to free resources, but to prevent the session 
  #   # from being maliciously hijacked later on.
  #   begin
  #       session = CGI::Session.new(cgi, 'new_session' => false)      
  #       session.delete                 
  #   rescue ArgumentError  # if no old session
  #   end
  #   session = CGI::Session.new(cgi, 'new_session' => true)
  #   session.close
  #
  class Session

    #:nodoc:
    class NoSession < RuntimeError; end

    # The id of this session.
    attr_reader :session_id, :new_session

    def Session::callback(dbman)  #:nodoc:
      Proc.new{
	dbman[0].close unless dbman.empty?
      }
    end

    # Create a new session id.
    #
    # The session id is an MD5 hash based upon the time,
    # a random number, and a constant string.  This routine
    # is used internally for automatically generated
    # session ids. 
    def create_new_id
      require 'digest/md5'
      md5 = Digest::MD5::new
      now = Time::now
      md5.update(now.to_s)
      md5.update(String(now.usec))
      md5.update(String(rand(0)))
      md5.update(String($$))
      md5.update('foobar')
      @new_session = true
      md5.hexdigest
    end
    private :create_new_id

    # Create a new CGI::Session object for +request+.
    #
    # +request+ is an instance of the +CGI+ class (see cgi.rb).
    # +option+ is a hash of options for initialising this
    # CGI::Session instance.  The following options are
    # recognised:
    #
    # session_key:: the parameter name used for the session id.
    #               Defaults to '_session_id'.
    # session_id:: the session id to use.  If not provided, then
    #              it is retrieved from the +session_key+ parameter
    #              of the request, or automatically generated for
    #              a new session.
    # new_session:: if true, force creation of a new session.  If not set, 
    #               a new session is only created if none currently
    #               exists.  If false, a new session is never created,
    #               and if none currently exists and the +session_id+
    #               option is not set, an ArgumentError is raised.
    # database_manager:: the name of the class providing storage facilities
    #                    for session state persistence.  Built-in support
    #                    is provided for +FileStore+ (the default),
    #                    +MemoryStore+, and +PStore+ (from
    #                    cgi/session/pstore.rb).  See the documentation for
    #                    these classes for more details.
    #
    # The following options are also recognised, but only apply if the
    # session id is stored in a cookie.
    #
    # session_expires:: the time the current session expires, as a 
    #                   +Time+ object.  If not set, the session will terminate
    #                   when the user's browser is closed.
    # session_domain:: the hostname domain for which this session is valid.
    #                  If not set, defaults to the hostname of the server.
    # session_secure:: if +true+, this session will only work over HTTPS.
    # session_path:: the path for which this session applies.  Defaults
    #                to the directory of the CGI script.
    #
    # +option+ is also passed on to the session storage class initialiser; see
    # the documentation for each session storage class for the options
    # they support.
    #                  
    # The retrieved or created session is automatically added to +request+
    # as a cookie, and also to its +output_hidden+ table, which is used
    # to add hidden input elements to forms.  
    #
    # *WARNING* the +output_hidden+
    # fields are surrounded by a <fieldset> tag in HTML 4 generation, which
    # is _not_ invisible on many browsers; you may wish to disable the
    # use of fieldsets with code similar to the following
    # (see http://blade.nagaokaut.ac.jp/cgi-bin/scat.rb/ruby/ruby-list/37805)
    #
    #   cgi = CGI.new("html4")
    #   class << cgi
    #       undef_method :fieldset
    #   end
    #
    def initialize(request, option={})
      @new_session = false
      session_key = option['session_key'] || '_session_id'
      session_id = option['session_id']
      unless session_id
	if option['new_session']
	  session_id = create_new_id
	end
      end
      unless session_id
	if request.key?(session_key)
	  session_id = request[session_key]
	  session_id = session_id.read if session_id.respond_to?(:read)
	end
	unless session_id
	  session_id, = request.cookies[session_key]
	end
	unless session_id
	  unless option.fetch('new_session', true)
	    raise ArgumentError, "session_key `%s' should be supplied"%session_key
	  end
	  session_id = create_new_id
	end
      end
      @session_id = session_id
      dbman = option['database_manager'] || FileStore
      begin
        @dbman = dbman::new(self, option)
      rescue NoSession
        unless option.fetch('new_session', true)
          raise ArgumentError, "invalid session_id `%s'"%session_id
        end
        session_id = @session_id = create_new_id
        retry
      end
      request.instance_eval do
	@output_hidden = {session_key => session_id} unless option['no_hidden']
	@output_cookies =  [
          Cookie::new("name" => session_key,
		      "value" => session_id,
		      "expires" => option['session_expires'],
		      "domain" => option['session_domain'],
		      "secure" => option['session_secure'],
		      "path" => if option['session_path'] then
				  option['session_path']
		                elsif ENV["SCRIPT_NAME"] then
				  File::dirname(ENV["SCRIPT_NAME"])
				else
				  ""
				end)
        ] unless option['no_cookies']
      end
      @dbprot = [@dbman]
      ObjectSpace::define_finalizer(self, Session::callback(@dbprot))
    end

    # Retrieve the session data for key +key+.
    def [](key)
      unless @data
	@data = @dbman.restore
      end
      @data[key]
    end

    # Set the session date for key +key+.
    def []=(key, val)
      unless @write_lock
	@write_lock = true
      end
      unless @data
	@data = @dbman.restore
      end
      @data[key] = val
    end

    # Store session data on the server.  For some session storage types,
    # this is a no-op.
    def update  
      @dbman.update
    end

    # Store session data on the server and close the session storage.  
    # For some session storage types, this is a no-op.
    def close
      @dbman.close
      @dbprot.clear
    end

    # Delete the session from storage.  Also closes the storage.
    #
    # Note that the session's data is _not_ automatically deleted
    # upon the session expiring.
    def delete
      @dbman.delete
      @dbprot.clear
    end

    # File-based session storage class.
    #
    # Implements session storage as a flat file of 'key=value' values.
    # This storage type only works directly with String values; the
    # user is responsible for converting other types to Strings when
    # storing and from Strings when retrieving.
    class FileStore
      # Create a new FileStore instance.
      #
      # This constructor is used internally by CGI::Session.  The
      # user does not generally need to call it directly.
      #
      # +session+ is the session for which this instance is being
      # created.  The session id must only contain alphanumeric
      # characters; automatically generated session ids observe
      # this requirement.
      # 
      # +option+ is a hash of options for the initialiser.  The
      # following options are recognised:
      #
      # tmpdir:: the directory to use for storing the FileStore
      #          file.  Defaults to Dir::tmpdir (generally "/tmp"
      #          on Unix systems).
      # prefix:: the prefix to add to the session id when generating
      #          the filename for this session's FileStore file.
      #          Defaults to the empty string.
      # suffix:: the prefix to add to the session id when generating
      #          the filename for this session's FileStore file.
      #          Defaults to the empty string.
      #
      # This session's FileStore file will be created if it does
      # not exist, or opened if it does.
      def initialize(session, option={})
	dir = option['tmpdir'] || Dir::tmpdir
       prefix = option['prefix'] || ''
	suffix = option['suffix'] || ''
	id = session.session_id
        require 'digest/md5'
        md5 = Digest::MD5.hexdigest(id)[0,16]
	@path = dir+"/"+prefix+md5+suffix
	unless File::exist? @path
          unless session.new_session
            raise CGI::Session::NoSession, "uninitialized session"
          end
	  @hash = {}
	end
      end

      # Restore session state from the session's FileStore file.
      #
      # Returns the session state as a hash.
      def restore
	unless @hash
	  @hash = {}
          begin
	    f = File.open(@path, 'r')
	    f.flock File::LOCK_SH
	    for line in f
	      line.chomp!
	      k, v = line.split('=',2)
	      @hash[CGI::unescape(k)] = CGI::unescape(v)
	    end
          ensure
	    f.close unless f.nil?
          end
	end
	@hash
      end

      # Save session state to the session's FileStore file.
      def update
	return unless @hash
        begin
	  f = File.open(@path, File::CREAT|File::TRUNC|File::RDWR, 0600)
	  f.flock File::LOCK_EX
   	  for k,v in @hash
	    f.printf "%s=%s\n", CGI::escape(k), CGI::escape(String(v))
	  end
        ensure
          f.close unless f.nil?
        end
      end

      # Update and close the session's FileStore file.
      def close
	update
      end

      # Close and delete the session's FileStore file.
      def delete
        File::unlink @path
      rescue Errno::ENOENT
      end
    end

    # In-memory session storage class.
    #
    # Implements session storage as a global in-memory hash.  Session
    # data will only persist for as long as the ruby interpreter 
    # instance does.
    class MemoryStore
      GLOBAL_HASH_TABLE = {} #:nodoc:

      # Create a new MemoryStore instance.
      #
      # +session+ is the session this instance is associated with.
      # +option+ is a list of initialisation options.  None are
      # currently recognised.
      def initialize(session, option=nil)
	@session_id = session.session_id
        unless GLOBAL_HASH_TABLE.key?(@session_id)
          unless session.new_session
            raise CGI::Session::NoSession, "uninitialized session"
          end
          GLOBAL_HASH_TABLE[@session_id] = {}
        end
      end

      # Restore session state.
      #
      # Returns session data as a hash.
      def restore
	GLOBAL_HASH_TABLE[@session_id]
      end

      # Update session state.
      #
      # A no-op.
      def update
	# don't need to update; hash is shared
      end

      # Close session storage.
      #
      # A no-op.
      def close
	# don't need to close
      end

      # Delete the session state.
      def delete
	GLOBAL_HASH_TABLE.delete(@session_id)
      end
    end
  end
end