TYPO3 v15 dev-main snapshot ()
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Session\Backend;
|
||||
|
||||
use Doctrine\DBAL\Exception as DBALException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
|
||||
use TYPO3\CMS\Core\Crypto\HashAlgo;
|
||||
use TYPO3\CMS\Core\Crypto\HashService;
|
||||
use TYPO3\CMS\Core\Database\Connection;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
|
||||
use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotCreatedException;
|
||||
use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotFoundException;
|
||||
use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotUpdatedException;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* This session backend requires the 'table' configuration option. If the backend is used to holds non-authenticated
|
||||
* sessions (default in frontend application), the 'ses_userid' configuration option must be set to `0`.
|
||||
*/
|
||||
#[Autoconfigure(public: true, shared: false)]
|
||||
class DatabaseSessionBackend implements SessionBackendInterface, HashableSessionBackendInterface
|
||||
{
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $configuration = [];
|
||||
|
||||
/**
|
||||
* @var bool Indicates whether the ses_userid is set to `0` in the sessions table
|
||||
*/
|
||||
protected $hasAnonymousSessions = false;
|
||||
|
||||
public function __construct(
|
||||
private readonly ConnectionPool $connectionPool,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Initializes the session backend
|
||||
*
|
||||
* @param string $identifier Name of the session type, e.g. FE or BE
|
||||
* @internal To be used only by SessionManager
|
||||
*/
|
||||
public function initialize(string $identifier, array $configuration)
|
||||
{
|
||||
$this->hasAnonymousSessions = (bool)($configuration['has_anonymous'] ?? false);
|
||||
$this->configuration = $configuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the configuration is valid
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
* @internal To be used only by SessionManager
|
||||
*/
|
||||
public function validateConfiguration(): bool
|
||||
{
|
||||
if (empty($this->configuration['table'])) {
|
||||
throw new \InvalidArgumentException(
|
||||
'The session backend "' . static::class . '" needs a "table" configuration.',
|
||||
1442996707
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function hash(string $sessionId): string
|
||||
{
|
||||
return GeneralUtility::makeInstance(HashService::class)
|
||||
->hmac($sessionId, 'core-session-backend', HashAlgo::SHA3_256);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read session data
|
||||
*
|
||||
* @return array Returns the session data
|
||||
* @throws SessionNotFoundException
|
||||
*/
|
||||
public function get(string $sessionId): array
|
||||
{
|
||||
$query = $this->getQueryBuilder();
|
||||
$query->select('*')
|
||||
->from($this->configuration['table'])
|
||||
->where($query->expr()->eq('ses_id', $query->createNamedParameter($this->hash($sessionId))));
|
||||
$result = $query->executeQuery()->fetchAssociative();
|
||||
if (!is_array($result)) {
|
||||
throw new SessionNotFoundException(
|
||||
'The session with identifier ' . $sessionId . ' was not found ',
|
||||
1481885483
|
||||
);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session record
|
||||
*
|
||||
* @return bool true if the session was deleted, false it session could not be found
|
||||
*/
|
||||
public function remove(string $sessionId): bool
|
||||
{
|
||||
$query = $this->getQueryBuilder();
|
||||
$query->delete($this->configuration['table'])
|
||||
->where(
|
||||
$query->expr()->or(
|
||||
$query->expr()->eq('ses_id', $query->createNamedParameter($this->hash($sessionId))),
|
||||
$query->expr()->eq('ses_id', $query->createNamedParameter($sessionId))
|
||||
)
|
||||
);
|
||||
|
||||
return (bool)$query->executeStatement();
|
||||
}
|
||||
|
||||
/**
|
||||
* Write session data. This method prevents overriding existing session data.
|
||||
* ses_id will always be set to $sessionId and overwritten if existing in $sessionData
|
||||
* This method updates ses_tstamp automatically
|
||||
*
|
||||
* @return array The newly created session record.
|
||||
* @throws SessionNotCreatedException
|
||||
*/
|
||||
public function set(string $sessionId, array $sessionData): array
|
||||
{
|
||||
$sessionId = $this->hash($sessionId);
|
||||
$sessionData['ses_id'] = $sessionId;
|
||||
$sessionData['ses_tstamp'] = $GLOBALS['EXEC_TIME'] ?? time();
|
||||
|
||||
try {
|
||||
$this->getConnection()->insert(
|
||||
$this->configuration['table'],
|
||||
$sessionData,
|
||||
['ses_data' => Connection::PARAM_LOB]
|
||||
);
|
||||
} catch (DBALException $e) {
|
||||
throw new SessionNotCreatedException(
|
||||
'Session could not be written to database: ' . $e->getMessage(),
|
||||
1481895005,
|
||||
$e
|
||||
);
|
||||
}
|
||||
|
||||
return $sessionData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the session data.
|
||||
* ses_id will always be set to $sessionId and overwritten if existing in $sessionData
|
||||
* This method updates ses_tstamp automatically
|
||||
*
|
||||
* @param array $sessionData The session data to update. Data may be partial.
|
||||
* @return array $sessionData The newly updated session record.
|
||||
* @throws SessionNotUpdatedException
|
||||
*/
|
||||
public function update(string $sessionId, array $sessionData): array
|
||||
{
|
||||
$hashedSessionId = $this->hash($sessionId);
|
||||
$sessionData['ses_id'] = $hashedSessionId;
|
||||
$sessionData['ses_tstamp'] = $GLOBALS['EXEC_TIME'] ?? time();
|
||||
|
||||
try {
|
||||
// allow 0 records to be affected, happens when no columns where changed
|
||||
$this->getConnection()->update(
|
||||
$this->configuration['table'],
|
||||
$sessionData,
|
||||
['ses_id' => $hashedSessionId],
|
||||
['ses_data' => Connection::PARAM_LOB]
|
||||
);
|
||||
} catch (DBALException $e) {
|
||||
throw new SessionNotUpdatedException(
|
||||
'Session with id ' . $sessionId . ' could not be updated: ' . $e->getMessage(),
|
||||
1481889220,
|
||||
$e
|
||||
);
|
||||
}
|
||||
return $sessionData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Garbage Collection
|
||||
*
|
||||
* @param int $maximumLifetime maximum lifetime of authenticated user sessions, in seconds.
|
||||
* @param int $maximumAnonymousLifetime maximum lifetime of non-authenticated user sessions, in seconds. If set to 0, non-authenticated sessions are ignored.
|
||||
*/
|
||||
public function collectGarbage(int $maximumLifetime, int $maximumAnonymousLifetime = 0)
|
||||
{
|
||||
$query = $this->getQueryBuilder();
|
||||
|
||||
$query->delete($this->configuration['table'])
|
||||
->where($query->expr()->lt('ses_tstamp', (int)($GLOBALS['EXEC_TIME'] - (int)$maximumLifetime)))
|
||||
->andWhere($this->hasAnonymousSessions ? $query->expr()->neq('ses_userid', 0) : ' 1 = 1');
|
||||
$query->executeStatement();
|
||||
|
||||
if ($maximumAnonymousLifetime > 0 && $this->hasAnonymousSessions) {
|
||||
$query = $this->getQueryBuilder();
|
||||
$query->delete($this->configuration['table'])
|
||||
->where($query->expr()->lt('ses_tstamp', (int)($GLOBALS['EXEC_TIME'] - (int)$maximumAnonymousLifetime)))
|
||||
->andWhere($query->expr()->eq('ses_userid', 0));
|
||||
$query->executeStatement();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all sessions
|
||||
*
|
||||
* @return array Return a list of all user sessions. The list may be empty
|
||||
*/
|
||||
public function getAll(): array
|
||||
{
|
||||
$query = $this->getQueryBuilder();
|
||||
$query->select('*')->from($this->configuration['table']);
|
||||
return $query->executeQuery()->fetchAllAssociative();
|
||||
}
|
||||
|
||||
protected function getQueryBuilder(): QueryBuilder
|
||||
{
|
||||
return $this->getConnection()->createQueryBuilder();
|
||||
}
|
||||
|
||||
protected function getConnection(): Connection
|
||||
{
|
||||
return $this->connectionPool->getConnectionForTable($this->configuration['table']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Session\Backend\Exception;
|
||||
|
||||
use TYPO3\CMS\Core\Exception;
|
||||
|
||||
/**
|
||||
* An abstract session backend exception, specific exceptions extend this.
|
||||
*/
|
||||
abstract class AbstractBackendException extends Exception {}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Session\Backend\Exception;
|
||||
|
||||
class SessionNotCreatedException extends AbstractBackendException {}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Session\Backend\Exception;
|
||||
|
||||
class SessionNotFoundException extends AbstractBackendException {}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Session\Backend\Exception;
|
||||
|
||||
class SessionNotUpdatedException extends AbstractBackendException {}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Session\Backend;
|
||||
|
||||
interface HashableSessionBackendInterface
|
||||
{
|
||||
public function hash(string $sessionId): string;
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Session\Backend;
|
||||
|
||||
use Psr\Log\LoggerAwareInterface;
|
||||
use Psr\Log\LoggerAwareTrait;
|
||||
use TYPO3\CMS\Core\Crypto\HashAlgo;
|
||||
use TYPO3\CMS\Core\Crypto\HashService;
|
||||
use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotCreatedException;
|
||||
use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotFoundException;
|
||||
use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotUpdatedException;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* This session backend takes these optional configuration options: 'hostname' (default '127.0.0.1'),
|
||||
* 'database' (default 0), 'port' (default 3679), 'username' (no default value) and 'password' (no default value).
|
||||
*
|
||||
* @todo: Declare this class final.
|
||||
*/
|
||||
class RedisSessionBackend implements SessionBackendInterface, HashableSessionBackendInterface, LoggerAwareInterface
|
||||
{
|
||||
use LoggerAwareTrait;
|
||||
|
||||
protected array $configuration = [];
|
||||
|
||||
/**
|
||||
* Indicates whether the server is connected
|
||||
*/
|
||||
protected bool $connected = false;
|
||||
|
||||
/**
|
||||
* Used as instance independent identifier
|
||||
* (e.g. if multiple installations write into the same database)
|
||||
*/
|
||||
protected string $applicationIdentifier = '';
|
||||
|
||||
protected \Redis $redis;
|
||||
|
||||
protected string $identifier;
|
||||
|
||||
/**
|
||||
* Initializes the session backend
|
||||
*
|
||||
* @param string $identifier Name of the session type, e.g. FE or BE
|
||||
* @internal To be used only by SessionManager
|
||||
*/
|
||||
public function initialize(string $identifier, array $configuration): void
|
||||
{
|
||||
$this->redis = new \Redis();
|
||||
|
||||
$this->configuration = $configuration;
|
||||
$this->identifier = $identifier;
|
||||
$this->applicationIdentifier = ($configuration['keyPrefix'] ?? '') . 'typo3_ses_'
|
||||
. $identifier . '_'
|
||||
. sha1($GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey']) . '_';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the configuration is valid
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
* @internal To be used only by SessionManager
|
||||
*/
|
||||
public function validateConfiguration(): void
|
||||
{
|
||||
if (!extension_loaded('redis')) {
|
||||
throw new \RuntimeException(
|
||||
'The PHP extension "redis" must be installed and loaded in order to use the redis session backend.',
|
||||
1481269826
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($this->configuration['database'])) {
|
||||
if (!is_int($this->configuration['database'])) {
|
||||
throw new \InvalidArgumentException(
|
||||
'The specified database number is of type "' . gettype($this->configuration['database'])
|
||||
. '" but an integer is expected.',
|
||||
1481270871
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->configuration['database'] < 0) {
|
||||
throw new \InvalidArgumentException(
|
||||
'The specified database "' . $this->configuration['database'] . '" must be greater or equal than zero.',
|
||||
1481270923
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_string($this->configuration['password'] ?? '')) {
|
||||
throw new \InvalidArgumentException(
|
||||
'The specified password must be a string. To authenticate with a username and password'
|
||||
. ' tuple, use the separate "username" and "password" options.',
|
||||
1780850765
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function hash(string $sessionId): string
|
||||
{
|
||||
return GeneralUtility::makeInstance(HashService::class)
|
||||
->hmac($sessionId, 'core-session-backend', HashAlgo::SHA3_256);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read session data
|
||||
*
|
||||
* @return array Returns the session data
|
||||
* @throws SessionNotFoundException
|
||||
*/
|
||||
public function get(string $sessionId): array
|
||||
{
|
||||
$this->initializeConnection();
|
||||
|
||||
$hashedSessionId = $this->hash($sessionId);
|
||||
$rawData = $this->redis->get($this->getSessionKeyName($hashedSessionId));
|
||||
if ($rawData !== false) {
|
||||
$decodedValue = json_decode($rawData, true);
|
||||
if (is_array($decodedValue)) {
|
||||
return $decodedValue;
|
||||
}
|
||||
}
|
||||
throw new SessionNotFoundException('Session could not be fetched from redis', 1481885583);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session record
|
||||
*/
|
||||
public function remove(string $sessionId): bool
|
||||
{
|
||||
$this->initializeConnection();
|
||||
|
||||
$deleteResult = $this->redis->del($this->getSessionKeyName($this->hash($sessionId)));
|
||||
|
||||
// Redis delete result is either `int`, `false` or a `\Redis` multi mode object, where delete state cannot get
|
||||
// determined. Multi mode is not even supported by this session backend at all, therefore we handle this case as
|
||||
// "not successful".
|
||||
return is_int($deleteResult) && $deleteResult >= 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write session data. This method prevents overriding existing session data.
|
||||
* ses_id will always be set to $sessionId and overwritten if existing in $sessionData
|
||||
* This method updates ses_tstamp automatically
|
||||
*
|
||||
* @return array The newly created session record.
|
||||
* @throws SessionNotCreatedException
|
||||
*/
|
||||
public function set(string $sessionId, array $sessionData): array
|
||||
{
|
||||
$this->initializeConnection();
|
||||
|
||||
$hashedSessionId = $this->hash($sessionId);
|
||||
$sessionData['ses_id'] = $hashedSessionId;
|
||||
$sessionData['ses_tstamp'] = $GLOBALS['EXEC_TIME'] ?? time();
|
||||
|
||||
// nx will not allow overwriting existing keys
|
||||
$jsonString = json_encode($sessionData);
|
||||
$wasSet = is_string($jsonString) && $this->redis->set(
|
||||
$this->getSessionKeyName($hashedSessionId),
|
||||
$jsonString,
|
||||
['nx']
|
||||
);
|
||||
|
||||
if (!$wasSet) {
|
||||
throw new SessionNotCreatedException('Session could not be written to Redis', 1481895647);
|
||||
}
|
||||
|
||||
return $sessionData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the session data.
|
||||
* ses_id will always be set to $sessionId and overwritten if existing in $sessionData
|
||||
* This method updates ses_tstamp automatically
|
||||
*
|
||||
* @param array $sessionData The session data to update. Data may be partial.
|
||||
* @return array $sessionData The newly updated session record.
|
||||
* @throws SessionNotUpdatedException
|
||||
*/
|
||||
public function update(string $sessionId, array $sessionData): array
|
||||
{
|
||||
$hashedSessionId = $this->hash($sessionId);
|
||||
try {
|
||||
$sessionData = array_merge($this->get($sessionId), $sessionData);
|
||||
} catch (SessionNotFoundException $e) {
|
||||
throw new SessionNotUpdatedException('Cannot update non-existing record', 1484389971, $e);
|
||||
}
|
||||
$sessionData['ses_id'] = $hashedSessionId;
|
||||
$sessionData['ses_tstamp'] = $GLOBALS['EXEC_TIME'] ?? time();
|
||||
|
||||
$key = $this->getSessionKeyName($hashedSessionId);
|
||||
$jsonString = json_encode($sessionData);
|
||||
$wasSet = is_string($jsonString) && $this->redis->set($key, $jsonString);
|
||||
|
||||
if (!$wasSet) {
|
||||
throw new SessionNotUpdatedException('Session could not be updated in Redis', 1481896383);
|
||||
}
|
||||
|
||||
return $sessionData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Garbage Collection
|
||||
*
|
||||
* @param int $maximumLifetime maximum lifetime of authenticated user sessions, in seconds.
|
||||
* @param int $maximumAnonymousLifetime maximum lifetime of non-authenticated user sessions, in seconds. If set to 0, nothing is collected.
|
||||
*/
|
||||
public function collectGarbage(int $maximumLifetime, int $maximumAnonymousLifetime = 0): void
|
||||
{
|
||||
foreach ($this->getAll() as $sessionRecord) {
|
||||
if (!($sessionRecord['ses_userid'] ?? false)) {
|
||||
if ($maximumAnonymousLifetime > 0 && ($sessionRecord['ses_tstamp'] + $maximumAnonymousLifetime) < $GLOBALS['EXEC_TIME']) {
|
||||
$this->redis->del($this->getSessionKeyName($sessionRecord['ses_id']));
|
||||
}
|
||||
} elseif (($sessionRecord['ses_tstamp'] + $maximumLifetime) < $GLOBALS['EXEC_TIME']) {
|
||||
$this->redis->del($this->getSessionKeyName($sessionRecord['ses_id']));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the redis backend
|
||||
*
|
||||
* @throws \RuntimeException if access to redis with password is denied or if database selection fails
|
||||
*/
|
||||
protected function initializeConnection(): void
|
||||
{
|
||||
if ($this->connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->connected = $this->redis->pconnect(
|
||||
$this->configuration['hostname'] ?? '127.0.0.1',
|
||||
$this->configuration['port'] ?? 6379,
|
||||
0.0,
|
||||
$this->identifier
|
||||
);
|
||||
} catch (\RedisException $e) {
|
||||
$this->logger->alert('Could not connect to redis server.', ['exception' => $e]);
|
||||
}
|
||||
|
||||
if (!$this->connected) {
|
||||
throw new \RuntimeException(
|
||||
'Could not connect to redis server at ' . $this->configuration['hostname'] . ':' . $this->configuration['port'],
|
||||
1482242961
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->getAuthentication() !== null
|
||||
&& !$this->redis->auth($this->getAuthentication())
|
||||
) {
|
||||
throw new \RuntimeException(
|
||||
'Authentication to Redis failed”.',
|
||||
1481270961
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($this->configuration['database'])
|
||||
&& $this->configuration['database'] >= 0
|
||||
&& !$this->redis->select($this->configuration['database'])
|
||||
) {
|
||||
throw new \RuntimeException(
|
||||
'The given database "' . $this->configuration['database'] . '" could not be selected.',
|
||||
1481270987
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected function getAuthentication(): array|string|null
|
||||
{
|
||||
$username = $this->configuration['username'] ?? null;
|
||||
$password = $this->configuration['password'] ?? null;
|
||||
|
||||
return match (true) {
|
||||
// Username and password configured for authentication, build associative array
|
||||
// out of possible and supported array variants by `php-redis::auth()`.
|
||||
($username !== null && $password !== null) => [
|
||||
'user' => $username,
|
||||
'pass' => $password,
|
||||
],
|
||||
// Password-only authentication configured.
|
||||
($username === null && $password !== null) => $password,
|
||||
// No authentication configured.
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all sessions
|
||||
*
|
||||
* @return array Return a list of all user sessions. The list may be empty.
|
||||
*/
|
||||
public function getAll(): array
|
||||
{
|
||||
$this->initializeConnection();
|
||||
|
||||
$keys = [];
|
||||
// Initialize our iterator to null, needed by redis->scan
|
||||
$iterator = null;
|
||||
$this->redis->setOption(\Redis::OPT_SCAN, (string)\Redis::SCAN_RETRY);
|
||||
$pattern = $this->getSessionKeyName('*');
|
||||
// retry when we get no keys back, redis->scan returns a chunk (array) of keys per iteration
|
||||
while (($keyChunk = $this->redis->scan($iterator, $pattern)) !== false) {
|
||||
foreach ($keyChunk as $key) {
|
||||
$keys[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
$encodedSessions = $this->redis->mGet($keys);
|
||||
if (!is_array($encodedSessions)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sessions = [];
|
||||
foreach ($encodedSessions as $session) {
|
||||
if (is_string($session)) {
|
||||
$decodedSession = json_decode($session, true);
|
||||
if ($decodedSession) {
|
||||
$sessions[] = $decodedSession;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $sessions;
|
||||
}
|
||||
|
||||
protected function getSessionKeyName(string $sessionId): string
|
||||
{
|
||||
return $this->applicationIdentifier . $sessionId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Session\Backend;
|
||||
|
||||
use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotCreatedException;
|
||||
use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotFoundException;
|
||||
use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotUpdatedException;
|
||||
|
||||
/**
|
||||
* Interface SessionBackendInterface
|
||||
*/
|
||||
interface SessionBackendInterface
|
||||
{
|
||||
/**
|
||||
* Initializes the session backend
|
||||
*
|
||||
* @param string $identifier Name of the session type, e.g. FE or BE
|
||||
* @internal To be used only by SessionManager
|
||||
*/
|
||||
public function initialize(string $identifier, array $configuration);
|
||||
|
||||
/**
|
||||
* Checks if the configuration is valid
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
* @internal To be used only by SessionManager
|
||||
*/
|
||||
public function validateConfiguration();
|
||||
|
||||
/**
|
||||
* List all sessions
|
||||
*
|
||||
* @return array Return a list of all user sessions. The list may be empty.
|
||||
*/
|
||||
public function getAll(): array;
|
||||
|
||||
/**
|
||||
* Read session data
|
||||
*
|
||||
* @return array Returns the session data
|
||||
* @throws SessionNotFoundException
|
||||
*/
|
||||
public function get(string $sessionId): array;
|
||||
|
||||
/**
|
||||
* Delete a session record
|
||||
*
|
||||
* @return bool true if the session was deleted, false it session could not be found
|
||||
*/
|
||||
public function remove(string $sessionId): bool;
|
||||
|
||||
/**
|
||||
* Write session data. This method prevents overriding existing session data.
|
||||
* ses_id will always be set to $sessionId and overwritten if existing in $sessionData
|
||||
* This method updates ses_tstamp automatically
|
||||
*
|
||||
* @return array The newly created session record.
|
||||
* @throws SessionNotCreatedException
|
||||
*/
|
||||
public function set(string $sessionId, array $sessionData): array;
|
||||
|
||||
/**
|
||||
* Updates the session data.
|
||||
* ses_id will always be set to $sessionId and overwritten if existing in $sessionData
|
||||
* This method updates ses_tstamp automatically
|
||||
*
|
||||
* @param array $sessionData The session data to update. Data may be partial.
|
||||
* @return array $sessionData The newly updated session record.
|
||||
* @throws SessionNotUpdatedException
|
||||
*/
|
||||
public function update(string $sessionId, array $sessionData): array;
|
||||
|
||||
/**
|
||||
* Garbage Collection
|
||||
*
|
||||
* @param int $maximumLifetime maximum lifetime of authenticated user sessions, in seconds.
|
||||
* @param int $maximumAnonymousLifetime maximum lifetime of non-authenticated user sessions, in seconds. If set to 0, nothing is collected.
|
||||
*/
|
||||
public function collectGarbage(int $maximumLifetime, int $maximumAnonymousLifetime = 0);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Session;
|
||||
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
|
||||
use TYPO3\CMS\Core\Authentication\AbstractUserAuthentication;
|
||||
use TYPO3\CMS\Core\Session\Backend\HashableSessionBackendInterface;
|
||||
use TYPO3\CMS\Core\Session\Backend\SessionBackendInterface;
|
||||
use TYPO3\CMS\Core\SingletonInterface;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* Example Configuration
|
||||
*
|
||||
* ```
|
||||
* $GLOBALS['TYPO3_CONF_VARS']['SYS']['session'] => [
|
||||
* 'BE' => [
|
||||
* 'backend' => \TYPO3\CMS\Core\Session\Backend\FileSessionBackend::class,
|
||||
* 'savePath' => '/var/www/t3sessionframework/data/'
|
||||
* ],
|
||||
* ];
|
||||
* ```
|
||||
*/
|
||||
#[AsAlias('session-manager', public: true)]
|
||||
class SessionManager implements SingletonInterface
|
||||
{
|
||||
/**
|
||||
* @var SessionBackendInterface[]
|
||||
*/
|
||||
protected $sessionBackends = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly ContainerInterface $container,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Gets the currently running session backend for the given context
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function getSessionBackend(string $identifier): SessionBackendInterface
|
||||
{
|
||||
if (!isset($this->sessionBackends[$identifier])) {
|
||||
$configuration = $GLOBALS['TYPO3_CONF_VARS']['SYS']['session'][$identifier] ?? false;
|
||||
if (!$configuration) {
|
||||
throw new \InvalidArgumentException('Session configuration for identifier ' . $identifier . ' was not found', 1482234750);
|
||||
}
|
||||
|
||||
$sessionBackend = $this->createSessionBackendFromConfiguration($identifier, $configuration);
|
||||
|
||||
// Validates the session backend configuration and throws an exception if something's wrong
|
||||
$sessionBackend->validateConfiguration();
|
||||
$this->sessionBackends[$identifier] = $sessionBackend;
|
||||
}
|
||||
return $this->sessionBackends[$identifier];
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all sessions for a specific user ID
|
||||
*
|
||||
* @param SessionBackendInterface $backend see constants
|
||||
*/
|
||||
public function invalidateAllSessionsByUserId(SessionBackendInterface $backend, int $userId, ?AbstractUserAuthentication $userAuthentication = null)
|
||||
{
|
||||
$sessionToRenew = '';
|
||||
$hashedSessionToRenew = '';
|
||||
// Prevent destroying the session of the current user session, but renew session id
|
||||
if ($userAuthentication !== null && (int)$userAuthentication->user['uid'] === $userId) {
|
||||
$sessionToRenew = $userAuthentication->getSession()->getIdentifier();
|
||||
}
|
||||
if ($sessionToRenew !== '' && $backend instanceof HashableSessionBackendInterface) {
|
||||
$hashedSessionToRenew = $backend->hash($sessionToRenew);
|
||||
}
|
||||
|
||||
foreach ($backend->getAll() as $session) {
|
||||
if ($userAuthentication !== null) {
|
||||
if ($session['ses_id'] === $sessionToRenew || $session['ses_id'] === $hashedSessionToRenew) {
|
||||
$userAuthentication->enforceNewSessionId();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((int)$session['ses_userid'] === $userId) {
|
||||
$backend->remove($session['ses_id']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a session backend from the configuration
|
||||
*
|
||||
* @param string $identifier the identifier
|
||||
* @param array<string, class-string> $configuration The session configuration array
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
protected function createSessionBackendFromConfiguration(string $identifier, array $configuration): SessionBackendInterface
|
||||
{
|
||||
$className = $configuration['backend'];
|
||||
|
||||
if (!is_subclass_of($className, SessionBackendInterface::class)) {
|
||||
throw new \InvalidArgumentException('Configured session backend ' . $className . ' does not implement ' . SessionBackendInterface::class, 1482235035);
|
||||
}
|
||||
|
||||
$options = $configuration['options'] ?? [];
|
||||
|
||||
/** @var SessionBackendInterface $backend */
|
||||
$backend = $this->container->has($className) ? $this->container->get($className) : GeneralUtility::makeInstance($className);
|
||||
$backend->initialize($identifier, $options);
|
||||
return $backend;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Session;
|
||||
|
||||
use TYPO3\CMS\Core\Http\CookieScope;
|
||||
use TYPO3\CMS\Core\Log\LogManager;
|
||||
use TYPO3\CMS\Core\Security\JwtTrait;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* Represents all information about a user's session.
|
||||
* A user session can be bound to a frontend / backend user, or an anonymous session based on session data stored
|
||||
* in the session backend.
|
||||
*
|
||||
* If a session is anonymous, it can be fixated by storing the session in the backend, but only if there
|
||||
* is data in the session.
|
||||
*
|
||||
* if a session is user-bound, it is automatically fixated.
|
||||
*
|
||||
* The `$isNew` flag is meant to show that this user session object was not
|
||||
* fetched from the session backend, but initialized in the first place by
|
||||
* the current request.
|
||||
*
|
||||
* The `$data` argument stores arbitrary data valid for the user's session.
|
||||
*
|
||||
* A permanent session is not issued by a session-based cookie but a
|
||||
* time-based cookie. The session might be persisted in the user's browser.
|
||||
*/
|
||||
class UserSession
|
||||
{
|
||||
use JwtTrait;
|
||||
|
||||
protected const SESSION_UPDATE_GRACE_PERIOD = 61;
|
||||
protected string $identifier;
|
||||
protected ?int $userId;
|
||||
protected int $lastUpdated;
|
||||
protected array $data;
|
||||
protected bool $wasUpdated = false;
|
||||
protected string $ipLock = '';
|
||||
protected bool $isNew = true;
|
||||
protected bool $isPermanent = false;
|
||||
|
||||
protected function __construct(string $identifier, int $userId, int $lastUpdated, array $data = [])
|
||||
{
|
||||
$this->identifier = $identifier;
|
||||
$this->userId = $userId > 0 ? $userId : null;
|
||||
$this->lastUpdated = $lastUpdated;
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string the session ID. This is the `ses_id` respectively the `AbstractUserAuthentication->id`
|
||||
*/
|
||||
public function getIdentifier(): string
|
||||
{
|
||||
return $this->identifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ?int the user ID the session belongs to. Can also return `0` or `NULL` Which indicates an anonymous session. This is the `ses_userid`.
|
||||
*/
|
||||
public function getUserId(): ?int
|
||||
{
|
||||
return $this->userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int the timestamp of the last session data update. This is the `ses_tstamp`.
|
||||
*/
|
||||
public function getLastUpdated(): int
|
||||
{
|
||||
return $this->lastUpdated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets or updates session data value for a given `$key`. It is also
|
||||
* internally used if calling `AbstractUserAuthentication->setSessionData()`
|
||||
*
|
||||
* @param string $key The key whose value should be updated
|
||||
* @param mixed $value The value or `NULL` to unset the key
|
||||
*/
|
||||
public function set(string $key, $value): void
|
||||
{
|
||||
if ($key === '') {
|
||||
throw new \InvalidArgumentException('Argument key must not be empty', 1484312516);
|
||||
}
|
||||
if ($value === null) {
|
||||
unset($this->data[$key]);
|
||||
} else {
|
||||
$this->data[$key] = $value;
|
||||
}
|
||||
$this->wasUpdated = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the session has data assigned
|
||||
*/
|
||||
public function hasData(): bool
|
||||
{
|
||||
return $this->data !== [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the session data for the given `$key` or `NULL` if the key does
|
||||
* not exist. It is internally used if calling
|
||||
* `AbstractUserAuthentication->getSessionData()`
|
||||
*/
|
||||
public function get(string $key)
|
||||
{
|
||||
return $this->data[$key] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array the whole data array.
|
||||
*/
|
||||
public function getData(): array
|
||||
{
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides the whole data array. Can also be used to unset the array.
|
||||
* This also sets the `$wasUpdated` pointer to `true`
|
||||
*/
|
||||
public function overrideData(array $data): void
|
||||
{
|
||||
if ($this->data !== $data) {
|
||||
// Only set update flag if there is change in the $data array
|
||||
$this->wasUpdated = true;
|
||||
}
|
||||
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the session data has been updated
|
||||
*/
|
||||
public function dataWasUpdated(): bool
|
||||
{
|
||||
return $this->wasUpdated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user session is an anonymous one. This means, the
|
||||
* session does not belong to a logged-in user
|
||||
*/
|
||||
public function isAnonymous(): bool
|
||||
{
|
||||
return $this->userId === 0 || $this->userId === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string the `ipLock` state of the session
|
||||
*/
|
||||
public function getIpLock(): string
|
||||
{
|
||||
return $this->ipLock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the session is marked as new
|
||||
*/
|
||||
public function isNew(): bool
|
||||
{
|
||||
return $this->isNew;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the session was marked as permanent
|
||||
*/
|
||||
public function isPermanent(): bool
|
||||
{
|
||||
return $this->isPermanent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the session has to be updated
|
||||
*/
|
||||
public function needsUpdate(): bool
|
||||
{
|
||||
return $GLOBALS['EXEC_TIME'] > ($this->lastUpdated + self::SESSION_UPDATE_GRACE_PERIOD);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets session ID wrapped in JWT to be used for emitting a new cookie.
|
||||
* `Cookie: <JWT(HS256, [identifier => <session-id>], <signature(encryption-key, cookie-domain)>)>`
|
||||
*
|
||||
* @param ?CookieScope $scope
|
||||
* @return string the session ID wrapped in JWT to be used for emitting a new cookie
|
||||
*/
|
||||
public function getJwt(?CookieScope $scope = null): string
|
||||
{
|
||||
// @todo payload could be organized in a new `SessionToken` object
|
||||
return self::encodeHashSignedJwt(
|
||||
[
|
||||
'identifier' => $this->identifier,
|
||||
'time' => (new \DateTimeImmutable())->format(\DateTimeImmutable::RFC3339),
|
||||
'scope' => $scope,
|
||||
],
|
||||
self::createSigningKeyFromEncryptionKey(UserSession::class)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new user session based on the provided session record
|
||||
*
|
||||
* @param string $id the session identifier
|
||||
*/
|
||||
public static function createFromRecord(string $id, array $record, bool $markAsNew = false): self
|
||||
{
|
||||
$userSession = new self(
|
||||
$id,
|
||||
(int)($record['ses_userid'] ?? 0),
|
||||
(int)($record['ses_tstamp'] ?? 0),
|
||||
unserialize($record['ses_data'] ?? '', ['allowed_classes' => false]) ?: []
|
||||
);
|
||||
$userSession->ipLock = $record['ses_iplock'] ?? '';
|
||||
$userSession->isNew = $markAsNew;
|
||||
if (isset($record['ses_permanent'])) {
|
||||
$userSession->isPermanent = (bool)$record['ses_permanent'];
|
||||
}
|
||||
return $userSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a non fixated user session. This means the
|
||||
* session does not belong to a logged-in user
|
||||
*/
|
||||
public static function createNonFixated(string $identifier): self
|
||||
{
|
||||
$userSession = new self($identifier, 0, $GLOBALS['EXEC_TIME'], []);
|
||||
$userSession->isPermanent = false;
|
||||
$userSession->isNew = true;
|
||||
return $userSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies and resolves the session ID from a submitted cookie value:
|
||||
* `Cookie: <JWT(HS256, [identifier => <session-id>], <signature(encryption-key, cookie-domain)>)>`
|
||||
*
|
||||
* @param string $cookieValue submitted cookie value
|
||||
* @param CookieScope $scope
|
||||
* @return non-empty-string|null session ID, null in case verification failed
|
||||
* @throws \Exception
|
||||
* @see getJwt()
|
||||
*/
|
||||
public static function resolveIdentifierFromJwt(string $cookieValue, CookieScope $scope): ?string
|
||||
{
|
||||
if ($cookieValue === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = self::decodeJwt($cookieValue, self::createSigningKeyFromEncryptionKey(UserSession::class));
|
||||
|
||||
$identifier = !empty($payload->identifier) && is_string($payload->identifier) ? $payload->identifier : null;
|
||||
if ($identifier === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$domainScope = (string)($payload->scope->domain ?? '');
|
||||
$pathScope = (string)($payload->scope->path ?? '');
|
||||
if ($domainScope === '' || $pathScope === '') {
|
||||
$logger = GeneralUtility::makeInstance(LogManager::class)->getLogger(self::class);
|
||||
$logger->notice('A session cookie with out a domain scope has been used', ['cookieHash' => substr(sha1($cookieValue), 0, 12)]);
|
||||
return $identifier;
|
||||
}
|
||||
if ($domainScope !== $scope->domain || $pathScope !== $scope->path) {
|
||||
// invalid scope, the cookie jwt has been used on a wrong path or domain
|
||||
return null;
|
||||
}
|
||||
|
||||
return $identifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Used internally to store data in the backend
|
||||
* @return array The session record as array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$data = [
|
||||
'ses_id' => $this->identifier,
|
||||
'ses_data' => serialize($this->data),
|
||||
'ses_userid' => (int)$this->userId,
|
||||
'ses_iplock' => $this->ipLock,
|
||||
'ses_tstamp' => $this->lastUpdated,
|
||||
];
|
||||
if ($this->isPermanent) {
|
||||
$data['ses_permanent'] = 1;
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Session;
|
||||
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerAwareInterface;
|
||||
use Psr\Log\LoggerAwareTrait;
|
||||
use TYPO3\CMS\Core\Authentication\IpLocker;
|
||||
use TYPO3\CMS\Core\Crypto\Random;
|
||||
use TYPO3\CMS\Core\Http\CookieScopeTrait;
|
||||
use TYPO3\CMS\Core\Http\NormalizedParams;
|
||||
use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotFoundException;
|
||||
use TYPO3\CMS\Core\Session\Backend\SessionBackendInterface;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* The purpose of the UserSessionManager is to create new user session objects (acting as a factory),
|
||||
* depending on the need / request, and to fetch sessions from the session backend, effectively
|
||||
* encapsulating all calls to the `SessionManager`.
|
||||
*
|
||||
* The UserSessionManager can be retrieved using its static factory method create():
|
||||
*
|
||||
* ```
|
||||
* use TYPO3\CMS\Core\Session\UserSessionManager;
|
||||
*
|
||||
* $loginType = 'BE'; // or 'FE' for frontend
|
||||
* $userSessionManager = UserSessionManager::create($loginType);
|
||||
* ```
|
||||
*/
|
||||
class UserSessionManager implements LoggerAwareInterface
|
||||
{
|
||||
use LoggerAwareTrait;
|
||||
use CookieScopeTrait;
|
||||
|
||||
protected const SESSION_ID_LENGTH = 32;
|
||||
protected const GARBAGE_COLLECTION_LIFETIME = 86400;
|
||||
protected const LIFETIME_OF_ANONYMOUS_SESSION_DATA = 86400;
|
||||
|
||||
/**
|
||||
* Session timeout (on the storage-side, used to know until a session (timestamp) is valid
|
||||
*
|
||||
* If >0: session-timeout in seconds.
|
||||
* If =0: Instant logout after login.
|
||||
*/
|
||||
protected int $sessionLifetime;
|
||||
|
||||
protected int $garbageCollectionForAnonymousSessions = self::LIFETIME_OF_ANONYMOUS_SESSION_DATA;
|
||||
protected SessionBackendInterface $sessionBackend;
|
||||
protected IpLocker $ipLocker;
|
||||
protected string $loginType;
|
||||
|
||||
/**
|
||||
* Constructor. Marked as internal, as it is recommended to use the factory method "create"
|
||||
*
|
||||
* @internal it is recommended to use the factory method "create"
|
||||
*/
|
||||
public function __construct(SessionBackendInterface $sessionBackend, int $sessionLifetime, IpLocker $ipLocker, string $loginType)
|
||||
{
|
||||
$this->sessionBackend = $sessionBackend;
|
||||
$this->sessionLifetime = $sessionLifetime;
|
||||
$this->ipLocker = $ipLocker;
|
||||
$this->loginType = $loginType;
|
||||
}
|
||||
|
||||
protected function setGarbageCollectionTimeoutForAnonymousSessions(int $garbageCollectionForAnonymousSessions = 0): void
|
||||
{
|
||||
if ($garbageCollectionForAnonymousSessions > 0) {
|
||||
$this->garbageCollectionForAnonymousSessions = $garbageCollectionForAnonymousSessions;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and returns a session from the given request. If the given
|
||||
* `$cookieName` can not be obtained from the request an anonymous
|
||||
* session will be returned.
|
||||
*
|
||||
* @param string $cookieName Name of the cookie that might contain the session
|
||||
* @return UserSession An existing session if one is stored in the cookie, an anonymous session otherwise
|
||||
*/
|
||||
public function createFromRequestOrAnonymous(ServerRequestInterface $request, string $cookieName): UserSession
|
||||
{
|
||||
try {
|
||||
$cookieValue = (string)($request->getCookieParams()[$cookieName] ?? '');
|
||||
$scope = $this->getCookieScope($request->getAttribute('normalizedParams') ?? NormalizedParams::createFromRequest($request));
|
||||
$sessionId = UserSession::resolveIdentifierFromJwt($cookieValue, $scope);
|
||||
} catch (\Exception $exception) {
|
||||
$this->logger->debug('Could not resolve session identifier from JWT', ['exception' => $exception]);
|
||||
}
|
||||
return $this->getSessionFromSessionId($sessionId ?? '') ?? $this->createAnonymousSession();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and returns an anonymous session object (which is not persisted)
|
||||
*/
|
||||
public function createAnonymousSession(): UserSession
|
||||
{
|
||||
$randomSessionId = $this->createSessionId();
|
||||
return UserSession::createNonFixated($randomSessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and returns a new session object for a given session id
|
||||
*
|
||||
* @param string $sessionId The session id to be looked up in the session backend
|
||||
* @return UserSession The created user session object
|
||||
* @internal this is only used as a bridge for existing methods, might be removed or renamed without further notice
|
||||
*/
|
||||
public function createSessionFromStorage(string $sessionId): UserSession
|
||||
{
|
||||
$this->logger->debug('Fetch session with identifier {session}', ['session' => sha1($sessionId)]);
|
||||
$sessionRecord = $this->sessionBackend->get($sessionId);
|
||||
return UserSession::createFromRecord($sessionId, $sessionRecord);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a session has expired. This is also the case if `sessionLifetime` is `0`
|
||||
*/
|
||||
public function hasExpired(UserSession $session): bool
|
||||
{
|
||||
return $this->sessionLifetime === 0 || $GLOBALS['EXEC_TIME'] > $session->getLastUpdated() + $this->sessionLifetime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a given user session will expire within the given grace period
|
||||
*
|
||||
* @param int $gracePeriod in seconds
|
||||
*/
|
||||
public function willExpire(UserSession $session, int $gracePeriod): bool
|
||||
{
|
||||
return $GLOBALS['EXEC_TIME'] >= ($session->getLastUpdated() + $this->sessionLifetime) - $gracePeriod;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists an anonymous session without a user logged-in,
|
||||
* in order to store session data between requests
|
||||
*
|
||||
* @param UserSession $session The user session to fixate
|
||||
* @param bool $isPermanent If `true`, the session will get the `ses_permanent` flag
|
||||
* @return UserSession a new session object with an updated `ses_tstamp` (allowing to keep the session alive)
|
||||
*/
|
||||
public function fixateAnonymousSession(UserSession $session, bool $isPermanent = false): UserSession
|
||||
{
|
||||
// @todo: Refactor. Get Request or at least remote address hand over
|
||||
$sessionIpLock = $this->ipLocker->getSessionIpLock(NormalizedParams::createFromServerParams($_SERVER)->getRemoteAddress());
|
||||
$sessionRecord = $session->toArray();
|
||||
$sessionRecord['ses_iplock'] = $sessionIpLock;
|
||||
// Ensure the user is not set, as this is always an anonymous session (see elevateToFixatedUserSession)
|
||||
$sessionRecord['ses_userid'] = 0;
|
||||
if ($isPermanent) {
|
||||
$sessionRecord['ses_permanent'] = 1;
|
||||
}
|
||||
// The updated session record now also contains an updated timestamp (ses_tstamp)
|
||||
$updatedSessionRecord = $this->sessionBackend->set($session->getIdentifier(), $sessionRecord);
|
||||
return $this->recreateUserSession($session, $updatedSessionRecord);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes existing entries, creates and returns a new user session object.
|
||||
* See `regenerateSession()` below.
|
||||
*
|
||||
* @param UserSession $session The user session to recreate
|
||||
* @param int $userId The user id the session belongs to
|
||||
* @param bool $isPermanent If `true`, the session will get the `ses_permanent` flag
|
||||
* @return UserSession The newly created user session object
|
||||
*
|
||||
* @throws Backend\Exception\SessionNotCreatedException
|
||||
*/
|
||||
public function elevateToFixatedUserSession(UserSession $session, int $userId, bool $isPermanent = false): UserSession
|
||||
{
|
||||
$sessionId = $session->getIdentifier();
|
||||
$this->logger->debug('Create session ses_id = {session}', ['session' => sha1($sessionId)]);
|
||||
// Delete any session entry first
|
||||
$this->sessionBackend->remove($sessionId);
|
||||
// Re-create session entry
|
||||
// @todo: Refactor. Get Request or at least remote address hand over
|
||||
$sessionIpLock = $this->ipLocker->getSessionIpLock(NormalizedParams::createFromServerParams($_SERVER)->getRemoteAddress());
|
||||
$sessionRecord = [
|
||||
'ses_iplock' => $sessionIpLock,
|
||||
'ses_userid' => $userId,
|
||||
'ses_tstamp' => $GLOBALS['EXEC_TIME'],
|
||||
'ses_data' => '',
|
||||
];
|
||||
if ($isPermanent) {
|
||||
$sessionRecord['ses_permanent'] = 1;
|
||||
}
|
||||
$sessionRecord = $this->sessionBackend->set($sessionId, $sessionRecord);
|
||||
return UserSession::createFromRecord($sessionId, $sessionRecord, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerates the given session. This method should be used whenever a
|
||||
* user proceeds to a higher authorization level, for example when an
|
||||
* anonymous session is now authenticated.
|
||||
*
|
||||
* @param string $sessionId The session id
|
||||
* @param array $existingSessionRecord If given, this session record will be used instead of fetching again
|
||||
* @param bool $anonymous If true session will be regenerated as anonymous session
|
||||
*/
|
||||
public function regenerateSession(
|
||||
string $sessionId,
|
||||
array $existingSessionRecord = [],
|
||||
bool $anonymous = false
|
||||
): UserSession {
|
||||
if (empty($existingSessionRecord)) {
|
||||
$existingSessionRecord = $this->sessionBackend->get($sessionId);
|
||||
}
|
||||
if ($anonymous) {
|
||||
$existingSessionRecord['ses_userid'] = 0;
|
||||
}
|
||||
// Update session record with new ID
|
||||
$newSessionId = $this->createSessionId();
|
||||
$this->sessionBackend->set($newSessionId, $existingSessionRecord);
|
||||
$this->sessionBackend->remove($sessionId);
|
||||
return UserSession::createFromRecord($newSessionId, $existingSessionRecord, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the session timestamp for the given user session if the session
|
||||
* is marked as "needs update" (which means the current timestamp is
|
||||
* greater than "last updated + a specified grace-time").
|
||||
*
|
||||
* @return UserSession a modified user session with a last updated value if needed
|
||||
*/
|
||||
public function updateSessionTimestamp(UserSession $session): UserSession
|
||||
{
|
||||
if ($session->needsUpdate()) {
|
||||
// Update the session timestamp by writing a dummy update. (Backend will update the timestamp)
|
||||
$this->sessionBackend->update($session->getIdentifier(), []);
|
||||
$session = $this->recreateUserSession($session);
|
||||
}
|
||||
return $session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a given session is already persisted
|
||||
*/
|
||||
public function isSessionPersisted(UserSession $session): bool
|
||||
{
|
||||
return $this->getSessionFromSessionId($session->getIdentifier()) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a given session from the session backend
|
||||
*/
|
||||
public function removeSession(UserSession $session): void
|
||||
{
|
||||
$this->sessionBackend->remove($session->getIdentifier());
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the session data + timestamp in the session backend
|
||||
*/
|
||||
public function updateSession(UserSession $session): UserSession
|
||||
{
|
||||
$sessionRecord = $this->sessionBackend->update($session->getIdentifier(), $session->toArray());
|
||||
return $this->recreateUserSession($session, $sessionRecord);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the session backends `collectGarbage()` method with the given probability in percent.
|
||||
*/
|
||||
public function collectGarbage(int $garbageCollectionProbability = 1): void
|
||||
{
|
||||
if (rand(0, 99) < $garbageCollectionProbability) {
|
||||
$this->sessionBackend->collectGarbage(
|
||||
$this->sessionLifetime > 0 ? $this->sessionLifetime : self::GARBAGE_COLLECTION_LIFETIME,
|
||||
$this->garbageCollectionForAnonymousSessions
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new session ID using a random with SESSION_ID_LENGTH as length
|
||||
*/
|
||||
protected function createSessionId(): string
|
||||
{
|
||||
return GeneralUtility::makeInstance(Random::class)->generateRandomHexString(self::SESSION_ID_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to fetch a user session form the session backend.
|
||||
* If none is given, an anonymous session will be created.
|
||||
*/
|
||||
protected function getSessionFromSessionId(string $id): ?UserSession
|
||||
{
|
||||
if ($id === '') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$sessionRecord = $this->sessionBackend->get($id);
|
||||
if ($sessionRecord === []) {
|
||||
return null;
|
||||
}
|
||||
// If the session does not match the current IP lock, it should be treated as invalid
|
||||
// and a new session should be created.
|
||||
// @todo: Refactor. Get Request or at least remote address hand over
|
||||
if ($this->ipLocker->validateRemoteAddressAgainstSessionIpLock(
|
||||
NormalizedParams::createFromServerParams($_SERVER)->getRemoteAddress(),
|
||||
$sessionRecord['ses_iplock']
|
||||
)) {
|
||||
return UserSession::createFromRecord($id, $sessionRecord);
|
||||
}
|
||||
} catch (SessionNotFoundException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a `UserSessionManager` instance for the given login type. Has
|
||||
* several optional arguments used for testing purposes to inject dummy
|
||||
* objects if needed.
|
||||
*
|
||||
* Ideally, this factory encapsulates all `TYPO3_CONF_VARS` options, so
|
||||
* the actual object does not need to consider any global state.
|
||||
*/
|
||||
public static function create(string $loginType, ?int $sessionLifetime = null, ?SessionManager $sessionManager = null, ?IpLocker $ipLocker = null): self
|
||||
{
|
||||
$sessionManager = $sessionManager ?? GeneralUtility::makeInstance(SessionManager::class);
|
||||
$ipLocker = $ipLocker ?? GeneralUtility::makeInstance(
|
||||
IpLocker::class,
|
||||
(int)($GLOBALS['TYPO3_CONF_VARS'][$loginType]['lockIP'] ?? 0),
|
||||
(int)($GLOBALS['TYPO3_CONF_VARS'][$loginType]['lockIPv6'] ?? 0)
|
||||
);
|
||||
$lifetime = (int)($GLOBALS['TYPO3_CONF_VARS'][$loginType]['lifetime'] ?? 0);
|
||||
$sessionLifetime = $sessionLifetime ?? (int)$GLOBALS['TYPO3_CONF_VARS'][$loginType]['sessionTimeout'];
|
||||
if ($sessionLifetime > 0 && $sessionLifetime < $lifetime && $lifetime > 0) {
|
||||
// If server session timeout is non-zero but less than client session timeout: Copy this value instead.
|
||||
$sessionLifetime = $lifetime;
|
||||
}
|
||||
$object = GeneralUtility::makeInstance(
|
||||
self::class,
|
||||
$sessionManager->getSessionBackend($loginType),
|
||||
$sessionLifetime,
|
||||
$ipLocker,
|
||||
$loginType
|
||||
);
|
||||
if ($loginType === 'FE') {
|
||||
$object->setGarbageCollectionTimeoutForAnonymousSessions((int)($GLOBALS['TYPO3_CONF_VARS']['FE']['sessionDataLifetime'] ?? 0));
|
||||
}
|
||||
return $object;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recreates a `UserSession` object from the existing session data - keeping `new` state.
|
||||
* This method shall be used to reflect updated low-level session data in corresponding `UserSession` object.
|
||||
*/
|
||||
protected function recreateUserSession(UserSession $session, ?array $sessionRecord = null): UserSession
|
||||
{
|
||||
return UserSession::createFromRecord(
|
||||
$session->getIdentifier(),
|
||||
$sessionRecord ?? $this->sessionBackend->get($session->getIdentifier()),
|
||||
$session->isNew() // keep state (required to emit e.g. cookies)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user