<?php
/**
* Part of the Joomla Framework Session Package
*
* @copyright Copyright (C) 2005 - 2021 Open Source Matters, Inc. All rights reserved.
* @license GNU General Public License version 2 or later; see LICENSE
*/
namespace Joomla\Session;
use Joomla\Event\DispatcherAwareInterface;
use Joomla\Event\DispatcherAwareTrait;
use Joomla\Event\DispatcherInterface;
/**
* Class for managing HTTP sessions
*
* Provides access to session-state values as well as session-level settings and lifetime management methods.
* Based on the standard PHP session handling mechanism it provides more advanced features such as expire timeouts.
*
* @since 1.0
*/
class Session implements SessionInterface, DispatcherAwareInterface
{
use DispatcherAwareTrait;
/**
* Internal session state.
*
* @var string
* @since 1.0
*/
protected $state = SessionState::INACTIVE;
/**
* Maximum age of unused session in seconds.
*
* @var integer
* @since 1.0
*/
protected $expire = 900;
/**
* The session store object.
*
* @var StorageInterface
* @since 1.0
*/
protected $store;
/**
* Container holding session validators.
*
* @var ValidatorInterface[]
* @since 2.0.0
*/
protected $sessionValidators = [];
/**
* Constructor
*
* @param StorageInterface $store A StorageInterface implementation.
* @param DispatcherInterface $dispatcher DispatcherInterface for the session to use.
* @param array $options Optional parameters. Supported keys include:
* - name: The session name
* - id: The session ID
* - expire: The session lifetime in seconds
*
* @since 1.0
*/
public function __construct(StorageInterface $store = null, DispatcherInterface $dispatcher = null, array $options = [])
{
$this->store = $store ?: new Storage\NativeStorage(new Handler\FilesystemHandler);
if ($dispatcher)
{
$this->setDispatcher($dispatcher);
}
$this->setOptions($options);
$this->setState(SessionState::INACTIVE);
}
/**
* Adds a validator to the session
*
* @param ValidatorInterface $validator The session validator
*
* @return void
*
* @since 2.0.0
*/
public function addValidator(ValidatorInterface $validator): void
{
$this->sessionValidators[] = $validator;
}
/**
* Get expiration time in seconds
*
* @return integer The session expiration time in seconds
*
* @since 1.0
*/
public function getExpire()
{
return $this->expire;
}
/**
* Get current state of session
*
* @return string The session state
*
* @since 1.0
*/
public function getState()
{
return $this->state;
}
/**
* Get a session token.
*
* Tokens are used to secure forms from spamming attacks. Once a token has been generated the system will check the request to see if
* it is present, if not it will invalidate the session.
*
* @param boolean $forceNew If true, forces a new token to be created
*
* @return string
*
* @since 1.0
*/
public function getToken($forceNew = false)
{
// Ensure the session token exists and create it if necessary
if (!$this->has('session.token') || $forceNew)
{
$this->set('session.token', $this->createToken());
}
return $this->get('session.token');
}
/**
* Check if the session has the given token.
*
* @param string $token Hashed token to be verified
* @param boolean $forceExpire If true, expires the session
*
* @return boolean
*
* @since 1.0
*/
public function hasToken($token, $forceExpire = true)
{
$result = $this->get('session.token') === $token;
if (!$result && $forceExpire)
{
$this->setState(SessionState::EXPIRED);
}
return $result;
}
/**
* Retrieve an external iterator.
*
* @return \ArrayIterator Return an ArrayIterator of $_SESSION.
*
* @since 1.0
*/
public function getIterator()
{
return new \ArrayIterator($this->all());
}
/**
* Get session name
*
* @return string The session name
*
* @since 1.0
*/
public function getName()
{
return $this->store->getName();
}
/**
* Set the session name
*
* @param string $name The session name
*
* @return $this
*
* @since 2.0.0
*/
public function setName(string $name)
{
$this->store->setName($name);
return $this;
}
/**
* Get session id
*
* @return string The session id
*
* @since 1.0
*/
public function getId()
{
return $this->store->getId();
}
/**
* Set the session ID
*
* @param string $id The session ID
*
* @return $this
*
* @since 2.0.0
*/
public function setId(string $id)
{
$this->store->setId($id);
return $this;
}
/**
* Check if the session is active
*
* @return boolean
*
* @since 1.0
*/
public function isActive()
{
if ($this->getState() === SessionState::ACTIVE)
{
return $this->store->isActive();
}
return false;
}
/**
* Check whether this session is currently created
*
* @return boolean
*
* @since 1.0
*/
public function isNew()
{
$counter = $this->get('session.counter');
return $counter === 1;
}
/**
* Check if the session is started
*
* @return boolean
*
* @since 2.0.0
*/
public function isStarted(): bool
{
return $this->store->isStarted();
}
/**
* Get data from the session store
*
* @param string $name Name of a variable
* @param mixed $default Default value of a variable if not set
*
* @return mixed Value of a variable
*
* @since 1.0
*/
public function get($name, $default = null)
{
if (!$this->isActive())
{
$this->start();
}
return $this->store->get($name, $default);
}
/**
* Set data into the session store.
*
* @param string $name Name of a variable.
* @param mixed $value Value of a variable.
*
* @return mixed Old value of a variable.
*
* @since 1.0
*/
public function set($name, $value = null)
{
if (!$this->isActive())
{
$this->start();
}
return $this->store->set($name, $value);
}
/**
* Check whether data exists in the session store
*
* @param string $name Name of variable
*
* @return boolean True if the variable exists
*
* @since 1.0
*/
public function has($name)
{
if (!$this->isActive())
{
$this->start();
}
return $this->store->has($name);
}
/**
* Unset a variable from the session store
*
* @param string $name Name of variable
*
* @return mixed The value from session or NULL if not set
*
* @since 2.0.0
*/
public function remove(string $name)
{
if (!$this->isActive())
{
$this->start();
}
return $this->store->remove($name);
}
/**
* Clears all variables from the session store
*
* @return void
*
* @since 1.0
*/
public function clear()
{
if (!$this->isActive())
{
$this->start();
}
$this->store->clear();
}
/**
* Retrieves all variables from the session store
*
* @return array
*
* @since 2.0.0
*/
public function all(): array
{
if (!$this->isActive())
{
$this->start();
}
return $this->store->all();
}
/**
* Start a session.
*
* @return void
*
* @since 1.0
*/
public function start()
{
if ($this->isStarted())
{
return;
}
$this->store->start();
$this->setState(SessionState::ACTIVE);
// Initialise the session
$this->setCounter();
$this->setTimers();
// Perform security checks
if (!$this->validate())
{
// If the session isn't valid because it expired try to restart it or destroy it.
if ($this->getState() === SessionState::EXPIRED)
{
$this->restart();
}
else
{
$this->destroy();
}
}
if ($this->dispatcher)
{
if (!empty($this->dispatcher->getListeners('onAfterSessionStart')))
{
trigger_deprecation(
'joomla/session',
'2.0.0',
'The `onAfterSessionStart` event is deprecated and will be removed in 3.0, use the %s::START event instead.',
SessionEvents::class
);
// Dispatch deprecated event
$this->dispatcher->dispatch('onAfterSessionStart', new SessionEvent('onAfterSessionStart', $this));
}
// Dispatch new event
$this->dispatcher->dispatch(SessionEvents::START, new SessionEvent(SessionEvents::START, $this));
}
}
/**
* Frees all session variables and destroys all data registered to a session
*
* This method resets the $_SESSION variable and destroys all of the data associated
* with the current session in its storage (file or DB). It forces new session to be
* started after this method is called.
*
* @return boolean
*
* @see session_destroy()
* @see session_unset()
* @since 1.0
*/
public function destroy()
{
// Session was already destroyed
if ($this->getState() === SessionState::DESTROYED)
{
return true;
}
$this->clear();
$this->fork(true);
$this->setState(SessionState::DESTROYED);
return true;
}
/**
* Restart an expired or locked session.
*
* @return boolean True on success
*
* @see destroy
* @since 1.0
*/
public function restart()
{
// Backup existing session data
$data = $this->all();
$this->destroy();
if ($this->getState() !== SessionState::DESTROYED)
{
// @TODO :: generated error here
return false;
}
// Restart the session
$this->store->start();
$this->setState(SessionState::ACTIVE);
// Initialise the session
$this->setCounter();
$this->setTimers();
// Restore the data
foreach ($data as $key => $value)
{
$this->set($key, $value);
}
// If the restarted session cannot be validated then it will be destroyed
if (!$this->validate(true))
{
$this->destroy();
}
if ($this->dispatcher)
{
if (!empty($this->dispatcher->getListeners('onAfterSessionRestart')))
{
trigger_deprecation(
'joomla/session',
'2.0.0',
'The `onAfterSessionRestart` event is deprecated and will be removed in 3.0, use the %s::RESTART event instead.',
SessionEvents::class
);
// Dispatch deprecated event
$this->dispatcher->dispatch('onAfterSessionRestart', new SessionEvent('onAfterSessionRestart', $this));
}
// Dispatch new event
$this->dispatcher->dispatch(SessionEvents::RESTART, new SessionEvent(SessionEvents::RESTART, $this));
}
return true;
}
/**
* Create a new session and copy variables from the old one
*
* @param boolean $destroy Whether to delete the old session or leave it to garbage collection.
*
* @return boolean True on success
*
* @since 1.0
*/
public function fork($destroy = false)
{
$result = $this->store->regenerate($destroy);
if ($result)
{
$this->setTimers();
}
return $result;
}
/**
* Writes session data and ends session
*
* Session data is usually stored after your script terminated without the need
* to call {@link Session::close()}, but as session data is locked to prevent concurrent
* writes only one script may operate on a session at any time. When using
* framesets together with sessions you will experience the frames loading one
* by one due to this locking. You can reduce the time needed to load all the
* frames by ending the session as soon as all changes to session variables are
* done.
*
* @return void
*
* @see session_write_close()
* @since 1.0
*/
public function close()
{
$this->store->close();
$this->setState(SessionState::CLOSED);
}
/**
* Perform session data garbage collection
*
* @return integer|boolean Number of deleted sessions on success or boolean false on failure or if the function is unsupported
*
* @see session_gc()
* @since 2.0.0
*/
public function gc()
{
if (!$this->isActive())
{
$this->start();
}
return $this->store->gc();
}
/**
* Aborts the current session
*
* @return boolean
*
* @see session_abort()
* @since 2.0.0
*/
public function abort(): bool
{
if (!$this->isActive())
{
return true;
}
return $this->store->abort();
}
/**
* Create a token string
*
* @return string
*
* @since 1.3.1
*/
protected function createToken(): string
{
/*
* We are returning a 32 character string.
* The bin2hex() function will double the length of the hexadecimal value returned by random_bytes(),
* so generate the token from a 16 byte random value
*/
return bin2hex(random_bytes(16));
}
/**
* Set counter of session usage
*
* @return boolean True on success
*
* @since 1.0
*/
protected function setCounter()
{
$counter = $this->get('session.counter', 0);
$counter++;
$this->set('session.counter', $counter);
return true;
}
/**
* Set the session expiration
*
* @param integer $expire Maximum age of unused session in seconds
*
* @return $this
*
* @since 1.3.0
*/
protected function setExpire($expire)
{
$this->expire = $expire;
return $this;
}
/**
* Set the session state
*
* @param string $state Internal state
*
* @return $this
*
* @since 1.3.0
*/
protected function setState($state)
{
$this->state = $state;
return $this;
}
/**
* Set the session timers
*
* @return boolean True on success
*
* @since 1.0
*/
protected function setTimers()
{
if (!$this->has('session.timer.start'))
{
$start = time();
$this->set('session.timer.start', $start);
$this->set('session.timer.last', $start);
$this->set('session.timer.now', $start);
}
$this->set('session.timer.last', $this->get('session.timer.now'));
$this->set('session.timer.now', time());
return true;
}
/**
* Set additional session options
*
* @param array $options List of parameter
*
* @return boolean True on success
*
* @since 1.0
*/
protected function setOptions(array $options)
{
// Set name
if (isset($options['name']))
{
$this->setName($options['name']);
}
// Set id
if (isset($options['id']))
{
$this->setId($options['id']);
}
// Set expire time
if (isset($options['expire']))
{
$this->setExpire($options['expire']);
}
// Sync the session maxlifetime
if (!headers_sent())
{
ini_set('session.gc_maxlifetime', $this->getExpire());
}
return true;
}
/**
* Do some checks for security reasons
*
* If one check fails, session data has to be cleaned.
*
* @param boolean $restart Reactivate session
*
* @return boolean True on success
*
* @see http://shiflett.org/articles/the-truth-about-sessions
* @since 1.0
*/
protected function validate($restart = false)
{
// Allow to restart a session
if ($restart)
{
$this->setState(SessionState::ACTIVE);
}
// Check if session has expired
if ($this->expire)
{
$curTime = $this->get('session.timer.now', 0);
$maxTime = $this->get('session.timer.last', 0) + $this->expire;
// Empty session variables
if ($maxTime < $curTime)
{
$this->setState(SessionState::EXPIRED);
return false;
}
}
try
{
foreach ($this->sessionValidators as $validator)
{
$validator->validate($restart);
}
}
catch (Exception\InvalidSessionException $e)
{
$this->setState(SessionState::ERROR);
return false;
}
return true;
}
}