包含:登录失败锁定、90天密码有效期、30分钟会话超时、 强制改密、登录审计日志、屏幕水印、企业背景图、 备案信息固定底部、favicon、登录页JS修复等全部改动
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\SessionHandler;
|
||||
|
||||
use MRBS\Utf8\Utf8String;
|
||||
|
||||
class Cookie
|
||||
{
|
||||
|
||||
/**
|
||||
* A wrapper for `setcookie()` that uses the same values for `$path`, `$domain`, `$secure`, `$httponly` and, if
|
||||
* applicable, `$samesite` as for the session cookie.
|
||||
*/
|
||||
public static function set(string $name, string $value, int $expires) : bool
|
||||
{
|
||||
// Use the same cookie params as for the session cookie.
|
||||
$cookie_params = session_get_cookie_params();
|
||||
assert(version_compare(MRBS_MIN_PHP_VERSION, '7.3.0', '<'), "The else block can be removed.");
|
||||
if (version_compare(PHP_VERSION, '7.3.0', '>='))
|
||||
{
|
||||
// The new way, allowing 'samesite' to be set
|
||||
$result = setcookie($name, $value, [
|
||||
'expires' => $expires,
|
||||
'path' => $cookie_params['path'],
|
||||
'domain' => $cookie_params['domain'],
|
||||
'secure' => $cookie_params['secure'],
|
||||
'httponly' => $cookie_params['httponly'],
|
||||
'samesite' => $cookie_params['samesite']
|
||||
]);
|
||||
}
|
||||
else
|
||||
{
|
||||
// The old way. 'samesite' wasn't available until PHP 7.3.0.
|
||||
$result = setcookie(
|
||||
$name,
|
||||
$value,
|
||||
$expires,
|
||||
$cookie_params['path'],
|
||||
$cookie_params['domain'],
|
||||
$cookie_params['secure'],
|
||||
$cookie_params['httponly']
|
||||
);
|
||||
}
|
||||
|
||||
self::checkCookieSizes();
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
public static function delete(string $name) : bool
|
||||
{
|
||||
return self::set($name, '', time() - 42000);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check the sizes of the cookies that have been set and trigger a warning if they are too large.
|
||||
*
|
||||
* Browsers will reject cookies that are too large, although `setcookie()` will still set them and return TRUE.
|
||||
* What's considered too large depends on the browser. Some browsers just count the length of the name and value
|
||||
* of the cookie; others also count the length of the options (expires, path, domain, secure, httponly, samesite).
|
||||
* To be on the safe side, the options are included in the calculation.
|
||||
*/
|
||||
private static function checkCookieSizes() : void
|
||||
{
|
||||
$max_size = 4096;
|
||||
$headers = headers_list();
|
||||
|
||||
foreach ($headers as $header)
|
||||
{
|
||||
if (preg_match('/^Set-Cookie:\s*([^=]*)=(.*$)/', $header, $matches))
|
||||
{
|
||||
if ((string)(new Utf8String($matches[1] . '=' . $matches[2]))->byteCount() > $max_size)
|
||||
{
|
||||
trigger_error("Cookie '" . $matches[1] . "' exceeds $max_size bytes", E_USER_WARNING);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\SessionHandler;
|
||||
|
||||
use MRBS\Errors\Errors;
|
||||
use SessionHandlerInterface;
|
||||
use SessionUpdateTimestampHandlerInterface;
|
||||
use function MRBS\_tbl;
|
||||
use function MRBS\db;
|
||||
|
||||
// Suppress deprecation notices until we get to requiring at least PHP 8
|
||||
// because union types, needed for the return types of read() and gc(), are
|
||||
// not supported in PHP 7. Using the #[\ReturnTypeWillChange] attribute
|
||||
// does not help because that was only introduced in PHP 8.1.
|
||||
if (version_compare(MRBS_MIN_PHP_VERSION, '8.0.0') < 0)
|
||||
{
|
||||
$old_level = error_reporting();
|
||||
error_reporting($old_level & ~E_DEPRECATED);
|
||||
}
|
||||
else
|
||||
{
|
||||
trigger_error("This code can now be removed", E_USER_NOTICE);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A custom session handler that stores session data in a cookie.
|
||||
*
|
||||
* Ideally we would encrypt the session data, but most encryption algorithms will increase the length of
|
||||
* the string. As we will be putting the data in a cookie, whose size is limited to 4096 bytes, there
|
||||
* would be a real danger of the cookie becoming too large. Even unencrypted, it's quite possible that the
|
||||
* session data could be too large for a cookie. The cookie-based session handler is therefore not really
|
||||
* recommended.
|
||||
*/
|
||||
class SessionHandlerCookie implements SessionHandlerInterface, SessionUpdateTimestampHandlerInterface
|
||||
{
|
||||
private const DEFAULT_HASH_ALGO = 'sha512';
|
||||
|
||||
private $algo;
|
||||
private $include_ip;
|
||||
private $secret;
|
||||
|
||||
|
||||
public function __construct(
|
||||
#[\SensitiveParameter]
|
||||
string $secret,
|
||||
string $algo = self::DEFAULT_HASH_ALGO,
|
||||
bool $include_ip = false
|
||||
)
|
||||
{
|
||||
$this->include_ip = $include_ip;
|
||||
$this->secret = $secret;
|
||||
if (in_array($algo, hash_hmac_algos()))
|
||||
{
|
||||
$this->algo = $algo;
|
||||
}
|
||||
else
|
||||
{
|
||||
$this->algo = self::DEFAULT_HASH_ALGO;
|
||||
$message = "Invalid hash algorithm '$algo' specified, using '" . self::DEFAULT_HASH_ALGO . "'sha512' instead";
|
||||
trigger_error($message, E_USER_WARNING);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @return bool The return value (usually TRUE on success, FALSE on failure). Note this value is
|
||||
* returned internally to PHP for processing.
|
||||
*/
|
||||
public function open($path, $name): bool
|
||||
{
|
||||
// Nothing to do here
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @return bool The return value (usually TRUE on success, FALSE on failure). Note this value is
|
||||
* returned internally to PHP for processing.
|
||||
*/
|
||||
public function close(): bool
|
||||
{
|
||||
// Nothing to do here
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @return string Returns an encoded string of the read data. If nothing was read, it must
|
||||
* return an empty string. Note this value is returned internally to PHP for processing.
|
||||
*/
|
||||
public function read($id)
|
||||
{
|
||||
if (!$this->validateId($id))
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
$exploded = explode('_', $_COOKIE[$id]);
|
||||
if (count($exploded) !== 2)
|
||||
{
|
||||
return '';
|
||||
}
|
||||
list($hash, $base64_data) = $exploded;
|
||||
$data = base64_decode($base64_data);
|
||||
|
||||
// Check the hash
|
||||
if (!hash_equals($hash, self::getHash($this->algo, $data, $this->secret)))
|
||||
{
|
||||
trigger_error('Cookie has been tampered with or secret may have changed', E_USER_WARNING);
|
||||
return '';
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Decode the data so that we can check the expiry time and IP address
|
||||
session_decode($data);
|
||||
$this->validateSession();
|
||||
// Everything looks OK. Clear the internal data keys and return the data.
|
||||
unset($_SESSION['_ip']);
|
||||
unset($_SESSION['_expiry']);
|
||||
if (false === ($data = session_encode()))
|
||||
{
|
||||
throw new SessionHandlerCookieException('Failed to encode session data');
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
catch (SessionHandlerCookieException $e)
|
||||
{
|
||||
trigger_error($e->getMessage(), E_USER_WARNING);
|
||||
// We have to unset the $_SESSION variable as well as return an empty string because
|
||||
// we have already called session_decode() above.
|
||||
unset($_SESSION);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @return bool The return value (usually TRUE on success, FALSE on failure). Note this value is
|
||||
* returned internally to PHP for processing.
|
||||
*/
|
||||
public function write($id, $data): bool
|
||||
{
|
||||
// Decode the data so that we can set the expiry time and IP address and then encode it again.
|
||||
session_decode($data);
|
||||
assert(!isset($_SESSION['_expiry']), "'_expiry' is a reserved data key");
|
||||
assert(!isset($_SESSION['_ip']), "'_ip' is a reserved data key");
|
||||
// Set the expiry to be the same as the session cookie expiry, or else 0 for browser close
|
||||
$expiry = self::getExpiry();
|
||||
if ($expiry === false)
|
||||
{
|
||||
throw new \Exception('Session expiry time not set');
|
||||
}
|
||||
$_SESSION['_expiry'] = $expiry;
|
||||
if ($this->include_ip)
|
||||
{
|
||||
$_SESSION['_ip'] = $server['REMOTE_ADDR'] ?? null;
|
||||
}
|
||||
$data = session_encode();
|
||||
|
||||
$hash = self::getHash($this->algo, $data, $this->secret);
|
||||
|
||||
return Cookie::set($id, $hash . '_' . base64_encode($data), $expiry);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @return bool The return value (usually TRUE on success, FALSE on failure). Note this value is
|
||||
* returned internally to PHP for processing.
|
||||
*/
|
||||
public function destroy($id): bool
|
||||
{
|
||||
return Cookie::delete($id);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @return bool The return value (usually TRUE on success, FALSE on failure). Note this value is
|
||||
* returned internally to PHP for processing.
|
||||
*/
|
||||
public function gc($max_lifetime)
|
||||
{
|
||||
// Garbage collection is not required
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
public function validateId($id) : bool
|
||||
{
|
||||
// Need to provide this method to circumvent a bug in some versions of PHP.
|
||||
// See https://github.com/php/php-src/issues/9668
|
||||
return isset($_COOKIE[$id]);
|
||||
}
|
||||
|
||||
|
||||
public function updateTimestamp($id, $data) : bool
|
||||
{
|
||||
// We only need to provide this method because it's part of SessionUpdateTimestampHandlerInterface
|
||||
// which we are implementing in order to provide validateId().
|
||||
return $this->write($id, $data);
|
||||
}
|
||||
|
||||
|
||||
private static function getHash(
|
||||
string $algo,
|
||||
string $data,
|
||||
#[\SensitiveParameter]
|
||||
string $key
|
||||
) : string
|
||||
{
|
||||
if (!function_exists('hash_hmac'))
|
||||
{
|
||||
Errors::fatalError("It appears that your PHP has the hash functions " .
|
||||
"disabled, which are required for the 'cookie' " .
|
||||
"session scheme.");
|
||||
}
|
||||
|
||||
return hash_hmac($algo, $data, $key);
|
||||
}
|
||||
|
||||
|
||||
public static function updateExpiry(string $id, int $expiry) : void
|
||||
{
|
||||
$old_id = self::getId();
|
||||
if (($old_id === false) || ($old_id !== $id))
|
||||
{
|
||||
self::setId($id);
|
||||
self::setExpiry($expiry);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the session expiry time from the database.
|
||||
*
|
||||
* @return false|int Returns the session expiry time in seconds, or FALSE if the session expiry time is not set.
|
||||
*/
|
||||
private static function getExpiry()
|
||||
{
|
||||
$result = self::getVariable('session_expiry');
|
||||
return ($result === false) ? false : intval($result);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the session ID from the database.
|
||||
*
|
||||
* @return false|string Returns the session ID, or FALSE if the session ID is not set.
|
||||
*/
|
||||
private static function getId()
|
||||
{
|
||||
return self::getVariable('session_id');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get a variable from the variables table.
|
||||
*
|
||||
* @return false|string Returns the variable value, or FALSE if the variable is not set.
|
||||
*/
|
||||
private static function getVariable(string $name)
|
||||
{
|
||||
$sql_params = [':variable_name' => $name];
|
||||
$sql = "SELECT variable_content
|
||||
FROM " . _tbl('variables') . "
|
||||
WHERE variable_name=:variable_name
|
||||
LIMIT 1";
|
||||
return db()->query_scalar_non_bool($sql, $sql_params);
|
||||
}
|
||||
|
||||
|
||||
private static function setExpiry(int $expiry) : void
|
||||
{
|
||||
self::setVariable('session_expiry', (string)$expiry);
|
||||
}
|
||||
|
||||
|
||||
private static function setId(string $id) : void
|
||||
{
|
||||
self::setVariable('session_id', $id);
|
||||
}
|
||||
|
||||
|
||||
private static function setVariable(string $name, string $value) : void
|
||||
{
|
||||
$sql_params = [];
|
||||
$data = ['variable_name' => $name, 'variable_content' => $value];
|
||||
$sql = db()->syntax_upsert($data, _tbl('variables'), $sql_params, 'variable_name', ['id'], true);
|
||||
db()->command($sql, $sql_params);
|
||||
}
|
||||
|
||||
|
||||
private function validateSession() : void
|
||||
{
|
||||
global $server;
|
||||
|
||||
// Check expiry time
|
||||
if (!isset($_SESSION['_expiry']))
|
||||
{
|
||||
throw new SessionHandlerCookieException('Cookie expiry time not set');
|
||||
}
|
||||
if (($_SESSION['_expiry'] !== 0) && ($_SESSION['_expiry'] <= time()))
|
||||
{
|
||||
throw new SessionHandlerCookieException('Cookie has expired');
|
||||
}
|
||||
|
||||
// Check IP address
|
||||
if ($this->include_ip)
|
||||
{
|
||||
if (isset($_SESSION['_ip']))
|
||||
{
|
||||
if (!isset($server['REMOTE_ADDR']) || ($_SESSION['_ip'] !== $server['REMOTE_ADDR']))
|
||||
{
|
||||
throw new SessionHandlerCookieException('IP address has changed');
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (isset($_SESSION['REMOTE_ADDR']))
|
||||
{
|
||||
throw new SessionHandlerCookieException('IP address should be NULL');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Restore the original error reporting level
|
||||
if (version_compare(MRBS_MIN_PHP_VERSION, '8.0.0') < 0)
|
||||
{
|
||||
error_reporting($old_level);
|
||||
}
|
||||
else
|
||||
{
|
||||
trigger_error("This code can now be removed", E_USER_NOTICE);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\SessionHandler;
|
||||
|
||||
class SessionHandlerCookieException extends \Exception
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\SessionHandler;
|
||||
|
||||
use Defuse\Crypto\Crypto;
|
||||
use Defuse\Crypto\Exception\WrongKeyOrModifiedCiphertextException;
|
||||
use Defuse\Crypto\Key;
|
||||
use MRBS\DB\DBException;
|
||||
use PDOException;
|
||||
use SessionHandlerInterface;
|
||||
use SessionUpdateTimestampHandlerInterface;
|
||||
use function MRBS\_tbl;
|
||||
use function MRBS\db;
|
||||
|
||||
// Suppress deprecation notices until we get to requiring at least PHP 8
|
||||
// because union types, needed for the return types of read() and gc(), are
|
||||
// not supported in PHP 7. Using the #[\ReturnTypeWillChange] attribute
|
||||
// does not help because that was only introduced in PHP 8.1.
|
||||
if (version_compare(MRBS_MIN_PHP_VERSION, '8.0.0') < 0)
|
||||
{
|
||||
$old_level = error_reporting();
|
||||
error_reporting($old_level & ~E_DEPRECATED);
|
||||
}
|
||||
else
|
||||
{
|
||||
trigger_error("This code can now be removed", E_USER_NOTICE);
|
||||
}
|
||||
|
||||
// Use our own PHP session handling by storing sessions in the database. This has three advantages:
|
||||
// (a) it's more secure, especially on shared servers
|
||||
// (b) it avoids problems with ordinary sessions not working because the PHP session save
|
||||
// directory is not writable
|
||||
// (c) it's more resilient in clustered environments
|
||||
//
|
||||
// The class also encrypts the session data, using a random key which is stored in a cookie (based on
|
||||
// https://github.com/ezimuel/PHP-Secure-Session).
|
||||
|
||||
class SessionHandlerDb implements SessionHandlerInterface, SessionUpdateTimestampHandlerInterface
|
||||
{
|
||||
|
||||
/**
|
||||
* A random default key to be used if `$auth["session_php"]["store_key_in_cookie"]` is false.
|
||||
* Not very secure, but it's better than storing session data in plain text.
|
||||
*/
|
||||
private const DEFAULT_ASCII_KEY = 'def000005a41b5af1df304e485dee0d01d34eb4b5463333d5ecbe19020220c357745d1864efd58714e4d5d91591df76f228e1268e47f5f07be9336a244fd2fd561dc798a';
|
||||
private const KEY_COOKIE_PREFIX = 'KEY_';
|
||||
|
||||
private $key;
|
||||
private static $table;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
self::$table = _tbl('sessions');
|
||||
|
||||
// We need to lock the session data while it is in use in order to prevent problems
|
||||
// with Ajax calls. This happens with the default file session handler, but
|
||||
// in order to provide it with the DB session handler we need the ability to set multiple locks.
|
||||
if (!db()->supportsMultipleLocks())
|
||||
{
|
||||
throw new SessionHandlerDbException(
|
||||
"MRBS: database does not support multiple locks.",
|
||||
SessionHandlerDbException::NO_MULTIPLE_LOCKS
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
if (!db()->table_exists(self::$table))
|
||||
{
|
||||
// We throw an exception if the table doesn't exist rather than returning FALSE, because in some
|
||||
// versions of PHP, eg 7.0.25, session_start() will throw a fatal error if it can't open
|
||||
// a session, rather than just returning FALSE as the documentation seems to suggest. So
|
||||
// when a new SessionHandlerDb object is created we do it in a try/catch block. [Note that
|
||||
// the exception can't be thrown on open() because a try/catch round session_start() won't
|
||||
// catch the exception - maybe because open() is a callback function??]
|
||||
throw new SessionHandlerDbException(
|
||||
"MRBS: session table does not exist",
|
||||
SessionHandlerDbException::TABLE_NOT_EXISTS
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The return value (usually TRUE on success, FALSE on failure). Note this value is
|
||||
// returned internally to PHP for processing.
|
||||
public function open($path, $name): bool
|
||||
{
|
||||
try {
|
||||
$this->key = $this->getKey(self::KEY_COOKIE_PREFIX . $name);
|
||||
}
|
||||
catch (\Exception $e) {
|
||||
trigger_error("Failed to get key: " . $e->getMessage(), E_USER_WARNING);
|
||||
// The message below is sometimes seen. Log the key cookie value. It's usually because the cookie value is
|
||||
// 'deleted'. This is because, as the PHP manual says in https://www.php.net/manual/en/function.setcookie.php,
|
||||
// "Cookies must be deleted with the same parameters as they were set with. If the value argument is an empty
|
||||
// string, and all other arguments match a previous call to setcookie(), then the cookie with the specified name
|
||||
// will be deleted from the remote client. This is internally achieved by setting value to 'deleted' and
|
||||
// expiration time in the past."
|
||||
// However it's not clear why the cookie is being read, given that it has been deleted. And as it is being read
|
||||
// can we do some kind of retry and set the key cookie with a proper key so that session_start() does not fail?
|
||||
if (str_contains($e->getMessage(), 'Encoding::hexToBin() input is not a hex string'))
|
||||
{
|
||||
$key_cookie = $_COOKIE[self::KEY_COOKIE_PREFIX . $name];
|
||||
trigger_error("Key cookie: $key_cookie");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// The return value (usually TRUE on success, FALSE on failure). Note this value is
|
||||
// returned internally to PHP for processing.
|
||||
public function close(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// Returns an encoded string of the read data. If nothing was read, it must
|
||||
// return an empty string. Note this value is returned internally to PHP for
|
||||
// processing.
|
||||
public function read($id)
|
||||
{
|
||||
global $dbsys;
|
||||
|
||||
// Acquire mutex to lock the session id. When using the default file session handler
|
||||
// locks are obtained using flock(). We need to do something similar in order to prevent
|
||||
// problems with multiple Ajax requests writing to the S_SESSION variable while
|
||||
// another process is still using it.
|
||||
// Acquire a lock
|
||||
if (!db()->mutex_lock($id))
|
||||
{
|
||||
trigger_error("Failed to acquire a lock", E_USER_WARNING);
|
||||
return '';
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
$sql = "SELECT data
|
||||
FROM " . self::$table . "
|
||||
WHERE id=:id
|
||||
LIMIT 1";
|
||||
|
||||
$result = db()->query_scalar_non_bool($sql, array(':id' => $id));
|
||||
}
|
||||
catch (DBException $e)
|
||||
{
|
||||
// If the exception is because the sessions table doesn't exist, then that's
|
||||
// probably because we're in the middle of the upgrade that creates the
|
||||
// sessions table, so just ignore it and return ''. Otherwise, re-throw
|
||||
// the exception.
|
||||
if (!db()->table_exists(self::$table))
|
||||
{
|
||||
return '';
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
if (!isset($result) || ($result === false))
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
// TODO: fix this properly
|
||||
// In PostgreSQL we store the session base64 encoded. That's because the session data string (encoded by PHP)
|
||||
// can contain NULL bytes when the User object has protected properties. The solution is probably to convert
|
||||
// the data column in PostgreSQL to be bytea rather than text. However this doesn't seem to work for some reason -
|
||||
// no doubt soluble - and also upgrading the database is complicated while the roles branch is still under
|
||||
// development and there are two sets of upgrades to be merged. So for the moment we have this rather inelegant
|
||||
// workaround.
|
||||
// NOTE: this step is probably not necessary anymore, now that the session data is encrypted.
|
||||
if ($dbsys == 'pgsql')
|
||||
{
|
||||
$decoded = base64_decode($result, true);
|
||||
// Test to see if the data is base64 encoded so that we can handle session data written before this change.
|
||||
if (($decoded !== false) && (base64_encode($decoded) === $result))
|
||||
{
|
||||
$result = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$result = Crypto::decrypt($result, $this->key);
|
||||
}
|
||||
catch (WrongKeyOrModifiedCiphertextException $e) {
|
||||
$message = $e->getMessage();
|
||||
if (!str_contains($message, 'Ciphertext has invalid hex encoding'))
|
||||
{
|
||||
// This exception can be caused by (1) the wrong key being used, or (2) the cipher text having
|
||||
// been modified or truncated. The message will generally be "Integrity check failed". None
|
||||
// of these should normally happen. If the cipher text has been truncated, because it was too
|
||||
// long for the database column, then we should have seen an SQL error when the session data
|
||||
// was written.
|
||||
//
|
||||
// Sometimes the integrity check fails because the wrong key is being used. This can happen on
|
||||
// some servers where access to $_COOKIE has been restricted somehow (though not because 'C' has
|
||||
// been removed from variables_order in php.ini because then the session cookie doesn't work either).
|
||||
// If $_COOKIE is not working, then MRBS will generate a new key, which will be different from the
|
||||
// one used to encrypt the session data. If this is happening then set
|
||||
// $auth["session_php"]["store_key_in_cookie"] = false;
|
||||
// See https://github.com/meeting-room-booking-system/mrbs-code/issues/3983
|
||||
trigger_error(get_class($e) . ': ' . $message, E_USER_WARNING);
|
||||
$result = '';
|
||||
}
|
||||
// Otherwise do nothing. This is to handle the case where we are reading old session data before
|
||||
// encryption was introduced, when the session data will almost certainly contain non-hex characters.
|
||||
// So just return the undecrypted data from the database (because it was never encrypted in the first
|
||||
// place).
|
||||
}
|
||||
catch (\Exception $e) {
|
||||
trigger_error("Failed to decrypt session data: " . $e->getMessage(), E_USER_WARNING);
|
||||
$result = '';
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
// The return value (usually TRUE on success, FALSE on failure). Note this value is
|
||||
// returned internally to PHP for processing.
|
||||
public function write($id, $data): bool
|
||||
{
|
||||
global $dbsys;
|
||||
|
||||
try {
|
||||
$data = Crypto::encrypt($data, $this->key);
|
||||
}
|
||||
catch (\Exception $e) {
|
||||
trigger_error("Failed to encrypt session data: " . $e->getMessage(), E_USER_WARNING);
|
||||
return false;
|
||||
}
|
||||
|
||||
// See comment in read()
|
||||
if ($dbsys == 'pgsql')
|
||||
{
|
||||
$data = base64_encode($data);
|
||||
}
|
||||
|
||||
$query_data = array(
|
||||
'id' => $id,
|
||||
'data' => $data,
|
||||
'access' => time()
|
||||
);
|
||||
|
||||
$sql_params = array();
|
||||
$sql = db()->syntax_upsert($query_data, self::$table, $sql_params, 'id');
|
||||
|
||||
// From the MySQL manual:
|
||||
// "With ON DUPLICATE KEY UPDATE, the affected-rows value per row is 1 if the row is inserted as a
|
||||
// new row, 2 if an existing row is updated, and 0 if an existing row is set to its current values.
|
||||
// If you specify the CLIENT_FOUND_ROWS flag to the mysql_real_connect() C API function when connecting
|
||||
// to mysqld, the affected-rows value is 1 (not 0) if an existing row is set to its current values."
|
||||
return (0 < db()->command($sql, $sql_params));
|
||||
}
|
||||
|
||||
|
||||
// The return value (usually TRUE on success, FALSE on failure). Note this value is
|
||||
// returned internally to PHP for processing.
|
||||
public function destroy($id): bool
|
||||
{
|
||||
try
|
||||
{
|
||||
$sql = "DELETE FROM " . self::$table . " WHERE id=:id";
|
||||
db()->command($sql, array(':id' => $id));
|
||||
return true;
|
||||
}
|
||||
catch (\Exception $e)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// The return value (usually TRUE on success, FALSE on failure). Note this value is
|
||||
// returned internally to PHP for processing.
|
||||
public function gc($max_lifetime)
|
||||
{
|
||||
$sql = "DELETE FROM " . self::$table . " WHERE access<:old";
|
||||
db()->command($sql, array(':old' => time() - $max_lifetime));
|
||||
return true; // An exception will be thrown on error
|
||||
}
|
||||
|
||||
|
||||
// Need to provide this method to circumvent a bug in some versions of PHP.
|
||||
// See https://github.com/php/php-src/issues/9668
|
||||
public function validateId($id) : bool
|
||||
{
|
||||
// Acquire a lock
|
||||
if (!db()->mutex_lock($id))
|
||||
{
|
||||
trigger_error("Failed to acquire a lock", E_USER_WARNING);
|
||||
return false;
|
||||
}
|
||||
|
||||
$sql = "SELECT COUNT(*)
|
||||
FROM " . self::$table . "
|
||||
WHERE id=:id
|
||||
LIMIT 1";
|
||||
|
||||
return (db()->query1($sql, array(':id' => $id)) == 1);
|
||||
}
|
||||
|
||||
|
||||
// We only need to provide this method because it's part of SessionUpdateTimestampHandlerInterface
|
||||
// which we are implementing in order to provide validateId().
|
||||
public function updateTimestamp($id, $data) : bool
|
||||
{
|
||||
// Acquire a lock
|
||||
if (!db()->mutex_lock($id))
|
||||
{
|
||||
trigger_error("Failed to acquire a lock", E_USER_WARNING);
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
$sql = "UPDATE " . self::$table . "
|
||||
SET access=:access
|
||||
WHERE id=:id";
|
||||
|
||||
$sql_params = array(
|
||||
':id' => $id,
|
||||
':access' => time()
|
||||
);
|
||||
|
||||
$result = (1 === db()->command($sql, $sql_params));
|
||||
}
|
||||
catch(PDOException $e)
|
||||
{
|
||||
trigger_error($e->getMessage(), E_USER_WARNING);
|
||||
$result = false;
|
||||
}
|
||||
|
||||
// Release the mutex lock
|
||||
db()->mutex_unlock($id);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
// Delete the key cookie, but only if the expiry is not zero.
|
||||
public static function deleteKeyCookie() : void
|
||||
{
|
||||
if (false === ($name = session_name()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (session_get_cookie_params()['lifetime'] !== 0)
|
||||
{
|
||||
$name = self::KEY_COOKIE_PREFIX . $name;
|
||||
unset($_COOKIE[$name]);
|
||||
Cookie::delete($name);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Regenerate the key cookie, setting its expiry to be the same as the session cookie's.
|
||||
public static function regenerateKeyCookie() : void
|
||||
{
|
||||
// No need to do anything if we can't get a session name, or if the expiry is zero (browser close).
|
||||
if ((false === ($name = session_name())) || (0 === ($session_lifetime = session_get_cookie_params()['lifetime'])))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// And no need to do anything if we can't get a cookie value.
|
||||
$name = self::KEY_COOKIE_PREFIX . $name;
|
||||
if (!isset($_COOKIE[$name]))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// But otherwise, set the key cookie lifetime to be the same as the session cookie's.
|
||||
Cookie::set($name, $_COOKIE[$name], time() + $session_lifetime);
|
||||
}
|
||||
|
||||
|
||||
private function getKey(string $name) : Key
|
||||
{
|
||||
global $auth;
|
||||
|
||||
if (!$auth["session_php"]["store_key_in_cookie"])
|
||||
{
|
||||
return Key::loadFromAsciiSafeString(self::DEFAULT_ASCII_KEY);
|
||||
}
|
||||
|
||||
// Get the key from the cookie, or if there isn't one create a random key and
|
||||
// store it in the cookie.
|
||||
if (empty($_COOKIE[$name]))
|
||||
{
|
||||
$key = Key::createNewRandomKey();
|
||||
$ascii_key = $key->saveToAsciiSafeString();
|
||||
$session_lifetime = session_get_cookie_params()['lifetime'];
|
||||
// Set the expiry to be the same as the session cookie expiry, or else 0 for browser close
|
||||
Cookie::set($name, $ascii_key, ($session_lifetime > 0) ? time() + $session_lifetime : 0);
|
||||
$_COOKIE[$name] = $ascii_key;
|
||||
}
|
||||
else
|
||||
{
|
||||
$key = Key::loadFromAsciiSafeString($_COOKIE[$name]);
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// Restore the original error reporting level
|
||||
if (version_compare(MRBS_MIN_PHP_VERSION, '8.0.0') < 0)
|
||||
{
|
||||
error_reporting($old_level);
|
||||
}
|
||||
else
|
||||
{
|
||||
trigger_error("This code can now be removed", E_USER_NOTICE);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\SessionHandler;
|
||||
|
||||
|
||||
class SessionHandlerDbException extends \Exception
|
||||
{
|
||||
const TABLE_NOT_EXISTS = 1;
|
||||
const NO_MULTIPLE_LOCKS = 2;
|
||||
}
|
||||
Reference in New Issue
Block a user