349 lines
12 KiB
PHP
349 lines
12 KiB
PHP
<?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;
|
|
}
|
|
}
|