From 47df2b440971457bc5c22197b5596f49a8b28317 Mon Sep 17 00:00:00 2001 From: Peter Rotich Date: Wed, 26 Oct 2022 01:18:21 +0000 Subject: [PATCH] session: use osTicket\Session Backends This commit should have probably been broken into smaller commits but the wholesale change was required since it's an overhaul of how osTicket handles sessions. --- api/api.inc.php | 16 +- api/http.php | 8 +- client.inc.php | 2 + include/class.osticket.php | 4 +- include/class.ostsession.php | 801 ++++++++++++++++++++++------------ include/class.usersession.php | 125 +++--- logo.php | 5 +- logout.php | 2 +- scp/logo.php | 5 +- scp/logout.php | 8 +- scp/staff.inc.php | 5 +- 11 files changed, 605 insertions(+), 376 deletions(-) diff --git a/api/api.inc.php b/api/api.inc.php index 25115e3ca..a04e6157e 100644 --- a/api/api.inc.php +++ b/api/api.inc.php @@ -15,14 +15,20 @@ **********************************************************************/ file_exists('../main.inc.php') or die('System Error'); +/* + * Why API_SESSION ?? + * It indicates that the session is API - which session handler should handle as + * stateless for new sessions. + * Existing session continue to work as expected - this it's important for + * SSO authentication, which uses /api/auth/* endpoints. Such calls are not + * stateless. + * + */ +define('API_SESSION', true); + // APICALL const. define('APICALL', true); -// Disable sessions for the API. API should be considered stateless and -// shouldn't chew up database records to store sessions -if (!defined('DISABLE_SESSION')) - define('DISABLE_SESSION', true); - require_once('../main.inc.php'); require_once(INCLUDE_DIR.'class.http.php'); require_once(INCLUDE_DIR.'class.api.php'); diff --git a/api/http.php b/api/http.php index d9ab623e1..f9cff1310 100644 --- a/api/http.php +++ b/api/http.php @@ -13,15 +13,9 @@ vim: expandtab sw=4 ts=4 sts=4: **********************************************************************/ -// Use sessions — it's important for SSO authentication, which uses -// /api/auth/ext -define('DISABLE_SESSION', false); - require 'api.inc.php'; - # Include the main api urls require_once INCLUDE_DIR."class.dispatcher.php"; - $dispatcher = patterns('', url_post("^/tickets\.(?Pxml|json|email)$", array('api.tickets.php:TicketApiController','create')), url('^/tasks/', patterns('', @@ -29,8 +23,8 @@ $dispatcher = patterns('', )) ); +// Send api signal so backend can register endpoints Signal::send('api', $dispatcher); - # Call the respective function print $dispatcher->resolve(Osticket::get_path_info()); ?> diff --git a/client.inc.php b/client.inc.php index 63d7d1224..42f794285 100644 --- a/client.inc.php +++ b/client.inc.php @@ -79,6 +79,8 @@ $ost->addExtraHeader('getMaxIdleTime() : + SESSION_TTL); require(INCLUDE_DIR.'class.nav.php'); $nav = new UserNav($thisclient, 'home'); diff --git a/include/class.osticket.php b/include/class.osticket.php index 76101469b..04a4de427 100644 --- a/include/class.osticket.php +++ b/include/class.osticket.php @@ -59,9 +59,7 @@ class osTicket { require_once(INCLUDE_DIR.'class.company.php'); // Load the config $this->config = new OsticketConfig(); - // Start session (if not disabled) - if (!defined('DISABLE_SESSION') || !DISABLE_SESSION) - $this->session = osTicketSession::start(SESSION_TTL, + $this->session = osTicketSession::start(SESSION_TTL, $this->isUpgradePending()); // CSRF Token $this->csrf = new CSRF('__CSRFToken__'); diff --git a/include/class.ostsession.php b/include/class.ostsession.php index 04bca72ad..5bd0f4423 100644 --- a/include/class.ostsession.php +++ b/include/class.ostsession.php @@ -2,10 +2,10 @@ /********************************************************************* class.ostsession.php - Custom osTicket session handler. + osTicket Session Management Backend Peter Rotich - Copyright (c) 2006-2013 osTicket + Copyright (c) 2022 osTicket http://www.osticket.com Released under the GNU General Public License WITHOUT ANY WARRANTY. @@ -13,89 +13,222 @@ vim: expandtab sw=4 ts=4 sts=4: **********************************************************************/ +include_once INCLUDE_DIR.'class.session.php'; class osTicketSession { - static $backends = array( - 'db' => 'DbSessionBackend', - 'memcache' => 'MemcacheSessionBackend', - 'noop' => 'NoopSessionBackend', - 'system' => 'FallbackSessionBackend', - ); + // Session Name + private static $name = 'OSTSESSID'; + // Session Backend instance + private $backend; + // Session default TTL + private $ttl; - var $ttl = SESSION_TTL; - var $data = ''; - var $data_hash = ''; - var $id = ''; - var $backend; + function __construct($ttl = SESSION_TTL, $checkdbversion = false) { + // Session ttl cannot exceed php.ini maxlifetime setting + $maxlife = ini_get('session.gc_maxlifetime'); + $this->ttl = min($ttl ?: ($maxlife ?: SESSION_TTL), $maxlife); - function __construct($ttl=0, $checkdbversion=false){ - $this->ttl = $ttl ?: ini_get('session.gc_maxlifetime') ?: SESSION_TTL; - - // Set osTicket specific session name. - session_name('OSTSESSID'); - - // Forced cleanup on shutdown - register_shutdown_function('session_write_close'); - - // Set session cleanup time to match TTL - ini_set('session.gc_maxlifetime', $ttl); - - // Skip db version check if version is later than 1.7 - if ((!defined('MAJOR_VERSION') || $checkdbversion) - && OsticketConfig::getDBVersion()) - return session_start(); - - # Cookies - // Avoid setting a cookie domain without a dot, thanks - // http://stackoverflow.com/a/1188145 - $domain = null; - if (isset($_SERVER['HTTP_HOST']) - && strpos($_SERVER['HTTP_HOST'], '.') !== false - && !Validator::is_ip($_SERVER['HTTP_HOST'])) - // Remote port specification, as it will make an invalid domain - list($domain) = explode(':', $_SERVER['HTTP_HOST']); - - session_set_cookie_params($ttl, ROOT_PATH, $domain, + // Set osTicket specific session name + // TODO: Make it configurable in ost-config.php + session_name(self::$name); + // Set Default cookie Params before we start the session + session_set_cookie_params($this->ttl, ROOT_PATH, Http::domain(), osTicket::is_https(), true); - // Determine Session Backend to use - $bk = 'db'; - if (defined('DISABLE_SESSION')) - $bk = 'noop'; - elseif (defined('SESSION_BACKEND')) - $bk = SESSION_BACKEND; + /** Determine Session Backend to use **/ + if ((!defined('MAJOR_VERSION') || $checkdbversion) + && OsticketConfig::getDBVersion()) + // Default PHP SessionHandler + $bk = 'system'; + else // default is database + $bk = self::session_backend() ?: 'database'; + + // Tag the backend with storage backend class + switch ($bk) { + case 'database': + $bk = "$bk:DatabaseSessionStorageBackend"; + break; + case 'memcache': + $bk = "$bk:MemcacheSessionStorageBackend"; + break; + case 'memcache:database': + // memcache is primary while database is secondary + // Database doesn't store data when set as secondary, but + // very useful when session data is offloaded to memcache with + // database tracking sessions ids and expire time for the + // purpose of knowing who is online. + $bk = sprintf('%s:%s:%s', + 'memcache', + 'MemcacheSessionStorageBackend', + 'DatabaseSessionStorageBackend'); + break; + case 'database:memcache': + // database is primary while memcache is secondary + // This setup only makes sense if memcache is being used as + // backup backend + $bk = sprintf('%s:%s:%s', + 'database', + 'DatabaseSessionStorageBackend', + 'MemcacheSessionStorageBackend'); + break; + case 'system': + case 'noop': + // system & noop don't require storage backends + break; + default: + // Assume invalid entry - default to system + $bk = 'system'; + } + + /** Session Backend options **/ + $options = [ + // Default TTL (maxlife) + 'session_ttl' => $this->ttl, + // It indicates that the session is API session - which session + // handler should handle as stateless for new sessions. + 'api_session' => defined('API_SESSION'), + ]; + + // Set MaxLifeTime if defined. This is defined per user so it's + // preferred over ttl. + if (defined('SESSION_MAXLIFE') && is_numeric(SESSION_MAXLIFE)) + $options['session_maxlife'] = SESSION_MAXLIFE; + + // If $bk doesn't exit then an Exception will be thrown + // Backend is object is turned on success try { - if (!isset(self::$backends[$bk]) - || !class_exists(self::$backends[$bk])) - $bk = 'db'; - $this->backend = new self::$backends[$bk]($this->ttl); - } - catch (Exception $x) { - // Use the database for sessions - trigger_error($x->getMessage(), E_USER_WARNING); - $this->backend = new self::$backends['db']($this->ttl); + // If $bk doesn't exit then an Exception will be thrown + // backend object is turned on success + $this->backend = osTicket\Session\SessionBackend::register($bk, + $options, true); + } catch (Throwable $t) { + die($t->getMessage()); + // We're just gonna default to php session handler and hope for + // the best. + // TODO: Log the error and perhaps rethrow the exception so it + // can be fatal? } - if ($this->backend instanceof SessionBackend) { - // Set handlers. - session_set_save_handler( - array($this->backend, 'open'), - array($this->backend, 'close'), - array($this->backend, 'read'), - array($this->backend, 'write'), - array($this->backend, 'destroy'), - array($this->backend, 'gc') - ); - } - - // Start the session. + // Finally start the damn session. session_start(); } - function regenerate_id(){ - $oldId = session_id(); - session_regenerate_id(); - $this->backend->destroy($oldId); + /* + * session_backend + * + * Get configured session backend if any. + */ + static function session_backend($default = false) { + // Session Disabled? + // This is useful when fetching logos or on CLI - we don't want to update the + // session in such cases. + if (defined('NOOP_SESSION') || defined('DISABLE_SESSION')) + return 'noop'; // Ignore session data + + // No session backend set - return default + if (!defined('SESSION_BACKEND')) + return $default; + + // Explode backend incase it's chained + list($bk, $secondary) = explode(':', SESSION_BACKEND); + // Only recongnize supported primary backends + switch (strtolower($bk)) { + case 'memcache': + // Make sure we have memcache servers defined - if not + // then break so we can return default. + // TODO: Log an error or throw an exception + if (!defined('MEMCACHE_SERVERS')) + break; + // No break on purpose + case 'database': + case 'system': + return strtolower(SESSION_BACKEND); + break; + default: + return $default; + } + } + + /* + * registered_backend + * + * get registered backend if any + */ + static function registered_backend() { + global $ost; + if ($ost && isset($ost->session)) + return $ost->session->backend; + } + + /* + * regenerate($ttl) + * + * Regenerate current session_id and expire the old one in $ttl seconds + * This is preferred over destroying the session immediately just incase + * we have pending ajax requests for example + * + */ + static function regenerate(int $ttl=60) { + // Make sure session is active and headers are not already sent + if (session_status() !== PHP_SESSION_ACTIVE + || headers_sent()) + return false; + + // Save current ($old) session id + $old = session_id(); + // Expire current session cookie now + ttl + // We have to do it here before re regenerate becase renewCookie has + // hardcoded session calls to get name & id + // FIXME: Maybe bae... + self::renewCookie(time(), $ttl); + // Regenerate the session + // TODO: use session_create_id() instead of session_regenerate_id - + // but PHP (even 8) is still bugy and inconsistent when it comes to + // using SessionIdInterface::create_sid depening on session settings. + session_regenerate_id(false); + // get a new session id and force commit + // Expire old session now + ttl + session_write_close(); + // Expire old session now + ttl + // ::expire() is not a standard session routine so we have to commit + // the current "new" session first + self::expire($old, $ttl); + // Restart the new session + session_start(); + // Return the new session id + $new = session_id(); + error_log("regenerate: old($old) new($new)"); + return session_id(); + } + + /* + * expire session + * + * Expire session in $ttl from now if the storage backend supports + * it otherwise it should destroy it by calling this->destroy($id) + */ + // Expire session - end is near mb! + static function expire($id, $ttl) { + // See if we have a backend to ask to expire the session - otherwise + // we destroy session now! + if (!($backend=self::registered_backend())) + return false; + + // Expire session soonish (now() + $ttl) - end is near mb! + return (bool) $backend->expire($id, $ttl); + } + + static function renewCookie($baseTime=false, $window=false) { + $ttl = $window ?: SESSION_TTL; + $expire = ($baseTime ?: time()) + $ttl; + setcookie(session_name(), session_id(), $expire, + ini_get('session.cookie_path'), + ini_get('session.cookie_domain'), + ini_get('session.cookie_secure'), + ini_get('session.cookie_httponly')); + // Trigger expire update - neeed for secondary handlers that only + // log new sessions + self::expire(session_id(), $ttl); } static function destroyCookie() { @@ -106,270 +239,380 @@ class osTicketSession { ini_get('session.cookie_httponly')); } - static function renewCookie($baseTime=false, $window=false) { - setcookie(session_name(), session_id(), - ($baseTime ?: time()) + ($window ?: SESSION_TTL), - ini_get('session.cookie_path'), - ini_get('session.cookie_domain'), - ini_get('session.cookie_secure'), - ini_get('session.cookie_httponly')); - } - - /* helper functions */ - - function get_online_users($sec=0){ - $sql='SELECT user_id FROM '.SESSION_TABLE.' WHERE user_id>0 AND session_expire>NOW()'; - if($sec) - $sql.=" AND TIME_TO_SEC(TIMEDIFF(NOW(),session_updated))<$sec"; - - $users=array(); - if(($res=db_query($sql)) && db_num_rows($res)) { - while(list($uid)=db_fetch_row($res)) - $users[] = $uid; - } - + static function get_online_users(int $seconds = 0) { + // Authoretative is lookup is DatabaseSessionRecords assuming + // database is the primary backend or secondary logger + $records = DatabaseSessionRecord::active_sessions([ + 'lastseen' => $seconds, + 'authenticated' => true, + ]); + $users = []; + foreach ($records as $record) + $users[] = $record->getUserId(); return $users; } - /* ---------- static function ---------- */ static function start($ttl=0, $checkdbversion=false) { return new static($ttl, $checkdbversion); } } -abstract class SessionBackend implements SessionHandlerInterface { - var $isnew = false; - var $ttl; - - function __construct($ttl=SESSION_TTL) { - $this->ttl = $ttl; - } - - function open($save_path, $session_name) { - return true; - } - - function close() { - return true; - } - - function getTTL() { - return $this->ttl; - } - - function write($id, $data) { - // Last chance session update - $i = new ArrayObject(array('touched' => false)); - Signal::send('session.close', null, $i); - return $this->update($id, $i['touched'] ? session_encode() : $data); - } - - function cleanup() { - $this->gc(0); - } - - abstract function read($id); - abstract function update($id, $data); - abstract function destroy($id); - abstract function gc($maxlife); -} - -class SessionData -extends VerySimpleModel { +/* + * DatabaseSessionRecord + * + * ORM hook to session table with SessionRecordInterface implementation + */ +class DatabaseSessionRecord extends VerySimpleModel + implements osTicket\Session\SessionRecordInterface { static $meta = array( 'table' => SESSION_TABLE, 'pk' => array('session_id'), ); -} -class DbSessionBackend -extends SessionBackend { - var $data = null; + public function isNew() { + return $this->__new__; + } - function read($id) { + public function isValid() { + return true; + // Basic checks to make sure the session data is valid + // TODO: Throw specific exceptions so it can be handled downstream + if (isset($this->user_agent) + && strcmp($_SERVER['HTTP_USER_AGENT'], $this->user_agent) !== 0) + return false; + return true; + } + + public function setId(string $id) { + $this->session_id = $id; + return $this; + } + + public function getId() { + return $this->session_id; + } + + public function setData(string $data = null) { + $this->session_data = $data; + return $this; + } + + public function getData() { + return $this->session_data; + } + + public function getExpireTime() { + return $this->session_expire; + } + + private function setExpire(int $maxlife) { + $this->session_expire = SqlFunction::NOW() + ->plus(SqlInterval::SECOND($maxlife)); + } + + public function setTTL($ttl) { + $this->setExpire($ttl); + return $this; + } + + public function expire(int $ttl) { + $this->setTTL($ttl); + // Assume it will expire shortly - clear user_id + $this->user_id = 0; + return $this->commit(); + } + + public function getUpdateTime() { + return $this->session_updated; + } + + public function commit() { + return ($this->save()); + } + + public function save($refresh=false) { + global $thisstaff; + // See if we need to set the user id - should only be set once onlogin + if (!$this->user_id && $thisstaff) + $this->user_id = $thisstaff->getId(); + if (count($this->dirty)) + $this->session_updated = SqlFunction::NOW(); + return parent::save($refresh); + } + + public function delete() { + return (parent::delete()); + } + + public function toArray() { + return [ + 'id' => $this->getId(), + 'data' => $this->getData(), + 'updated' => $this->getUpdateTime(), + 'expires' => $this->getExpireTime(), + ]; + } + + /* + * lookupRecord + * + * Given session id - lookup the record + * + * Possible returns; + * false: on lookup failure + * null: doesn't exists and autocreate is false + * record: fetched or newly created session record + * + */ + public static function lookupRecord($id, $autocreate = false, + $backend=null) { + // We're doing lookup locally so we can auto-create one if the + // session doesn't exist. + $record = false; try { - $this->data = SessionData::objects() + $record = self::objects() ->filter(['session_id' => $id]) ->annotate(array('is_expired' => new SqlExpr(new Q(array('session_expire__lt' => SqlFunction::NOW()))))) ->one(); - - if ($this->data->is_expired > 0) { + if ($record->is_expired > 0) { // session_expire is in the past. Pretend it is expired and // reset the data. This will assist with CSRF issues - $this->data->session_data=''; + $record->session_data = ''; } - $this->id = $id; } catch (DoesNotExist $e) { - $this->data = new SessionData(['session_id' => $id, 'session_data' => '']); + // We're auto-creating model (unsaved) when one doesn't exist? + $record = $autocreate ? self::create($id) : null; } - catch (OrmException $e) { - return false; + catch (OrmException | Exception $ex) { + // This could happen if more than one record exits in the + // database for example. } - // Verify the User Agent string - if (isset($this->data->user_agent) - && (strcmp($_SERVER['HTTP_USER_AGENT'], $this->data->user_agent) !== 0)) { - $this->destroy($id); - return false; - } - return $this->data->session_data; + return $record; } - function update($id, $data){ - global $thisstaff; + static function create($vars) { + // We expect ::init($id) or ::init(array $vars) + if ($vars && !is_array($vars)) + $vars = ['session_id' => $vars]; + elseif (!isset($vars['session_id'])) + // Session Id is required + throw Exception(sprintf('session_id: %s', __('Required'))); - if (defined('DISABLE_SESSION') && $this->data->__new__) - return true; + // Set User Session Atrributes + if (!isset($vars['user_ip'])) + $vars['user_ip'] = $_SERVER['REMOTE_ADDR']; + if (!isset($vars['user_agent'])) + $vars['user_agent'] = $_SERVER['HTTP_USER_AGENT']; + // Default to system SESSION_TTL if session_maxlife is not set + $maxlife = $vars['session_maxlife'] ?? SESSION_TTL; + // filter out the $vars to just the fields we support - backend can + // send way more data than we want. + $vars = array_intersect_key($vars, + array_flip([ + 'session_id', + 'session_data', + 'session_expire', + 'session_updated', + 'user_id', + 'user_ip', + 'user_agent', + ])); - $ttl = $this && method_exists($this, 'getTTL') - ? $this->getTTL() : SESSION_TTL; - - // Create a session data obj if not loaded. - if (!isset($this->data)) - $this->data = new SessionData(['session_id' => $id]); - - $this->data->session_data = $data; - $this->data->session_expire = - SqlFunction::NOW()->plus(SqlInterval::SECOND($ttl)); - $this->data->user_id = $thisstaff ? $thisstaff->getId() : 0; - $this->data->user_ip = $_SERVER['REMOTE_ADDR']; - $this->data->user_agent = $_SERVER['HTTP_USER_AGENT']; - - return $this->data->save(); + // Create an instance + $record = new self($vars); + // set session_expire timestamp based on $maxlife + $record->setExpire($maxlife); + // Set updated to now() + $record->session_updated = SqlFunction::NOW(); + return $record; } - function destroy($id){ - return SessionData::objects()->filter(['session_id' => $id])->delete() ? true : false; + static function destroy($id) { + return ( (bool) self::objects() + ->filter(['session_id' => $id]) + ->delete()); } - function cleanup() { - $this->gc(0); - } - - function gc($maxlife){ - SessionData::objects()->filter([ - 'session_expire__lte' => SqlFunction::NOW() + static function cleanupExpired() { + return self::objects()->filter([ + 'session_expire__lte' => SqlFunction::NOW() ])->delete(); } + + static function active_sessions(array $criteria = []) { + $now = SqlFunction::NOW(); + // Active must not be expired + $filters = ['session_expire__gt' => $now]; + + // Authenticated users have user_id set (only Agents at the moment) + if (isset($criteria['authenticated']) && $criteria['authenticated']) + $filters['user_id__gt'] = 0; + elseif (isset($criteria['authenticated'])) + $filters['user_id'] = 0; // Guests only + + // last seen since X seconds + if (isset($criteria['lastseen']) && $criteria['lastseen']) { + $interval = new SqlInterval('SECOND', $criteria['lastseen']); + $filters['session_updated__gte'] = $now->minus($interval); + } + + return self::objects()->filter($filters); + } } -class MemcacheSessionBackend -extends SessionBackend { - var $memcache; - var $servers = array(); +/* + * Database Session Storage Backend + * + */ +class DatabaseSessionStorageBackend + extends osTicket\Session\AbstractSessionStorageBackend { - function __construct($ttl) { - parent::__construct($ttl); + public function expireRecord($id, $ttl) { + if (!($record=$this->getRecord($id))) + return false; - if (!extension_loaded('memcache')) - throw new Exception('Memcached extension is missing'); - if (!defined('MEMCACHE_SERVERS')) + return (bool) $record->expire($ttl); + } + + public function saveRecord($record, $secondary=false) { + if ($secondary || !is_a($record, 'DatabaseSessionRecord')) { + // We're only recording new sessions without data if we're + // secondary backend + if ($record->isNew()) + $this->writeRecord($record->getId(), null); + return true; + } + return (bool) $record->commit(); + } + + public function writeRecord($id, $data=null) { + $record = self:: lookupRecord($id, true); + $record->setData($data); + return (bool) $record->commit(); + } + + public function lookupRecord($id, $autocreate) { + return DatabaseSessionRecord::lookupRecord($id, $autocreate, $this); + } + + public function destroyRecord($id) { + return (bool) (DatabaseSessionRecord::destroy($id)); + } + + public function cleanupExpiredRecords() { + return DatabaseSessionRecord::cleanupExpired(); + } +} +/* + * Memchache Session Storage Backend + * + */ +class MemcacheSessionStorageBackend + extends osTicket\Session\AbstractMemcacheSessionStorageBackend { + + public function __construct($options) { + // Make sure we have memcache servers + $servers = []; + if (isset($options['servers']) && is_array($options['servers'])) + $servers = $options['servers']; + elseif (defined('MEMCACHE_SERVERS')) + $servers = explode(',', MEMCACHE_SERVERS); + // Bro Got Servers or Nah?! + if (!count($servers)) throw new Exception('MEMCACHE_SERVERS must be defined'); - $servers = explode(',', MEMCACHE_SERVERS); - $this->memcache = new Memcache(); - - foreach ($servers as $S) { - @list($host, $port) = explode(':', $S); - if (strpos($host, '/') !== false) - // Use port '0' for unix sockets - $port = 0; - else - $port = $port ?: ini_get('memcache.default_port') ?: 11211; - $this->servers[] = array(trim($host), (int) trim($port)); - // FIXME: Crash or warn if invalid $host or $port - } + $options['servers'] = $servers; + parent::__construct($options); } - function getKey($id) { - return sha1($id.SECRET_SALT); + public function saveRecord($record, $secondary = false) { + if ($secondary || !is_a($record, 'MemcacheSessionRecord')) { + if (!$record->isNew()) + return true; + } + return $this->writeRecord($record->getId(), $record->getData()); } - function read($id) { - $key = $this->getKey($id); - - // Try distributed read first - foreach ($this->servers as $S) { - list($host, $port) = $S; - $this->memcache->addServer($host, $port); - } - $data = $this->memcache->get($key); - - // Read from other servers on failure - if ($data === false && count($this->servers) > 1) { - foreach ($this->servers as $S) { - list($host, $port) = $S; - $this->memcache->pconnect($host, $port); - if ($data = $this->memcache->get($key)) - break; - } - - } - - // No session data on record -- new session - $this->isnew = $data === false; - - return $data ?: ''; + public function writeRecord($id, $data) { + return $this->set($id, $data); } - function update($id, $data) { - if (defined('DISABLE_SESSION') && $this->isnew) - return true; - - $key = $this->getKey($id); - foreach ($this->servers as $S) { - list($host, $port) = $S; - $this->memcache->pconnect($host, $port); - if (!$this->memcache->replace($key, $data, 0, $this->getTTL())); - $this->memcache->set($key, $data, 0, $this->getTTL()); - } - - return true; - - } - - function destroy($id) { - $key = $this->getKey($id); - foreach ($this->servers as $S) { - list($host, $port) = $S; - $this->memcache->pconnect($host, $port); - $this->memcache->replace($key, '', 0, 1); - $this->memcache->delete($key, 0); - } - - return true; - } - - function gc($maxlife) { - // Memcache does this automatically + public function lookupRecord($id, $autocreate = false) { + if (!($data = $this->get($id)) && !$autocreate) + return false; + return new MemcacheSessionRecord([ + 'id' => $id, + 'new' => $data === false, + 'data' => $data, + ], $this); } } -class NoopSessionBackend extends SessionBackend { - public function read($id) { - return ""; +class MemcacheSessionRecord +implements osTicket\Session\SessionRecordInterface { + private $backend; + private $data; + + public function __construct(array $ht, $backend) { + // Make sure we have an id + if (!isset($ht['id']) || !$ht['id']) + throw new Exception('Session id required'); + // Set backend + $this->backend = $backend; + // Set the data as array object + $this->data = new ArrayObject($ht, ArrayObject::ARRAY_AS_PROPS); } - public function update($id, $data) { + public function isNew() { + return ($this->data->new); + } + + public function isValid() { + return (strlen($this->getId()) == 32); + } + + public function getId() { + return $this->data->id; + } + + public function setId(string $id) { + $this->data->id = $id; + } + + public function getData() { + return $this->data->data; + } + + public function setData(string $data = null) { + $this->data->data = $data; + } + + public function setTTl(int $ttl) { + $this->data->ttl = $ttl; + } + + // Unsupported call but required + public function expire(int $ttl) { return true; } - public function destroy($id) { - return true; + public function commit() { + return $this->backend + ? $this->backend->saveRecord($this) + : false; } - public function gc($maxlife) { - return true; + public function toArray() { + return [ + 'id' => $this->getId(), + 'data' => $this->getData(), + ]; + } + + public static function create($vars) { + return new self($vars); } } - -class FallbackSessionBackend { - // Use default PHP settings, with some edits for best experience - function __construct() { - // FIXME: Consider extra possible security tweaks such as adjusting - // the session.save_path - } -} - ?> diff --git a/include/class.usersession.php b/include/class.usersession.php index 2e5d81f27..eac51ba57 100644 --- a/include/class.usersession.php +++ b/include/class.usersession.php @@ -2,10 +2,13 @@ /********************************************************************* class.usersession.php - User (client and staff) sessions handle. + User (client and staff) sessions manager + + User-Space session management, not to confused with Session Storage + Backends. Peter Rotich - Copyright (c) 2006-2013 osTicket + Copyright (c) 2022 osTicket http://www.osticket.com Released under the GNU General Public License WITHOUT ANY WARRANTY. @@ -52,9 +55,12 @@ class UserSession { } function sessionToken(){ + // Please note that user-space token is not meant to be secure at all + // we're simply encoding stuff we want to track as we refresh the + // session. $time = time(); $hash = md5($time.SESSION_SECRET.$this->userID); - $token = "$hash:$time:".MD5($this->ip); + $token = "$hash:$time:".MD5($this->getIP()); return $token; } @@ -66,35 +72,31 @@ class UserSession { return $expire; } - function isvalidSession($htoken,$maxidletime=0,$checkip=false){ - global $cfgi; + function isvalidSession($htoken, $maxidletime=0, $checkip=false){ + global $cfg; // Compare session ids if (strcmp($this->getSessionId(), session_id())) return false; - // Is the session invalidated? - if (isset($_SESSION['KAPUT']) && $_SESSION['KAPUT'] < time()) - return (session_destroy() && false); - $token = rawurldecode($htoken); // Check if we got what we expected.... if ($token && !strstr($token,":")) return false; // Get the goodies - list($hash, $expire, $ip) = explode(":",$token); + list($hash, $expire, $ip) = explode(':', $token); // Make sure the session hash is valid if ((md5($expire . SESSION_SECRET . $this->userID) != $hash)) return false; // is it expired?? - if ($maxidletime && ((time()-$expire)>$maxidletime)) + if ($maxidletime && ((time()-$expire) > $maxidletime)) return false; - #Make sure IP is still same ( proxy access??????) - if ($checkip && strcmp($ip, MD5($this->ip))) + // Make sure IP is still same - if requested + if ($checkip && strcmp($ip, MD5($this->getIP()))) return false; $this->validated = true; @@ -102,54 +104,13 @@ class UserSession { return true; } - function regenerateSession($destroy=false) { - // Delayed kaput time for current session - $_SESSION['KAPUT'] = time() + 60; - // Save the session id as old - $old = session_id(); - // Regenerate the session without destroying data - session_regenerate_id(false); - // Get new session id and close - $new = session_id(); - session_write_close(); - // Start new session - session_id($new); - session_start(); - $this->session_id = $new; - // Make sure new session is not set to KAPUT and TIME_BOMB - unset($_SESSION['KAPUT'], $_SESSION['TIME_BOMB']); - // Destroy ? - if ($destroy) { - // Destrory old session - $this->destroySession($old); - // Restore new session - session_id($new); - session_start(); - } - return true; - } - - function destroySession($id) { - // Close current session - session_write_close(); - // Start target session - session_id($id); - session_start(); - // Destroy session - session_destroy(); - session_write_close(); - return true; - } - function isValid() { return ($this->validated); } - } - trait UserSessionTrait { - // Session Object + // User Session Object var $session; // Session Token var $token; @@ -160,23 +121,55 @@ trait UserSessionTrait { // User class var $class = ''; - function refreshSession($refreshRate=60): void { - // If TIME_BOMB isset and less than the current time we need to regenerate - // session to help mitigate session fixation - if (isset($_SESSION['TIME_BOMB']) && ($_SESSION['TIME_BOMB'] < time())) - $this->regenerateSession(); + + public function getMaxIdleTime() { + return $this->maxidletime ; + } + + function refreshSession($refreshRate=60) { + // Check Time To Die (TTD) if any - OLD people.. I mean sessions, + // must die! Don't fight it bro! + if (isset($_SESSION['TTD']) && $_SESSION['TTD'] < time()) { + error_log(sprintf('Session %s with TTD %s was used', + session_id(), $_SESSION['TTD'])); + return (session_destroy() && false); + } + + // If TIME_BOMB is set and less than the current time we need to regenerate + // session id to help mitigate session fixation attacks. + if (isset($_SESSION['TIME_BOMB']) + && ($_SESSION['TIME_BOMB'] < time()) + && ($id=$this->regenerateSession())) { + // unset timer and et next one 24 hrs later + // TODO: Make regenerate frequency configurable in 2032 /j + // PS: Living and dying and the stories that are true Secrets to + // a good life is knowing when you're through ~ time bomb + $_SESSION['TIME_BOMB'] = time() + 86400; + // set id + $this->session_id = $id; + // Force cookie renewal NOW! + $refreshRate = -1; + } + // Deadband session token updates to once / 30-seconds $updated = $this->session->getLastUpdate($this->token); if ($updated + $refreshRate < time()) { + // Renew the session token $this->token = $this->getSessionToken(); - osTicketSession::renewCookie($updated, $this->maxidletime); + // Update the expire time for the session cookie + osTicketSession::renewCookie(time(), $this->getMaxIdleTime()); } } - function regenerateSession($destroy=false) { - $this->session->regenerateSession($destroy); - // Set cookie for the new session id. - $this->refreshSession(-1); + function regenerateSession(int $ttl = 120) { + // Set TTD (Time To Die) on current session + // If ttl is 0 then session is destroyed immediatetly + $_SESSION['TTD'] = time() + $ttl; // now + ttl + if (($id=osTicketSession::regenerate($ttl))) + $this->session_id = $id; + // unset TTD on the new session - new life my boy! + unset($_SESSION['TTD']); + return $id; } function getSession() { @@ -202,7 +195,7 @@ trait UserSessionTrait { function isValidSession() { return ($this->getId() && $this->session->isvalidSession($this->token, - $this->maxidletime, $this->checkip)); + $this->getMaxIdleTime(), $this->checkip)); } abstract function isValid(); diff --git a/logo.php b/logo.php index d9dadca3b..b23271043 100644 --- a/logo.php +++ b/logo.php @@ -15,9 +15,8 @@ vim: expandtab sw=4 ts=4 sts=4: **********************************************************************/ -// Disable updating session data - false still starts the session but data -// write is ignored. -define('DISABLE_SESSION', false); +// Use Noop Session Handler +define('NOOP_SESSION', true); require('client.inc.php'); $ttl = 86400; // max-age if (($logo = $ost->getConfig()->getClientLogo())) { diff --git a/logout.php b/logout.php index 9fb8037ac..f559f7748 100644 --- a/logout.php +++ b/logout.php @@ -20,6 +20,6 @@ if ($thisclient && $_GET['auth'] && $ost->validateLinkToken($_GET['auth'])) $thisclient->logOut(); osTicketSession::destroyCookie(); - +session_destroy(); Http::redirect('index.php'); ?> diff --git a/scp/logo.php b/scp/logo.php index 085263992..983e57292 100644 --- a/scp/logo.php +++ b/scp/logo.php @@ -15,9 +15,8 @@ vim: expandtab sw=4 ts=4 sts=4: **********************************************************************/ -// Disable updating session data - false still starts the session but data -// write is ignored. -define('DISABLE_SESSION', false); +// Use Noop Session Handler +define('NOOP_SESSION', true); require_once('../main.inc.php'); $ttl = 86400; // max-age if (isset($_GET['backdrop'])) { diff --git a/scp/logout.php b/scp/logout.php index a40079026..8a5c3c23b 100644 --- a/scp/logout.php +++ b/scp/logout.php @@ -22,15 +22,9 @@ if(!$_GET['auth'] || !$ost->validateLinkToken($_GET['auth'])) try { $thisstaff->logOut(); - - //Destroy session on logout. - // TODO: Stop doing this starting with 1.9 - separate session data per - // app/panel. session_unset(); - session_destroy(); - osTicketSession::destroyCookie(); - + session_destroy(); //Clear any ticket locks the staff has. Lock::removeStaffLocks($thisstaff->getId()); } diff --git a/scp/staff.inc.php b/scp/staff.inc.php index b794003d1..d0ea2fe9e 100644 --- a/scp/staff.inc.php +++ b/scp/staff.inc.php @@ -96,6 +96,9 @@ if(!$thisstaff->isAdmin()) { exit; } } +/******* SET STAFF DEFAULTS **********/ +define('PAGE_LIMIT', $thisstaff->getPageLimit() ?: DEFAULT_PAGE_LIMIT); +define('SESSION_MAXLIFE', $thisstaff->getMaxIdleTime()); //Keep the session activity alive $thisstaff->refreshSession(); @@ -113,8 +116,6 @@ $ost->addExtraHeader('getPageLimit() ?: DEFAULT_PAGE_LIMIT); $tabs=array(); $submenu=array();