osticket-docker/osTicket-v1.18.2/include/class.ostsession.php

684 lines
22 KiB
PHP

<?php
/*********************************************************************
class.ostsession.php
osTicket Session Management Backend
Peter Rotich <peter@osticket.com>
Copyright (c) 2022 osTicket
http://www.osticket.com
Released under the GNU General Public License WITHOUT ANY WARRANTY.
See LICENSE.TXT for details.
vim: expandtab sw=4 ts=4 sts=4:
**********************************************************************/
include_once INCLUDE_DIR.'class.session.php';
class osTicketSession {
// Session SSID
private $name = 'OSTSESSID';
// Session Backend instance
private $backend;
// Session default TTL
private $ttl;
function __construct($name, $ttl = SESSION_TTL, $checkdbversion = false) {
// session name/ssid
if ($name && strcmp($this->name, $name))
$this->name = $name;
$maxlife = ini_get('session.gc_maxlifetime');
$this->ttl = $ttl ?: ($maxlife ?: SESSION_TTL);
// Set osTicket specific session name/sessid
session_name($this->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 **/
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'),
'callbacks' => $this->getCallbacks($bk),
];
// 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 $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?
}
// Finally start the damn session.
session_start();
}
// returns session callbacks we might be interested in monitoring
private function getCallbacks($bk) {
return [
// see onClose routine for details
'close' => [$this, 'onClose']
];
}
// onClose - is used to signal those interested on changing session
// data, to do so, before data is commited.
public function onClose($handler) {
$i = new ArrayObject(['touched' => false]);
Signal::send('session.close', null, $i);
return (bool) $i['touched'];
}
/*
* 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
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, int $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);
}
// Aks the backend to destroy a session now
static function destroy($id) {
// Expire with ttl of 0 destroys the session
return (bool) self::expire($id, 0);
}
// Cleanup Expired Sessions
static function cleanup() {
// get active backend
if (!($backend=self::registered_backend()))
return false;
return (bool) $backend->cleanup();
}
static function renewCookie($baseTime=false, $window=false) {
global $ost;
$ttl = $window ?: SESSION_TTL;
$expire = ($baseTime ?: time()) + $ttl;
$opts = [
'expires' => $expire,
'path' => ini_get('session.cookie_path'),
'domain' => ini_get('session.cookie_domain'),
'secure' => ini_get('session.cookie_secure'),
'httponly' => ini_get('session.cookie_httponly'),
'samesite' => !empty($ost->getConfig()->getAllowIframes()) ? 'None' : 'Strict'
];
setcookie(session_name(), session_id(), $opts);
// Trigger expire update - neeed for secondary handlers that only
// log new sessions
self::expire(session_id(), $ttl);
}
static function destroyCookie() {
setcookie(session_name(), 'deleted', 1,
ini_get('session.cookie_path'),
ini_get('session.cookie_domain'),
ini_get('session.cookie_secure'),
ini_get('session.cookie_httponly'));
}
static function get_online_users(int $seconds = 0) {
// Authoretative 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 start($name, $ttl=0, $checkdbversion=false) {
return new static($name, $ttl, $checkdbversion);
}
}
/*
* 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'),
);
public function isNew() {
return $this->__new__;
}
public function isValid() {
// NOTE: We're not enforcing IP match here because it's a user space
// setting and checked elsewhere
// User Agent must remain same for the lifecycle of the session
if (isset($this->user_agent)
&& strcmp($_SERVER['HTTP_USER_AGENT'], $this->user_agent) !== 0)
return false;
// Make sure id len is 32
return (strlen($this->getId()) == 32);
}
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 {
$record = self::objects()
->filter(['session_id' => $id])
->annotate(array('is_expired' =>
new SqlExpr(new Q(array('session_expire__lt' => SqlFunction::NOW())))))
->one();
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
$record->session_data = '';
}
}
catch (DoesNotExist $e) {
// We're auto-creating model (unsaved) when one doesn't exist?
$record = $autocreate ? self::create($id) : null;
}
catch (OrmException | Exception $ex) {
// This could happen if more than one record exits in the
// database for example.
}
return $record;
}
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')));
// 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',
]));
// 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;
}
static function destroy($id) {
return ( (bool) self::objects()
->filter(['session_id' => $id])
->delete());
}
static function cleanupExpired() {
return self::objects()->filter([
'session_expire__lte' => SqlFunction::NOW()
])->delete();
}
static function user_sessions(int $id) {
$criteria = ['user_id' => $id];
return self::active_sessions($criteria);
}
static function active_sessions(array $criteria = []) {
$criteria['active'] = true;
return self::sessions($criteria);
}
static function expired_sessions(array $criteria = []) {
$criteria['active'] = false;
return self::sessions($criteria);
}
static function sessions(array $criteria = []) {
// now
$now = SqlFunction::NOW();
// empty filters
$filters = [];
// Active or Expired
if (isset($criteria['active']) && $criteria['active']) {
// Active must not be expired
$filters = ['session_expire__gt' => $now];
} elseif (isset($criteria['active'])) {
// expired session if active is set to false
$filters = ['session_expire__lt' => $now];
}
// Authenticated users have user_id set (only Agents at the moment)
if (isset($criteria['user_id']) && $criteria['user_id'])
$filters['user_id'] = $criteria['user_id'];
elseif (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);
}
}
/*
* Database Session Storage Backend
*
*/
class DatabaseSessionStorageBackend
extends osTicket\Session\AbstractSessionStorageBackend {
public function expireRecord($id, $ttl) {
if (!($record=$this->getRecord($id)))
return false;
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');
$options['servers'] = $servers;
parent::__construct($options);
}
public function saveRecord($record, $secondary = false) {
if ($secondary || !is_a($record, 'MemcacheSessionRecord')) {
if (!$record->isNew())
return true;
}
return $this->writeRecord($record->getId(), $record->getData());
}
public function writeRecord($id, $data) {
return $this->set($id, $data);
}
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 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 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 commit() {
return $this->backend
? $this->backend->saveRecord($this)
: false;
}
public function toArray() {
return [
'id' => $this->getId(),
'data' => $this->getData(),
];
}
public static function create($vars) {
return new self($vars);
}
}
?>