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

579 lines
19 KiB
PHP

<?php
/*********************************************************************
class.session.php
osTicket core Session Handlers & Backends
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:
**********************************************************************/
namespace osTicket\Session {
// Extend global Exception
class Exception extends \Exception {}
// Session backend factory
// TODO: Allow plugins to register backends.
class SessionBackend {
static private $backends = [
// Database is the default backend for osTicket
'database' => ':AbstractSessionStorageBackend',
// Session data are stored in memcache saving database the pain
// of storing and retriving the records
'memcache' => ':AbstractMemcacheSessionStorageBackend',
// Noop/Null SessionHandler doesn't care about sh*t
'noop' => 'NoopSessionStorageBackend',
// Default SessionHandler
'system' => 'SystemSessionHandler',
];
static public function getBackends() {
return self::$backends;
}
static public function getBackend(string $bk) {
return (($backends=self::getBackends())
&& isset($backends[$bk]))
? $backends[$bk]
: null;
}
static public function has(string $bk) {
return (self::getBackend($bk));
}
static public function factory(string $bk, array $options = []) {
list ($bk, $handler, $secondary) = explode(':', $bk);
if (!($backend=self::getBackend($bk)))
throw new Exception('Unknown Session Backend: '.$bk);
if ($backend[0] == ':') {
// External implementation required
if (!$handler)
throw new Exception(sprintf(
'%s: requires storage backend driver',
$bk));
// External handler needs global namespacing
$handler ="\\$handler";
// handler must implement parent
$impl = substr($backend, 1);
} elseif ($handler) {
$handler ="\\$handler";
} else {
// local handler is being used force this namespace
$handler = sprintf('%s\%s', __NAMESPACE__, $backend);
$impl = ($bk == 'system') ? 'SystemSessionHandler' : 'AbstractSessionHandler';
}
// Make sure handler / backend class exits
if (!$handler
|| !class_exists($handler))
throw new Exception(sprintf('unknown storage backend - [%s]',
$handler));
// Set secondary backend with global namespacing if it is
// chained to primary backend ($bk). Primary backend will
// validate and instanciate it if it can support the interface
// of the said backend.
if ($secondary)
$options['secondary'] = "\\$secondary";
$sessionHandler = new $handler($options);
// Make sure the handler implements the right interface
$impl = sprintf('%s\%s', __NAMESPACE__, $impl);
if ($impl && !is_a($sessionHandler, $impl))
throw new Exception(sprintf('%s: must implement %s',
$handler, $impl));
return $sessionHandler;
}
static public function register(string $bk, array $options = [], bool
$register_shutdown = true) {
if (($handler=self::factory($bk, $options))
&& session_set_save_handler($handler,
$register_shutdown))
return $handler;
}
}
interface SessionRecordInterface {
// Basic setters and getters
function getId();
function setId(string $id);
function getData();
function setData(string $data);
function setTTL(int $ttl);
// expire in ttl & commit
function expire(int $ttl);
// commit / save the record to storage
function commit();
// Checkers
function isNew();
function isValid();
// to array [id, data] expected - other attributes can be returned
// as well dependin on the storage backend
function toArray();
}
abstract class AbstractSessionHandler implements
\SessionHandlerInterface,
\SessionUpdateTimestampHandlerInterface {
// Options
protected $options;
// Callback registry
private $callbacks;
// Flags
private $isnew = false;
private $isapi = false;
// maxlife & ttl
private $ttl;
private $maxlife;
// Secondary backend
protected $secondary = null;
public function __construct($options = []) {
// Set flags based on passed in options
$this->options = $options;
// Default TTL
$this->ttl = $this->options['session_ttl'] ??
ini_get('session.gc_maxlifetime');
// Dynamic maxlife (MLT)
if (isset($this->options['session_maxlife']))
$this->maxlife = $this->options['session_maxlife'];
// API Session Flag
if (isset($this->options['api_session']))
$this->isapi = $this->options['api_session'];
// callbacks
if (isset($options['callbacks'])) {
$this->callbacks = $options['callbacks'];
unset($options['callbacks']);
}
// Set Secondary Backend if any
if (isset($options['secondary']))
$this->setSecondaryBackend($options['secondary']);
}
/*
* set a seconday backend... for now it's private but will be public
* down the road.
*/
private function setSecondaryBackend($backend, $options = null) {
// Ignore invalid backend
if (!$backend
// class exists
|| !class_exists($backend)
// Not same backend as handler
|| !strcasecmp(get_class($this), $backend))
return false;
// Init Secondary handler if set and valid
$options = $options ?? $this->options;
unset($options['secondary']); //unset secondary to avoid loop.
$this->secondary = new $backend($options);
// Make sure it's truly a storage backend and not a Ye!
if (!is_a($this->secondary,
__NAMESPACE__.'\AbstractSessionStorageBackend'))
$this->secondary = null; // nah...
return ($this->secondary);
}
/*
* API Sessions are Stateless and new sessions shouldn't be created
* when this flag is turned on.
*
*/
protected function isApiSession() {
return ($this->isapi);
}
/*
* onEvent
*
* Storage Backends that's interested in monitoring / reporting on
* events can implement this routine.
*
*/
protected function onEvent($event) {
return false;
}
// Handles callback for registered event listeners
protected function callbackOn($event) {
if (!$this->callbacks
|| !isset($this->callbacks[$event]))
return false;
return (($collable=$this->callbacks[$event])
&& is_callable($collable))
? call_user_func($collable, $this)
: false;
}
/*
* pre_save
*
* This is a hook called by session storage backends before saving a
* session record.
*
*/
public function pre_save(SessionRecordInterface $record) {
// We're NOT creating new API Sessions since API is stateless.
// However existing sessions are updated, allowing for External
// Authentication / Authorization to be processed via API endpoints.
if ($record->isNew() && $this->isApiSession())
return false;
return true;
}
/*
* Default osTicket TTL is the default Session's Maxlife
*/
public function getTTL() {
return $this->ttl;
}
/*
* Maxlife is based on logged in user (if any)
*
*/
public function getMaxlife() {
// Prefer maxlife defined based on settings for the
// current session user - otherwise use default ttl
if (!isset($this->maxlife))
$this->maxlife = (defined('SESSION_MAXLIFE')
&& is_numeric(SESSION_MAXLIFE))
? SESSION_MAXLIFE : $this->getTTL();
return $this->maxlife;
}
public function setMaxLife(int $maxlife) {
$this->maxlife = $maxlife;
}
public function open($save_path, $session_name) {
return true;
}
public function close() {
return true;
}
public function write($id, $data) {
// Last chance session update - returns true if update is done
if ($this->onEvent('close'))
$data = session_encode();
return $this->update($id, $data);
}
public function validateId($id) {
return true;
}
public function updateTimestamp($id, $data) {
return true;
}
public function gc($maxlife) {
$this->cleanup();
}
abstract function read($id);
abstract function update($id, $data);
abstract function expire($id, $ttl);
abstract function destroy($id);
abstract function cleanup();
}
abstract class AbstractSessionStorageBackend extends AbstractSessionHandler {
// Record we cache between read & update/write
private $record;
protected function onEvent($event) {
return $this->callbackOn($event);
}
public function getRecord($id, $autocreate = false) {
if (!isset($this->record)
|| !is_object($this->record)
// Mismatch here means new session id
|| strcmp($id, $this->record->getId()))
$this->record = static::lookupRecord($id, $autocreate, $this);
return $this->record;
}
// This is the wrapper for to ask the backend to lookup or
// create/init a record when not found
protected function getRecordOrCreate($id) {
return $this->getRecord($id, true);
}
public function read($id) {
// we're auto creating the record if it doesn't exist so we can
// have a new cached recoed on write/update.
return (($record = $this->getRecordOrCreate($id))
&& !$record->isNew())
? $record->getData()
: '';
}
public function update($id, $data) {
if (!($record = $this->getRecord($id)))
return false;
// Upstream backend can overwrite saving the record via pre_save hook
// depending on the type of session or class of user etc.
if ($this->pre_save($record) === false)
return true; // record is being ignored
// Set id & data
$record->setId($id);
$record->setData($data);
// Ask backend to save the record
if (!$this->saveRecord($record))
return false;
// See if we need to send the record to secondary backend to
// send the record to for logging or audit reasons or whatever!
try {
if (isset($this->secondary))
$this->secondary->saveRecord($record, true);
} catch (\Trowable $t) {
// Ignore any BS!
}
// clear cache
$this->record = null;
return true;
}
public function expire($id, $ttl) {
// Destroy session record if expire is now.
if ($ttl == 0)
return $this->destroy($id);
if (!$this->expireRecord($id, $ttl))
return false;
try {
if (isset($this->secondary))
$this->secondary->expireRecord($id, $ttl);
} catch (\Trowable $t) {
// Ignore any BS!
}
return true;
}
public function destroy($id) {
if (!($this->destroyRecord($id)))
return false;
try {
if (isset($this->secondary))
$this->secondary->destroyRecord($id);
} catch (\Trowable $t) {
// Ignore any BS!
}
return true;
}
public function cleanup() {
$this->cleanupExpiredRecords();
try {
if (isset($this->secondary))
$this->secondary->cleanupExpiredRecords();
} catch (\Trowable $t) {
// Ignore any BS!
}
return true;
}
// Backend must implement lookup method to return a record that
// implements SessionRecordInterface.
abstract function lookupRecord($id, $autocreate);
// save record
abstract function saveRecord($record, $secondary = false);
// writeRecord is useful when replicating records without the need
// to transcode it when backends are different
abstract function writeRecord($id, $data);
// expireRecord
abstract function expireRecord($id, $ttl);
// Backend should implement destroyRecord that takes $id to avoid
// the need to do record lookup
abstract function destroyRecord($id);
// Clear expired records - backend knows best how
abstract function cleanupExpiredRecords();
}
abstract class AbstractMemcacheSessionStorageBackend
extends AbstractSessionStorageBackend {
private $memcache;
private $servers = [];
public function __construct($options) {
parent::__construct($options);
// Make sure we have memcache module installed
if (!extension_loaded('memcache'))
throw new Exception('Memcache extension is missing');
// Require servers to be defined
if (!isset($options['servers'])
|| !is_array($options['servers'])
|| !count($options['servers']))
throw new Exception('Memcache severs required');
// Init Memchache module
$this->memcache = new \Memcache();
// Add servers
foreach ($options['servers'] as $server) {
list($host, $port) = explode(':', $server);
// Use port '0' for unix sockets
if (strpos($host, '/') !== false)
$port = 0;
elseif (!$port)
$port = ini_get('memcache.default_port') ?: 11211;
$this->addServer(trim($host), (int) trim($port));
}
if (!$this->getNumServers())
throw new Exception('At least one memcache severs required');
// Init Memchache module
$this->memcache = new \Memcache();
}
protected function addServer($host, $port) {
// TODO: Support add options
// FIXME: Crash or warn if invalid $host or $port
// Cache Servers locally
$this->servers[] = [$host, $port];
// Add Server
$this->memcache->addServer($host, $port);
}
protected function getNumServers() {
return count($this->getServers());
}
protected function getServers() {
return $this->servers;
}
protected function getKey($id) {
return sha1($id.SECRET_SALT);
}
protected function get($id) {
// get key
$key = $this->getKey($id);
// Attempt distributed read
$data = $this->memcache->get($key);
// Read from other servers on failure
if ($data === false
&& $this->getNumServers()) {
foreach ($this->getServers() as $server) {
list($host, $port) = $server;
$this->memcache->pconnect($host, $port);
if ($data = $this->memcache->get($key))
break;
}
}
return $data;
}
protected function set($id, $data) {
// Since memchache takes care of carbage collection internally
// we want to make sure we set data to expire based on the session's
// maxidletime (if available) otherwise it defailts to SESSION_TTL.
// Memchache has a maximum ttl of 30 days
$ttl = min($this->getMaxlife(), 2592000);
$key = $this->getKey($id);
foreach ($this->getServers() as $server) {
list($host, $port) = $server;
$this->memcache->pconnect($host, $port);
if (!$this->memcache->replace($key, $data, 0, $ttl))
$this->memcache->set($key, $data, 0, $ttl);
}
// FIXME: Return false if we fail to write to at least one server
return true;
}
public function expireRecord($id, $ttl) {
return true;
}
public function destroyRecord($id) {
$key = $this->getKey($id);
foreach ($this->getServers() as $server) {
list($host, $port) = $server;
$this->memcache->pconnect($host, $port);
$this->memcache->replace($key, '', 0, 1);
$this->memcache->delete($key, 0);
}
return true;
}
public function cleanupExpiredRecords() {
// Memcache does this automatically
return true;
}
abstract function writeRecord($id, $data);
abstract function saveRecord($record, $secondary = false);
}
/*
* NoopSessionHandler
*
* Use this session handler when you don't care about session data.
*
*/
class NoopSessionStorageBackend extends AbstractSessionHandler {
public function read($id) {
return "";
}
public function update($id, $data) {
return true;
}
public function expire($id, $ttl) {
return true;
}
public function destroy($id) {
return true;
}
public function gc($maxlife) {
return true;
}
public function cleanup() {
return true;
}
}
// Delegate everything to PHP Default SessionHandler
class SystemSessionHandler extends \SessionHandler {
public function __construct() {
}
}
}