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); }