1#
2# cgi/session.rb - session support for cgi scripts
3#
4# Copyright (C) 2001  Yukihiro "Matz" Matsumoto
5# Copyright (C) 2000  Network Applied Communication Laboratory, Inc.
6# Copyright (C) 2000  Information-technology Promotion Agency, Japan
7#
8# Author: Yukihiro "Matz" Matsumoto
9#
10# Documentation: William Webber (william@williamwebber.com)
11
12require 'cgi'
13require 'tmpdir'
14
15class CGI
16
17  # == Overview
18  #
19  # This file provides the CGI::Session class, which provides session
20  # support for CGI scripts.  A session is a sequence of HTTP requests
21  # and responses linked together and associated with a single client.
22  # Information associated with the session is stored
23  # on the server between requests.  A session id is passed between client
24  # and server with every request and response, transparently
25  # to the user.  This adds state information to the otherwise stateless
26  # HTTP request/response protocol.
27  #
28  # == Lifecycle
29  #
30  # A CGI::Session instance is created from a CGI object.  By default,
31  # this CGI::Session instance will start a new session if none currently
32  # exists, or continue the current session for this client if one does
33  # exist.  The +new_session+ option can be used to either always or
34  # never create a new session.  See #new() for more details.
35  #
36  # #delete() deletes a session from session storage.  It
37  # does not however remove the session id from the client.  If the client
38  # makes another request with the same id, the effect will be to start
39  # a new session with the old session's id.
40  #
41  # == Setting and retrieving session data.
42  #
43  # The Session class associates data with a session as key-value pairs.
44  # This data can be set and retrieved by indexing the Session instance
45  # using '[]', much the same as hashes (although other hash methods
46  # are not supported).
47  #
48  # When session processing has been completed for a request, the
49  # session should be closed using the close() method.  This will
50  # store the session's state to persistent storage.  If you want
51  # to store the session's state to persistent storage without
52  # finishing session processing for this request, call the update()
53  # method.
54  #
55  # == Storing session state
56  #
57  # The caller can specify what form of storage to use for the session's
58  # data with the +database_manager+ option to CGI::Session::new.  The
59  # following storage classes are provided as part of the standard library:
60  #
61  # CGI::Session::FileStore:: stores data as plain text in a flat file.  Only
62  #                           works with String data.  This is the default
63  #                           storage type.
64  # CGI::Session::MemoryStore:: stores data in an in-memory hash.  The data
65  #                             only persists for as long as the current ruby
66  #                             interpreter instance does.
67  # CGI::Session::PStore:: stores data in Marshalled format.  Provided by
68  #                        cgi/session/pstore.rb.  Supports data of any type,
69  #                        and provides file-locking and transaction support.
70  #
71  # Custom storage types can also be created by defining a class with
72  # the following methods:
73  #
74  #    new(session, options)
75  #    restore  # returns hash of session data.
76  #    update
77  #    close
78  #    delete
79  #
80  # Changing storage type mid-session does not work.  Note in particular
81  # that by default the FileStore and PStore session data files have the
82  # same name.  If your application switches from one to the other without
83  # making sure that filenames will be different
84  # and clients still have old sessions lying around in cookies, then
85  # things will break nastily!
86  #
87  # == Maintaining the session id.
88  #
89  # Most session state is maintained on the server.  However, a session
90  # id must be passed backwards and forwards between client and server
91  # to maintain a reference to this session state.
92  #
93  # The simplest way to do this is via cookies.  The CGI::Session class
94  # provides transparent support for session id communication via cookies
95  # if the client has cookies enabled.
96  #
97  # If the client has cookies disabled, the session id must be included
98  # as a parameter of all requests sent by the client to the server.  The
99  # CGI::Session class in conjunction with the CGI class will transparently
100  # add the session id as a hidden input field to all forms generated
101  # using the CGI#form() HTML generation method.  No built-in support is
102  # provided for other mechanisms, such as URL re-writing.  The caller is
103  # responsible for extracting the session id from the session_id
104  # attribute and manually encoding it in URLs and adding it as a hidden
105  # input to HTML forms created by other mechanisms.  Also, session expiry
106  # is not automatically handled.
107  #
108  # == Examples of use
109  #
110  # === Setting the user's name
111  #
112  #   require 'cgi'
113  #   require 'cgi/session'
114  #   require 'cgi/session/pstore'     # provides CGI::Session::PStore
115  #
116  #   cgi = CGI.new("html4")
117  #
118  #   session = CGI::Session.new(cgi,
119  #       'database_manager' => CGI::Session::PStore,  # use PStore
120  #       'session_key' => '_rb_sess_id',              # custom session key
121  #       'session_expires' => Time.now + 30 * 60,     # 30 minute timeout
122  #       'prefix' => 'pstore_sid_')                   # PStore option
123  #   if cgi.has_key?('user_name') and cgi['user_name'] != ''
124  #       # coerce to String: cgi[] returns the
125  #       # string-like CGI::QueryExtension::Value
126  #       session['user_name'] = cgi['user_name'].to_s
127  #   elsif !session['user_name']
128  #       session['user_name'] = "guest"
129  #   end
130  #   session.close
131  #
132  # === Creating a new session safely
133  #
134  #   require 'cgi'
135  #   require 'cgi/session'
136  #
137  #   cgi = CGI.new("html4")
138  #
139  #   # We make sure to delete an old session if one exists,
140  #   # not just to free resources, but to prevent the session
141  #   # from being maliciously hijacked later on.
142  #   begin
143  #       session = CGI::Session.new(cgi, 'new_session' => false)
144  #       session.delete
145  #   rescue ArgumentError  # if no old session
146  #   end
147  #   session = CGI::Session.new(cgi, 'new_session' => true)
148  #   session.close
149  #
150  class Session
151
152    class NoSession < RuntimeError #:nodoc:
153    end
154
155    # The id of this session.
156    attr_reader :session_id, :new_session
157
158    def Session::callback(dbman)  #:nodoc:
159      Proc.new{
160        dbman[0].close unless dbman.empty?
161      }
162    end
163
164    # Create a new session id.
165    #
166    # The session id is an MD5 hash based upon the time,
167    # a random number, and a constant string.  This routine
168    # is used internally for automatically generated
169    # session ids.
170    def create_new_id
171      require 'securerandom'
172      begin
173        session_id = SecureRandom.hex(16)
174      rescue NotImplementedError
175        require 'digest/md5'
176        md5 = Digest::MD5::new
177        now = Time::now
178        md5.update(now.to_s)
179        md5.update(String(now.usec))
180        md5.update(String(rand(0)))
181        md5.update(String($$))
182        md5.update('foobar')
183        session_id = md5.hexdigest
184      end
185      session_id
186    end
187    private :create_new_id
188
189    # Create a new CGI::Session object for +request+.
190    #
191    # +request+ is an instance of the +CGI+ class (see cgi.rb).
192    # +option+ is a hash of options for initialising this
193    # CGI::Session instance.  The following options are
194    # recognised:
195    #
196    # session_key:: the parameter name used for the session id.
197    #               Defaults to '_session_id'.
198    # session_id:: the session id to use.  If not provided, then
199    #              it is retrieved from the +session_key+ parameter
200    #              of the request, or automatically generated for
201    #              a new session.
202    # new_session:: if true, force creation of a new session.  If not set,
203    #               a new session is only created if none currently
204    #               exists.  If false, a new session is never created,
205    #               and if none currently exists and the +session_id+
206    #               option is not set, an ArgumentError is raised.
207    # database_manager:: the name of the class providing storage facilities
208    #                    for session state persistence.  Built-in support
209    #                    is provided for +FileStore+ (the default),
210    #                    +MemoryStore+, and +PStore+ (from
211    #                    cgi/session/pstore.rb).  See the documentation for
212    #                    these classes for more details.
213    #
214    # The following options are also recognised, but only apply if the
215    # session id is stored in a cookie.
216    #
217    # session_expires:: the time the current session expires, as a
218    #                   +Time+ object.  If not set, the session will terminate
219    #                   when the user's browser is closed.
220    # session_domain:: the hostname domain for which this session is valid.
221    #                  If not set, defaults to the hostname of the server.
222    # session_secure:: if +true+, this session will only work over HTTPS.
223    # session_path:: the path for which this session applies.  Defaults
224    #                to the directory of the CGI script.
225    #
226    # +option+ is also passed on to the session storage class initializer; see
227    # the documentation for each session storage class for the options
228    # they support.
229    #
230    # The retrieved or created session is automatically added to +request+
231    # as a cookie, and also to its +output_hidden+ table, which is used
232    # to add hidden input elements to forms.
233    #
234    # *WARNING* the +output_hidden+
235    # fields are surrounded by a <fieldset> tag in HTML 4 generation, which
236    # is _not_ invisible on many browsers; you may wish to disable the
237    # use of fieldsets with code similar to the following
238    # (see http://blade.nagaokaut.ac.jp/cgi-bin/scat.rb/ruby/ruby-list/37805)
239    #
240    #   cgi = CGI.new("html4")
241    #   class << cgi
242    #       undef_method :fieldset
243    #   end
244    #
245    def initialize(request, option={})
246      @new_session = false
247      session_key = option['session_key'] || '_session_id'
248      session_id = option['session_id']
249      unless session_id
250        if option['new_session']
251          session_id = create_new_id
252          @new_session = true
253        end
254      end
255      unless session_id
256        if request.key?(session_key)
257          session_id = request[session_key]
258          session_id = session_id.read if session_id.respond_to?(:read)
259        end
260        unless session_id
261          session_id, = request.cookies[session_key]
262        end
263        unless session_id
264          unless option.fetch('new_session', true)
265            raise ArgumentError, "session_key `%s' should be supplied"%session_key
266          end
267          session_id = create_new_id
268          @new_session = true
269        end
270      end
271      @session_id = session_id
272      dbman = option['database_manager'] || FileStore
273      begin
274        @dbman = dbman::new(self, option)
275      rescue NoSession
276        unless option.fetch('new_session', true)
277          raise ArgumentError, "invalid session_id `%s'"%session_id
278        end
279        session_id = @session_id = create_new_id unless session_id
280        @new_session=true
281        retry
282      end
283      request.instance_eval do
284        @output_hidden = {session_key => session_id} unless option['no_hidden']
285        @output_cookies =  [
286          Cookie::new("name" => session_key,
287          "value" => session_id,
288          "expires" => option['session_expires'],
289          "domain" => option['session_domain'],
290          "secure" => option['session_secure'],
291          "path" =>
292          if option['session_path']
293            option['session_path']
294          elsif ENV["SCRIPT_NAME"]
295            File::dirname(ENV["SCRIPT_NAME"])
296          else
297          ""
298          end)
299        ] unless option['no_cookies']
300      end
301      @dbprot = [@dbman]
302      ObjectSpace::define_finalizer(self, Session::callback(@dbprot))
303    end
304
305    # Retrieve the session data for key +key+.
306    def [](key)
307      @data ||= @dbman.restore
308      @data[key]
309    end
310
311    # Set the session data for key +key+.
312    def []=(key, val)
313      @write_lock ||= true
314      @data ||= @dbman.restore
315      @data[key] = val
316    end
317
318    # Store session data on the server.  For some session storage types,
319    # this is a no-op.
320    def update
321      @dbman.update
322    end
323
324    # Store session data on the server and close the session storage.
325    # For some session storage types, this is a no-op.
326    def close
327      @dbman.close
328      @dbprot.clear
329    end
330
331    # Delete the session from storage.  Also closes the storage.
332    #
333    # Note that the session's data is _not_ automatically deleted
334    # upon the session expiring.
335    def delete
336      @dbman.delete
337      @dbprot.clear
338    end
339
340    # File-based session storage class.
341    #
342    # Implements session storage as a flat file of 'key=value' values.
343    # This storage type only works directly with String values; the
344    # user is responsible for converting other types to Strings when
345    # storing and from Strings when retrieving.
346    class FileStore
347      # Create a new FileStore instance.
348      #
349      # This constructor is used internally by CGI::Session.  The
350      # user does not generally need to call it directly.
351      #
352      # +session+ is the session for which this instance is being
353      # created.  The session id must only contain alphanumeric
354      # characters; automatically generated session ids observe
355      # this requirement.
356      #
357      # +option+ is a hash of options for the initializer.  The
358      # following options are recognised:
359      #
360      # tmpdir:: the directory to use for storing the FileStore
361      #          file.  Defaults to Dir::tmpdir (generally "/tmp"
362      #          on Unix systems).
363      # prefix:: the prefix to add to the session id when generating
364      #          the filename for this session's FileStore file.
365      #          Defaults to "cgi_sid_".
366      # suffix:: the prefix to add to the session id when generating
367      #          the filename for this session's FileStore file.
368      #          Defaults to the empty string.
369      #
370      # This session's FileStore file will be created if it does
371      # not exist, or opened if it does.
372      def initialize(session, option={})
373        dir = option['tmpdir'] || Dir::tmpdir
374        prefix = option['prefix'] || 'cgi_sid_'
375        suffix = option['suffix'] || ''
376        id = session.session_id
377        require 'digest/md5'
378        md5 = Digest::MD5.hexdigest(id)[0,16]
379        @path = dir+"/"+prefix+md5+suffix
380        if File::exist? @path
381          @hash = nil
382        else
383          unless session.new_session
384            raise CGI::Session::NoSession, "uninitialized session"
385          end
386          @hash = {}
387        end
388      end
389
390      # Restore session state from the session's FileStore file.
391      #
392      # Returns the session state as a hash.
393      def restore
394        unless @hash
395          @hash = {}
396          begin
397            lockf = File.open(@path+".lock", "r")
398            lockf.flock File::LOCK_SH
399            f = File.open(@path, 'r')
400            for line in f
401              line.chomp!
402              k, v = line.split('=',2)
403              @hash[CGI::unescape(k)] = Marshal.restore(CGI::unescape(v))
404            end
405          ensure
406            f.close unless f.nil?
407            lockf.close if lockf
408          end
409        end
410        @hash
411      end
412
413      # Save session state to the session's FileStore file.
414      def update
415        return unless @hash
416        begin
417          lockf = File.open(@path+".lock", File::CREAT|File::RDWR, 0600)
418          lockf.flock File::LOCK_EX
419          f = File.open(@path+".new", File::CREAT|File::TRUNC|File::WRONLY, 0600)
420          for k,v in @hash
421            f.printf "%s=%s\n", CGI::escape(k), CGI::escape(String(Marshal.dump(v)))
422          end
423          f.close
424          File.rename @path+".new", @path
425        ensure
426          f.close if f and !f.closed?
427          lockf.close if lockf
428        end
429      end
430
431      # Update and close the session's FileStore file.
432      def close
433        update
434      end
435
436      # Close and delete the session's FileStore file.
437      def delete
438        File::unlink @path+".lock" rescue nil
439        File::unlink @path+".new" rescue nil
440        File::unlink @path rescue Errno::ENOENT
441      end
442    end
443
444    # In-memory session storage class.
445    #
446    # Implements session storage as a global in-memory hash.  Session
447    # data will only persist for as long as the ruby interpreter
448    # instance does.
449    class MemoryStore
450      GLOBAL_HASH_TABLE = {} #:nodoc:
451
452      # Create a new MemoryStore instance.
453      #
454      # +session+ is the session this instance is associated with.
455      # +option+ is a list of initialisation options.  None are
456      # currently recognised.
457      def initialize(session, option=nil)
458        @session_id = session.session_id
459        unless GLOBAL_HASH_TABLE.key?(@session_id)
460          unless session.new_session
461            raise CGI::Session::NoSession, "uninitialized session"
462          end
463          GLOBAL_HASH_TABLE[@session_id] = {}
464        end
465      end
466
467      # Restore session state.
468      #
469      # Returns session data as a hash.
470      def restore
471        GLOBAL_HASH_TABLE[@session_id]
472      end
473
474      # Update session state.
475      #
476      # A no-op.
477      def update
478        # don't need to update; hash is shared
479      end
480
481      # Close session storage.
482      #
483      # A no-op.
484      def close
485        # don't need to close
486      end
487
488      # Delete the session state.
489      def delete
490        GLOBAL_HASH_TABLE.delete(@session_id)
491      end
492    end
493
494    # Dummy session storage class.
495    #
496    # Implements session storage place holder.  No actual storage
497    # will be done.
498    class NullStore
499      # Create a new NullStore instance.
500      #
501      # +session+ is the session this instance is associated with.
502      # +option+ is a list of initialisation options.  None are
503      # currently recognised.
504      def initialize(session, option=nil)
505      end
506
507      # Restore (empty) session state.
508      def restore
509        {}
510      end
511
512      # Update session state.
513      #
514      # A no-op.
515      def update
516      end
517
518      # Close session storage.
519      #
520      # A no-op.
521      def close
522      end
523
524      # Delete the session state.
525      #
526      # A no-op.
527      def delete
528      end
529    end
530  end
531end
532