blob: 66436cfeddba8757b131c83cefa84a8e2a32382f [file] [log] [blame]
#
# Session - Itcl object for web session management for Rivet
#
#
# Copyright 2004 The Apache Software Foundation
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
package provide Session 1.0
package require Itcl
::itcl::class Session {
# true if the page being processed didn't have a previous session
public variable isNewSession 1
# contains the reason why this session is a new session, or "" if it isn't
public variable newSessionReason ""
# the routine that will handle saving data, could use DIO, could use
# flatfiles, etc.
public variable saveHandler ""
# the name of the DIO object that we'll use to access the database
public variable dioObject "DIO"
# the name of the cookie used to set the session ID
public variable cookieName "rivetSession"
# the probability that garbage collection will occur in percent.
public variable gcProbability 1
# the number of seconds after which data will be seen as "garbage"
# and cleaned up -- defaults to 1 day
public variable gcMaxLifetime 86400
# the substring you want to check each HTTP referer for. If the
# referer was sent by the browser and the substring is not found,
# the session will be deleted.
public variable refererCheck ""
public variable entropyFile "/dev/urandom"
# the number of bytes which will be read from the entropy file
public variable entropyLength 0
# set the scramble code to something unique for the site or the
# app or whatever, to slightly increase the unguessability of
# session ids
public variable scrambleCode "some random string"
# the lifetime of the cookie in minutes. 0 means until the browser
# is closed.
public variable cookieLifetime 0
# the lifetime of the session in seconds. this will be updated if
# additional pages are fetched while the session is still alive.
public variable sessionLifetime 7200
# if a request is being processed, a session is active, and this many
# seconds have elapsed since the session was created or the session
# update time was last updated, the session update time will be updated
# (the session being in use extends the session lifetime)
public variable sessionRefreshInterval 900
# the webserver subpath that the session cookie applies to -- defaults
# to /
public variable cookiePath "/"
# the domain to set in the session cookie
public variable cookieDomain ""
# the status of the last operation, "" if ok
public variable status
# specifies whether cookies should only be sent over secure connections
public variable cookieSecure 0
# specifies whether cookies should only be sent over http connections
public variable cookieHttpOnly 0
# the name of the table that session info will be stored in
public variable sessionTable "rivet_session"
# the name of the table that contains cached session data
public variable sessionCacheTable "rivet_session_cache"
# the file that debug messages will be written to
public variable debugFile stdout
# set debug mode to 1 to trace through and see the session object
# do its thing
public variable debugMode 1
constructor {args} {
eval configure $args
$dioObject registerSpecialField $sessionTable session_update_time NOW
$dioObject registerSpecialField $sessionTable session_start_time NOW
}
method status {args} {
if {$args == ""} {
return $status
}
set status $args
}
# get_entropy_bytes - read entropyLength bytes from a random data
# device, such as /dev/random or /dev/urandom, available on some
# systems as a way to generate random data
#
# if entropyLength is 0 (the default) or entropyFile isn't defined
# or doesn't open successfully, returns an empty string
#
method get_entropy_bytes {} {
if {$entropyLength == 0 || $entropyFile == ""} {
return ""
}
if {[catch {open $entropyFile} fp] == 1} {
return ""
}
set entropyBytes [read $fp $entropyLength]
close $fp
if {[binary scan $entropyBytes h* data]} {
debug "get_entropy_bytes: returning '$data'"
return $data
}
error "software bug - binary scan behaved unexpectedly"
}
#
# gen_session_id - generate a session ID by md5'ing as many things
# as we can get our hands on.
#
method gen_session_id {args} {
package require md5
# if the Apache unique ID module is installed, the environment
# variable UNIQUE_ID will have been set. If not, we'll get an
# empty string, which won't hurt anything.
set uniqueID [::rivet::env UNIQUE_ID]
set sessionIdKey "$uniqueID[clock clicks][pid]$args[clock seconds]$scrambleCode[get_entropy_bytes]"
debug "gen_session_id - feeding this to md5: '$sessionIdKey'"
return [::md5::md5 -hex -- $sessionIdKey]
}
#
# do_garbage_collection - delete dead sessions from the session table.
# corresponding session table cache entries will automatically be
# deleted as well (assuming they've been defined with ON DELETE CASCADE)
#
method do_garbage_collection {} {
debug "do_garbage_collection: performing garbage collection"
# set result [$dioObject exec "delete from $sessionTable where timestamp 'now' - session_update_time > interval '$gcMaxLifetime seconds';"]
set del_cmd "delete from $sessionTable where "
append del_cmd [$dioObject makeDBFieldValue $sessionTable session_update_time now SECS]
append del_cmd " - [$dioObject makeDBFieldValue $sessionTable session_update_time {} SECS]"
append del_cmd " > $gcMaxLifetime"
debug "do_garbage_collection: > $del_cmd <"
set result [$dioObject exec $del_cmd]
$result destroy
}
#
# consider_garbage_collection - perform a garbage collection gcProbability
# percent of the time. For example, if gcProbability is 1, about 1 in
# every 100 times this routine is called, garbage collection will be
# performed.
#
method consider_garbage_collection {} {
if {rand() <= $gcProbability / 100.0} {
do_garbage_collection
}
}
#
# set_session_cookie - set a session cookie to the specified value --
# other cookie attributes are controlled by variables defined in the
# object
#
method set_session_cookie {value} {
::rivet::cookie set $cookieName $value \
-path $cookiePath \
-minutes $cookieLifetime \
-secure $cookieSecure \
-HttpOnly $cookieHttpOnly
}
#
# id - get the session ID of the current browser
#
# returns a session ID if their session cookie matches a current session.
# returns an empty string if they do not have a session.
#
# status will be set to an empty string if all is ok, "timeout" if
# the session had timed out, "no_cookie" if no cookie was previously
# defined (session id could still be valid though -- first visit)
#
# ...caches the results in the info array to avoid calls to the database
# in subsequent requests for the user ID from the same page, a common
# occurrence.
#
method id {} {
::request::global sessionInfo
status ""
# if we already know the session ID, we're done.
# (i.e. we've already validated them earlier in the
# handling of the current page.)
if {[info exists sessionInfo(sessionID)]} {
debug "id called, returning cached ID '$sessionInfo(sessionID)'"
return $sessionInfo(sessionID)
}
#
# see if they have a session cookie. if they don't,
# set status and return.
#
set sessionCookie [::rivet::cookie get $cookieName]
if {$sessionCookie == ""} {
# they did not have a cookie set, they are not logged in
status "no_cookie"
debug "id: no session cookie '$cookieName'"
return ""
}
# there is a session Cookie, grab the remote address of the connection,
# see if our state table says he has logged into us from this
# address within our login timeout window and we've given him
# this session
debug "id: found session cookie '$cookieName' value '$sessionCookie'"
set a(session_id) $sessionCookie
set a(ip_address) [::rivet::env REMOTE_ADDR]
# see if there's a record matching the session ID cookie and
# IP address
set kf [list session_id ip_address]
set key [$dioObject makekey a $kf]
if {![$dioObject fetch $key a -table $sessionTable -keyfield $kf]} {
debug "id: no entry in the session table for session '$sessionCookie' and address [::rivet::env REMOTE_ADDR]: [$dioObject errorinfo]"
status "no_session"
return ""
}
## Carve the seconds out of the session_update_time field in the
# $sessionTable table. Trim off the timezone at the end.
set secs [clock scan [string range $a(session_update_time) 0 18]]
# if the session has timed out, delete the session and return -1
if {[expr $secs + $sessionLifetime] < [clock seconds]} {
$dioObject delete $key -table $sessionTable -keyfield $kf
debug "id: session '$sessionCookie' timed out"
status "timeout"
return ""
}
# Their session is still alive. If the session refresh
# interval time has expired, update the session update time in the
# database (we don't update every time they request a page for
# performance reasons) The idea is it's been at least 15 minutes or
# something like that since they've logged in, and they're still
# doing stuff, so reset their session update time to now
if {[expr $secs + $sessionRefreshInterval] < [clock seconds]} {
debug "session '$sessionCookie' alive, refreshing session update time"
set a(session_update_time) now
if {![$dioObject store a -table $sessionTable -keyfield $kf]} {
debug "id: Failed to store $sessionTable: [$dioObject errorinfo]"
puts "Failed to store $sessionTable: [$dioObject errorinfo]"
}
}
#
# THEY VALIDATED. Cache the session ID in the sessionInfo array
# that will only exist for the handling of this request, set that
# this is not a new session (at least one previous request has been
# handled with this session ID) and return the session ID
#
debug "id: active session, '$a(session_id)'"
set sessionInfo(sessionID) $a(session_id)
set isNewSession 0
return $a(session_id)
}
#
# store - given a package name, a key string, and a data string,
# store the data in the rivet session cache
#
method store {packageName key data} {
set a(session_id) [id]
set a(package_) $packageName
set a(key_) $key
regsub -all {\\} $data {\\\\} data
set a(data) $data
debug "store session data, package_ '$packageName', key_ '$key', data '$data'"
set kf [list session_id package_ key_]
if {![$dioObject store a -table $sessionCacheTable -keyfield $kf]} {
debug "Failed to store $sessionCacheTable '$kf'"
#parray a
error [$dioObject errorinfo]
}
}
#
# fetch - given a package name and a key, return the data stored
# for this session
#
method fetch {packageName key} {
set kf [list session_id package_ key_]
set a(session_id) [id]
set a(package_) $packageName
set a(key_) $key
set key [$dioObject makekey a $kf]
if {![$dioObject fetch $key a -table $sessionCacheTable -keyfield $kf]} {
status [$dioObject errorinfo]
debug "error: [$dioObject errorinfo]"
debug "fetch session data failed, package_ '$packageName', key_ '$key', error '[$dioObject errorinfo]'"
return ""
}
debug "fetch session data succeeded, package_ '$packageName', key_ '$key', result '$a(data)'"
return $a(data)
}
#
# stash - given a packagename and a key-value dictionary of data
# the procedure calls method store to data into the cache table using
# the dictionary keys as column _key value
#
#
method stash {packageName keyvalue_d} {
dict for {key value} $keyvalue_d {
$this store $packageName $key $value
}
}
#
# load - given a package names returns a dictionary storing the key - value pairs for this session
#
#
method load {packageName} {
set package_d [dict create]
$dioObject forall \
"select key_,data from $sessionCacheTable where package_='$packageName' and session_id='[$this id]'" a {
dict set package_d $a(key_) $a(data)
}
return $package_d
}
# clear - given a package name and optionally a key it deletes rows in the cache for this session.
# when also the key value is specified only the row for that package-key pair is deleted
#
method clear {packageName {key ""}} {
if {$key == ""} {
set keyval [list [$this id] $packageName]
set keyfield {session_id package_}
} else {
set keyval [list [$this id] $packageName $key]
set keyfield {session_id package_ key_}
}
$dioObject delete $keyval -table $sessionCacheTable -keyfield $keyfield
}
#
# delete - given a user ID and looking at their IP address we inherited
# from the environment (thanks, webserver), remove them from the session
# table. (the session table is how the server remembers stuff about
# sessions)
#
method delete_session {{session_id ""}} {
set ip_address [::rivet::env REMOTE_ADDR]
if {$session_id == ""} {
set session_id [id]
}
debug "delete session $session_id"
set kf [list session_id ip_address]
$dioObject delete [list $session_id $ip_address] -table $sessionTable -keyfield $kf
## NEED TO delete saved session data here too, from the
# $sessionCacheTable structure.
}
#
# create_session - Generate a session ID and store the session in the
# session table.
#
# returns the session_id
#
method create_session {} {
## Create their session by storing their session information in
# the session table.
set a(ip_address) [::rivet::env REMOTE_ADDR]
set a(session_start_time) now
set a(session_update_time) now
set a(session_id) [gen_session_id $a(ip_address)]
set kf [list ip_address session_id]
if {![$dioObject store a -table $sessionTable -keyfield $kf]} {
debug "Failed to store $sessionTable: [$dioObject errorinfo]"
puts "Failed to store $sessionTable: [$dioObject errorinfo]"
}
debug "create_session: ip $a(ip_address), id '$a(session_id)'"
return $a(session_id)
}
#
# activate - find the session ID if they have one. if they don't, create
# one and drop a cookie on them.
#
method activate {} {
::request::global sessionInfo
debug "activate: checking out the situation"
# a small percentage of the time, try to delete stale session data
consider_garbage_collection
set id [id]
if {$id != ""} {
debug "activate: returning session id '$id'"
return $id
}
# it's a new session, save the reason for why it's a new session,
# set that it's a new session, drop a session cookie on the browser
# that issued this request, set the session ID cache variable, and
# return the cookie ID
set newSessionReason [status]
debug "activate: new session, reason '$newSessionReason'"
set id [create_session]
set isNewSession 1
set_session_cookie $id
set sessionInfo(sessionID) $id
debug "activate: created session '$id' and set cookie (theoretically)"
return $id
}
#
# is_new_session - return a 1 if it's a new session, else a zero if there
# were one or more prior pages creating and/or using this session ID
#
method is_new_session {} {
return $isNewSession
}
#
# new_session_reason - return the reason why a session is new, either
# it didn't have a cookie "no_cookie", there was a cookie but no
# matching session "no_session", or there was a cookie and a session
# but the session has timed out "timeout". if the session isn't new,
# returns ""
#
method new_session_reason {} {
return $newSessionReason
}
#
# debug - output a debugging message
#
method debug {message} {
if {$debugMode} {
puts $debugFile "$this (debug) $message<br>"
flush $debugFile
}
}
}