From d19c814a0ce6866b101ef49b3ecab5aa4a2f44e7 Mon Sep 17 00:00:00 2001 From: "Bruno O. Notario" Date: Tue, 20 Feb 2024 00:19:26 -0300 Subject: [PATCH] Cookie refactoring, added session database handler, session refactoring, remove __destruct on Database Bugfix: __destruct method on DB driver close the connection before write a session --- system/Cookies/Config.php | 145 +++++++++++++ system/Cookies/SameSite.php | 4 + system/Cookies/autoload.php | 164 +++++++++++--- system/MagiQL/MagiQL.php | 39 ++++ system/database/Databases/MySQLi.php | 12 +- system/database/autoload.php | 2 +- system/session/Api/HandlerInterface.php | 31 +++ system/session/Handlers/Database.php | 201 ++++++++++++++++++ .../{Redis/Handler.php => Handlers/Redis.php} | 7 +- system/session/autoload.php | 56 +++-- 10 files changed, 587 insertions(+), 74 deletions(-) create mode 100644 system/Cookies/Config.php create mode 100644 system/session/Api/HandlerInterface.php create mode 100644 system/session/Handlers/Database.php rename system/session/{Redis/Handler.php => Handlers/Redis.php} (95%) diff --git a/system/Cookies/Config.php b/system/Cookies/Config.php new file mode 100644 index 0000000..097a161 --- /dev/null +++ b/system/Cookies/Config.php @@ -0,0 +1,145 @@ +config = $config; + $this->value['sameSite'] = $config->get('cookie_same_site') ?: self::STRICT; + $this->value['expires'] = $config->get('cookie_expires') ?: self::DEFAULT_EXPIRY; + $this->value['path'] = $config->get('cookie_path') ?: self::DEFAULT_PATH; + $this->value['domain'] = $config->get('cookie_domain') ?: self::DEFAULT_DOMAIN; + $this->value['secure'] = $config->get('cookie_secure') !== null ? $config->get('cookie_secure') : ($this->isSecure()); + $this->value['httpOnly'] = $config->get('cookie_http_only') !== null ? $config->get('cookie_http_only') : self::DEFAULT_HTTP_ONLY; + } + + /** @return $this */ + public function setStrict() + { + $this->value['sameSite'] = self::STRICT; + return $this; + } + + /** @return $this */ + public function setLax() + { + $this->value['sameSite'] = self::LAX; + return $this; + } + + /** @return $this */ + public function setNone() + { + $this->value['sameSite'] = self::NONE; + return $this; + } + + /** + * @param string $key + * @return string + */ + public function getValue($key) + { + return $this->value[$key]; + } + + /** @return string[] */ + public function getValues() + { + return $this->value; + } + + /** + * @param string $key + * @param string|int $value + * @return $this + */ + public function setValue($key, $value) { + $this->value[$key] = $value; + return $this; + } + + /** + * Check if is secure (SSL) connection + * @return bool + */ + public function isSecure() + { + return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || $_SERVER['SERVER_PORT'] == 443; + } + + /** @return string */ + public function getSameSite() { + return $this->value['sameSite']; + } + + /** @return string */ + public function getExpires() { + return $this->value['expires']; + } + + /** @return string */ + public function getPath() { + return $this->value['path']; + } + + /** @return string */ + public function getDomain() { + return $this->value['domain']; + } + + /** @return string */ + public function getSecure() { + return $this->value['secure']; + } + + /** @return string */ + public function getHttpOnly() { + return $this->value['httpOnly']; + } + + /** + * + * @return $this + */ + static public function getInstance() + { + $class = get_called_class(); + return \Phacil\Framework\Registry::getAutoInstance((new $class())); + } +} diff --git a/system/Cookies/SameSite.php b/system/Cookies/SameSite.php index 4f57043..213c6be 100644 --- a/system/Cookies/SameSite.php +++ b/system/Cookies/SameSite.php @@ -8,6 +8,10 @@ namespace Phacil\Framework\Cookies; +/** + * @since 2.0.0 + * @package Phacil\Framework\Cookies + */ class SameSite { const STRICT = 'Strict'; diff --git a/system/Cookies/autoload.php b/system/Cookies/autoload.php index 7720a43..326668f 100644 --- a/system/Cookies/autoload.php +++ b/system/Cookies/autoload.php @@ -9,6 +9,7 @@ namespace Phacil\Framework; use Phacil\Framework\Cookies\SameSite; +use Phacil\Framework\Cookies\Config as CookieConfig; /** * @since 2.0.0 @@ -20,51 +21,56 @@ class Cookies { * * @var int */ - private $expiry = 0; + private $expiry; /** * * @var string */ - private $path = '/'; + private $path; /** * * @var string */ - private $domain = ''; + private $domain; /** * * @var bool */ - private $secure = true; + private $secure; /** * * @var bool */ - private $httpOnly = true; + private $httpOnly; - private $sameSite = SameSite::STRICT; + /** + * + * @var string + */ + private $sameSite; /** * - * @param int $expiry - * @param string $path - * @param string $domain - * @param bool $secure - * @param bool $httpOnly - * @param string $sameSite + * @var \Phacil\Framework\Cookies\Config + */ + private $config; + + private $cookie_key = null; + + private $cookie_value = null; + + /** + * @param \Phacil\Framework\Cookies\Config $config * @return void */ - public function __construct($expiry = 0, $path = '/', $domain = "", $secure = true, $httpOnly = true, $sameSite = SameSite::STRICT){ - $this->expiry = $expiry; - $this->path = $path; - $this->domain = $domain; - $this->secure = $secure; - $this->httpOnly = $httpOnly; - $this->sameSite = $sameSite; + public function __construct( + CookieConfig $config + ){ + $this->config = $config; } /** @@ -78,27 +84,27 @@ class Cookies { { if (version_compare(phpversion(), "7.3.0", "<")) { if($isRaw){ - return setrawcookie($name, $value, $this->expiry, $this->path . "; samesite=".$this->sameSite, $this->domain, $this->secure, $this->httpOnly); + return setrawcookie($name, $value, $this->getExpires(), $this->getPath() . "; samesite=".$this->getSameSite(), $this->getDomain(), $this->getSecure(), $this->getHttpOnly()); } - return setcookie($name, $value, $this->expiry, $this->path. "; samesite=".$this->sameSite, $this->domain, $this->secure, $this->httpOnly); + return setcookie($name, $value, $this->getExpires(), $this->getPath(). "; samesite=".$this->getSameSite(), $this->getDomain(), $this->getSecure(), $this->getHttpOnly()); } else { if ($isRaw) { return setrawcookie($name, $value, [ - 'expires' => $this->expiry, - 'path' => $this->path, - 'domain' => $this->domain, - 'secure' => $this->secure, - 'httponly' => $this->httpOnly, - 'samesite' => $this->sameSite + 'expires' => $this->getExpires(), + 'path' => $this->getPath(), + 'domain' => $this->getDomain(), + 'secure' => $this->getSecure(), + 'httponly' => $this->getHttpOnly(), + 'samesite' => $this->getSameSite() ]); } return setcookie($name, $value, [ - 'expires' => $this->expiry, - 'path' => $this->path, - 'domain' => $this->domain, - 'secure' => $this->secure, - 'httponly' => $this->httpOnly, - 'samesite' => $this->sameSite + 'expires' => $this->getExpires(), + 'path' => $this->getPath(), + 'domain' => $this->getDomain(), + 'secure' => $this->getSecure(), + 'httponly' => $this->getHttpOnly(), + 'samesite' => $this->getSameSite() ]); } //return $this; @@ -123,6 +129,11 @@ class Cookies { return $this; } + /** @return int|string */ + public function getExpires(){ + return $this->expiry ?: $this->config->getExpires(); + } + /** * @param string $path * @return $this @@ -132,6 +143,11 @@ class Cookies { return $this; } + /** @return string */ + public function getPath(){ + return $this->path ?: $this->config->getPath(); + } + /** * @param string $domain * @return $this @@ -141,6 +157,11 @@ class Cookies { return $this; } + /** @return string */ + public function getDomain() { + return $this->domain ?: $this->config->getDomain(); + } + /** * @param bool $secure * @return $this @@ -150,6 +171,11 @@ class Cookies { return $this; } + /** @return bool|string */ + public function getSecure() { + return $this->secure ?: $this->config->getSecure(); + } + /** * @param bool $httpOnly * @return $this @@ -159,6 +185,11 @@ class Cookies { return $this; } + /** @return bool|string */ + public function getHttpOnly() { + return $this->httpOnly !== null ? $this->httpOnly :$this->config->getHttpOnly(); + } + /** * * @param \Phacil\Framework\Cookies\SameSite $sameSite @@ -169,6 +200,73 @@ class Cookies { return $this; } + /** @return string */ + public function getSameSite(){ + return $this->sameSite ?: $this->config->getSameSite(); + } + + /** + * @param string $cookieName + * @return mixed|null + */ + public function getCookieValue($cookieName){ + return \Phacil\Framework\Request::COOKIE($cookieName); + } + + /** + * @param string $cookieName + * @return mixed|null + */ + public function get($cookieName){ + return $this->getCookieValue($cookieName); + } + + /** + * @param string $cookieName + * @param mixed $value + * @return bool + */ + public function set($cookieName, $value) { + if($this->setCookie($cookieName, $value)){ + \Phacil\Framework\Request::COOKIE($cookieName, $value); + return true; + } + return false; + } + + public function setKey($key) { + if(!is_string($key)) throw new \Phacil\Framework\Exception\InvalidArgumentException('Invalid cookie key value'); + + $this->cookie_key = $key; + return $this; + } + + /** + * @param mixed $value + * @return $this + */ + public function setValue($value) { + $this->cookie_value = $value; + return $this; + } + + /** + * @return bool + * @throws \Phacil\Framework\Exception\InvalidArgumentException + */ + public function save() { + if(!empty($this->cookie_key)) { + return $this->set($this->cookie_key, $this->cookie_value); + } + + throw new \Phacil\Framework\Exception\InvalidArgumentException('Cookie key value is required.'); + } + + /** @return array */ + public function getCookies() { + return \Phacil\Framework\Request::COOKIE(); + } + /** * @param string $name * @return void diff --git a/system/MagiQL/MagiQL.php b/system/MagiQL/MagiQL.php index dbd468f..ac5dae6 100644 --- a/system/MagiQL/MagiQL.php +++ b/system/MagiQL/MagiQL.php @@ -53,6 +53,45 @@ class MagiQL extends Builder { return $this->db->execute($query, $values); } + /** + * @param string $tableName + * @return bool + * @throws \Phacil\Framework\Exception + */ + public function isTableExists($tableName) { + if($this->db->getDBTypeId() == \Phacil\Framework\Interfaces\Databases::LIST_DB_TYPE_ID['MYSQL']){ + $sql = 'SELECT (1) AS tbl_exists FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = :v1 AND TABLE_SCHEMA = :v2'; + $result = $this->db->execute($sql, [ + ':v1' => $tableName, + ':v2' => \Phacil\Framework\Config::DB_DATABASE() + ]); + } + if($this->db->getDBTypeId() == \Phacil\Framework\Interfaces\Databases::LIST_DB_TYPE_ID['MSSQL']){ + $sql = "SELECT OBJECT_ID(:v1, 'U') AS table_id"; + $result = $this->db->execute($sql, [ + ':v1' => $tableName + ]); + } + if($this->db->getDBTypeId() == \Phacil\Framework\Interfaces\Databases::LIST_DB_TYPE_ID['POSTGRE']){ + $sql = "SELECT 1 FROM information_schema.tables WHERE table_name = :v1"; + $result = $this->db->execute($sql, [ + ':v1' => $tableName + ]); + } + if($this->db->getDBTypeId() == \Phacil\Framework\Interfaces\Databases::LIST_DB_TYPE_ID['SQLLITE3']){ + $sql = "SELECT name FROM sqlite_master WHERE type='table' AND name=:v1"; + $result = $this->db->execute($sql, [ + ':v1' => $tableName + ]); + } + + if($result && $result->getNumRows() > 0){ + return true; + } + + return false; + } + public function __call($name, $arguments = array()){ return call_user_func_array([$this->queryObj, $name], $arguments); diff --git a/system/database/Databases/MySQLi.php b/system/database/Databases/MySQLi.php index 96487da..6129c47 100644 --- a/system/database/Databases/MySQLi.php +++ b/system/database/Databases/MySQLi.php @@ -133,7 +133,7 @@ class MySQLi implements Databases { /** @return void */ public function __destruct() { - $this->connection->close(); + //$this->connection->close(); } /** @@ -165,15 +165,15 @@ class MySQLi implements Databases { $stmt = $this->connection->prepare($sql); + if (!$stmt) { + throw new \Phacil\Framework\Exception('Error preparing query: ' . $this->connection->error); + } + array_unshift($bindParams, $types); call_user_func_array([$stmt, 'bind_param'], $bindParams); } else { $stmt = $this->connection->prepare($sql); - } - - if ($stmt === false) { - throw new \Phacil\Framework\Exception('Error preparing query: ' . $this->connection->error); - } + } $result_exec = $stmt->execute(); diff --git a/system/database/autoload.php b/system/database/autoload.php index 2398e63..f4b117c 100644 --- a/system/database/autoload.php +++ b/system/database/autoload.php @@ -94,7 +94,7 @@ class Database implements DatabaseApi { * {@inheritdoc} */ public function __destruct() { - unset($this->driver); + //unset($this->driver); } /** diff --git a/system/session/Api/HandlerInterface.php b/system/session/Api/HandlerInterface.php new file mode 100644 index 0000000..fa03334 --- /dev/null +++ b/system/session/Api/HandlerInterface.php @@ -0,0 +1,31 @@ +_sessionTable = self::TABLE_NAME; + $this->connection = $resource->query(); + $this->checkConnection(); + $this->encryptor = $encryptor; + } + + public function getFailedLockAttempts() { } + + public function setName($name) { } + + private function hashed($hash) { + //return $this->encryptor->hash($hash); + return $hash; + } + + /** + * Check DB connection + * + * @return void + * @throws \Magento\Framework\Exception\SessionException + */ + protected function checkConnection() + { + if (!$this->connection) { + throw new \Phacil\Framework\Exception( + "The write connection to the database isn't available. Please try again later." + ); + } + if (!$this->connection->isTableExists($this->_sessionTable)) { + throw new \Phacil\Framework\Exception( + "The database storage table doesn't exist. Verify the table and try again." + ); + } + } + + /** + * Open session + * + * @param string $savePath ignored + * @param string $sessionName ignored + * @return bool + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + #[\ReturnTypeWillChange] + public function open($savePath, $sessionName) + { + return true; + } + + /** + * Close session + * + * @return bool + */ + #[\ReturnTypeWillChange] + public function close() + { + return true; + } + + /** + * Fetch session data + * + * @param string $sessionId + * @return string + */ + #[\ReturnTypeWillChange] + public function read($sessionId) + { + // need to use write connection to get the most fresh DB sessions + $select = $this->connection->select()->from( + $this->_sessionTable + ); + $select->where()->eq(self::COLUMN_ID, $this->hashed($sessionId))->end(); + + $data = $select->load(); + + if($data->getNumRows() < 1){ + return null; + } + + $data = $data->getRow()->getValue(self::COLUMN_DATA); + + // check if session data is a base64 encoded string + $decodedData = base64_decode($data, true); + if ($decodedData !== false) { + $data = $decodedData; + } + return $data; + } + + /** + * Update session + * + * @param string $sessionId + * @param string $sessionData + * @return bool + */ + #[\ReturnTypeWillChange] + public function write($sessionId, $sessionData) + { + $hashedSessionId = $this->hashed($sessionId); + $select = $this->connection->select()->from($this->_sessionTable); + $select->where()->eq(self::COLUMN_ID, $hashedSessionId)->end(); + $exists = $select->load(); + + // encode session serialized data to prevent insertion of incorrect symbols + $sessionData = base64_encode($sessionData); + $bind = [self::COLUMN_EXPIRES => time(), self::COLUMN_DATA => $sessionData]; + + if ($exists->getNumRows() > 0) { + $update = $this->connection->update($this->_sessionTable, $bind); + $update->where()->eq(self::COLUMN_ID, $hashedSessionId)->end(); + $update->load(); + } else { + $bind[self::COLUMN_ID] = $hashedSessionId; + $this->connection->insert($this->_sessionTable, $bind)->load(); + } + return true; + } + + /** + * Destroy session + * + * @param string $sessionId + * @return bool + */ + #[\ReturnTypeWillChange] + public function destroy($sessionId) + { + $del = $this->connection->delete($this->_sessionTable); + $del->where()->eq(self::COLUMN_ID, $this->hashed($sessionId))->end(); + return $del->load(); + } + + /** + * Garbage collection + * + * @param int $maxLifeTime + * @return bool + * @SuppressWarnings(PHPMD.ShortMethodName) + */ + #[\ReturnTypeWillChange] + public function gc($maxLifeTime) + { + $del = $this->connection->delete($this->_sessionTable); + $del->where()->lessThan(self::COLUMN_EXPIRES, time() - $maxLifeTime); + return $del->load(); + } +} diff --git a/system/session/Redis/Handler.php b/system/session/Handlers/Redis.php similarity index 95% rename from system/session/Redis/Handler.php rename to system/session/Handlers/Redis.php index 0a1f89b..465c96e 100644 --- a/system/session/Redis/Handler.php +++ b/system/session/Handlers/Redis.php @@ -6,7 +6,7 @@ * Phacil PHP Framework - https://github.com/exacti/phacil-framework */ -namespace Phacil\Framework\Session\Redis; +namespace Phacil\Framework\Session\Handlers; use Cm\RedisSession\Handler\ConfigInterface; use Cm\RedisSession\Handler\LoggerInterface; @@ -14,8 +14,7 @@ use Cm\RedisSession\ConnectionFailedException; use Cm\RedisSession\ConcurrentConnectionsExceededException; use Phacil\Framework\Exception; -class Handler implements \SessionHandlerInterface { - +class Redis implements \Phacil\Framework\Session\Api\HandlerInterface { private $savePath; @@ -72,8 +71,10 @@ class Handler implements \SessionHandlerInterface { return $this->connection[$pid]; } + /** {@inheritdoc} */ function setName($name){ $this->name = $name; + return $this; } /** diff --git a/system/session/autoload.php b/system/session/autoload.php index b4ffedc..b8a5438 100644 --- a/system/session/autoload.php +++ b/system/session/autoload.php @@ -8,15 +8,14 @@ namespace Phacil\Framework; -use Phacil\Framework\Config; +use Phacil\Framework\Config as ConfigFramework; +use Phacil\Framework\Cookies\Config as CookieConfig; /** * The session manipulation class * * You can activate the Redis session instead use the default PHP session manipulation. * - * @param bool $redis Active or not the Redis session - * * @since 1.0.0 * @package Phacil\Framework */ @@ -63,28 +62,26 @@ class Session /** * - * @var \Phacil\Framework\Cookies\SameSite + * @var \Phacil\Framework\Cookies\Config */ - private $sameSite; + private $cookieConfig; /** * - * @param bool $redis - * @param string|null $redisDSN - * @param int|null $redisPort - * @param string|null $redisPass - * @param int|null $redis_expire - * @param string $redis_prefix + * @param \Phacil\Framework\Registry $registry + * @param \Phacil\Framework\Config $config + * @param \Phacil\Framework\Cookies\Config $cookieConfig * @return void + * @throws \Phacil\Framework\Exception */ public function __construct( \Phacil\Framework\Registry $registry, - Config $config, - \Phacil\Framework\Cookies\SameSite $sameSite + ConfigFramework $config, + CookieConfig $cookieConfig ) { $this->registry = $registry; - $this->sameSite = $sameSite; + $this->cookieConfig = $cookieConfig; $this->name = (Config::SESSION_PREFIX() ?: 'SESS') . (isset($_SERVER['REMOTE_ADDR']) ? md5($_SERVER['REMOTE_ADDR']) : md5(date("dmY"))); @@ -116,17 +113,19 @@ class Session ini_set('session.use_cookies', 'On'); ini_set('session.use_trans_sid', 'Off'); - ini_set('session.cookie_httponly', 1); - if ($this->isSecure()) - ini_set('session.cookie_secure', 1); + ini_set('session.cookie_httponly', $this->cookieConfig->getHttpOnly()); + ini_set('session.cookie_secure', $this->cookieConfig->getSecure()); if (version_compare(phpversion(), "7.3.0", "<")) { - session_set_cookie_params(0, '/; samesite=' . $this->sameSite->getValue()); + session_set_cookie_params($this->cookieConfig->getExpires(), $this->cookieConfig->getPath().'; samesite=' . $this->cookieConfig->getSameSite(), $this->cookieConfig->getDomain(), $this->cookieConfig->getSecure(), $this->cookieConfig->getHttpOnly()); } else { session_set_cookie_params([ - 'lifetime' => 0, - 'path' => '/', - 'samesite' => $this->sameSite->getValue() + 'lifetime' => $this->cookieConfig->getExpires(), + 'path' => $this->cookieConfig->getPath(), + 'samesite' => $this->cookieConfig->getSameSite(), + 'domain' => $this->cookieConfig->getDomain(), + 'secure' => $this->cookieConfig->getSecure(), + 'httponly' => $this->cookieConfig->getHttpOnly(), ]); } //session_id(md5()); @@ -152,7 +151,11 @@ class Session if (!$redis) return false; - $this->saveHandler = $this->registry->getInstance(\Phacil\Framework\Session\Redis\Handler::class); + if (!\Phacil\Framework\Registry::checkPreferenceExist(\Phacil\Framework\Session\Api\HandlerInterface::class)) { + \Phacil\Framework\Registry::addDIPreference(\Phacil\Framework\Session\Api\HandlerInterface::class, \Phacil\Framework\Session\Handlers\Redis::class); + } + + $this->saveHandler = $this->registry->getInstance(\Phacil\Framework\Session\Api\HandlerInterface::class); $this->saveHandler->setName($this->name); @@ -192,15 +195,6 @@ class Session } } - /** - * Check if is secure (SSL) connection - * @return bool - */ - private function isSecure() - { - return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || $_SERVER['SERVER_PORT'] == 443; - } - /** * Flush all session data * @return void