579 lines
19 KiB
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() {
|
|
}
|
|
}
|
|
}
|