From: Manuel Flandorfer Date: Wed, 6 Dec 2023 16:16:54 +0000 (+0000) Subject: Add a custom SessionHandler for user sessions X-Git-Url: https://vcs.fsf.org/?a=commitdiff_plain;h=c5123ef5a8ac1dd6d2b56c28e2ebcf55e739e137;p=civicrm-core.git Add a custom SessionHandler for user sessions --- diff --git a/CRM/Logging/Schema.php b/CRM/Logging/Schema.php index 70cc37c30a..d539e1b393 100644 --- a/CRM/Logging/Schema.php +++ b/CRM/Logging/Schema.php @@ -162,6 +162,9 @@ AND TABLE_NAME LIKE 'civicrm_%' // dev/core#1762 Don't log subscription_history $this->tables = preg_grep('/^civicrm_subscription_history/', $this->tables, PREG_GREP_INVERT); + // Don't log sessions + $this->tables = preg_grep('/^civicrm_session/', $this->tables, PREG_GREP_INVERT); + // do not log civicrm_mailing_recipients table, CRM-16193 $this->tables = array_diff($this->tables, ['civicrm_mailing_recipients']); $this->logTableSpec = array_fill_keys($this->tables, []); diff --git a/CRM/Utils/System/Standalone.php b/CRM/Utils/System/Standalone.php index 18f5b7bfc7..070f596d07 100644 --- a/CRM/Utils/System/Standalone.php +++ b/CRM/Utils/System/Standalone.php @@ -16,6 +16,7 @@ */ use Civi\Standalone\Security; +use Civi\Standalone\SessionHandler; /** * Standalone specific stuff goes here. @@ -590,4 +591,23 @@ class CRM_Utils_System_Standalone extends CRM_Utils_System_Base { // TODO: Prettier error page } + /** + * Start a new session. + */ + public function sessionStart() { + $session_handler = new SessionHandler(); + session_set_save_handler($session_handler); + + $session_max_lifetime = Civi::settings()->get('standaloneusers_session_max_lifetime'); + + session_start([ + 'cookie_httponly' => 1, + 'gc_maxlifetime' => $session_max_lifetime, + 'name' => 'CIVISOSESSID', + 'use_cookies' => 1, + 'use_only_cookies' => 1, + 'use_strict_mode' => 1, + ]); + } + } diff --git a/ext/standaloneusers/CRM/Standaloneusers/BAO/Session.php b/ext/standaloneusers/CRM/Standaloneusers/BAO/Session.php new file mode 100644 index 0000000000..bd877ee76b --- /dev/null +++ b/ext/standaloneusers/CRM/Standaloneusers/BAO/Session.php @@ -0,0 +1,74 @@ +prepare("DELETE FROM $table_name WHERE last_accessed < ?"); + $db->execute($stmt, $expiration_date); + } + + /** + * Delete a session with a specific session ID + * + * @param DB_mysqli $db + * @param string $session_id + * @return void + */ + public static function destroy($db, $session_id) { + $table_name = self::$_tableName; + $stmt = $db->prepare("DELETE FROM $table_name WHERE session_id = ?"); + $db->execute($stmt, $session_id); + } + + /** + * Read serialized session data + * + * @param DB_mysqli $db + * @param string $session_id + * @return string + */ + public static function read($db, $session_id) { + $table_name = self::$_tableName; + $stmt = $db->prepare("SELECT * FROM $table_name WHERE session_id = ? FOR UPDATE"); + + return $db->execute($stmt, $session_id)->fetchRow(DB_FETCHMODE_ASSOC); + } + + /** + * Update session data or just the last_accessed timestamp if no data is provided + * + * @param DB_mysqli $db + * @param string $session_id + * @param string $data + * @return void + */ + public static function write($db, $session_id, $data = NULL) { + $table_name = self::$_tableName; + + if (is_null($data)) { + $stmt = $db->prepare(" + INSERT INTO $table_name (session_id, last_accessed) VALUES (?, NOW()) + ON DUPLICATE KEY UPDATE last_accessed = NOW() + "); + + $db->execute($stmt, $session_id); + } + else { + $stmt = $db->prepare(" + INSERT INTO $table_name (session_id, data, last_accessed) VALUES (?, ?, NOW()) + ON DUPLICATE KEY UPDATE data = ?, last_accessed = NOW() + "); + + $db->execute($stmt, [$session_id, $data, $data]); + } + } + +} diff --git a/ext/standaloneusers/CRM/Standaloneusers/DAO/Session.php b/ext/standaloneusers/CRM/Standaloneusers/DAO/Session.php new file mode 100644 index 0000000000..230951ff67 --- /dev/null +++ b/ext/standaloneusers/CRM/Standaloneusers/DAO/Session.php @@ -0,0 +1,262 @@ +__table = 'civicrm_session'; + parent::__construct(); + } + + /** + * Returns localized title of this entity. + * + * @param bool $plural + * Whether to return the plural version of the title. + */ + public static function getEntityTitle($plural = FALSE) { + return $plural ? E::ts('Sessions') : E::ts('Session'); + } + + /** + * Returns all the column names of this table + * + * @return array + */ + public static function &fields() { + if (!isset(Civi::$statics[__CLASS__]['fields'])) { + Civi::$statics[__CLASS__]['fields'] = [ + 'id' => [ + 'name' => 'id', + 'type' => CRM_Utils_Type::T_INT, + 'title' => E::ts('ID'), + 'description' => E::ts('Unique Session ID'), + 'required' => TRUE, + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_session.id', + 'table_name' => 'civicrm_session', + 'entity' => 'Session', + 'bao' => 'CRM_Standaloneusers_DAO_Session', + 'localizable' => 0, + 'html' => [ + 'type' => 'Text', + ], + 'readonly' => TRUE, + 'add' => NULL, + ], + 'session_id' => [ + 'name' => 'session_id', + 'type' => CRM_Utils_Type::T_STRING, + 'title' => E::ts('Session ID'), + 'description' => E::ts('Hexadecimal Session Identifier'), + 'required' => TRUE, + 'maxlength' => 64, + 'size' => CRM_Utils_Type::BIG, + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_session.session_id', + 'table_name' => 'civicrm_session', + 'entity' => 'Session', + 'bao' => 'CRM_Standaloneusers_DAO_Session', + 'localizable' => 0, + 'html' => [ + 'type' => 'Text', + ], + 'add' => NULL, + ], + 'data' => [ + 'name' => 'data', + 'type' => CRM_Utils_Type::T_LONGTEXT, + 'title' => E::ts('Data'), + 'description' => E::ts('Session Data'), + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_session.data', + 'table_name' => 'civicrm_session', + 'entity' => 'Session', + 'bao' => 'CRM_Standaloneusers_DAO_Session', + 'localizable' => 0, + 'add' => NULL, + ], + 'last_accessed' => [ + 'name' => 'last_accessed', + 'type' => CRM_Utils_Type::T_DATE + CRM_Utils_Type::T_TIME, + 'title' => E::ts('Last Accessed'), + 'description' => E::ts('Timestamp of the last session access'), + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_session.last_accessed', + 'table_name' => 'civicrm_session', + 'entity' => 'Session', + 'bao' => 'CRM_Standaloneusers_DAO_Session', + 'localizable' => 0, + 'add' => NULL, + ], + ]; + CRM_Core_DAO_AllCoreTables::invoke(__CLASS__, 'fields_callback', Civi::$statics[__CLASS__]['fields']); + } + return Civi::$statics[__CLASS__]['fields']; + } + + /** + * Return a mapping from field-name to the corresponding key (as used in fields()). + * + * @return array + * Array(string $name => string $uniqueName). + */ + public static function &fieldKeys() { + if (!isset(Civi::$statics[__CLASS__]['fieldKeys'])) { + Civi::$statics[__CLASS__]['fieldKeys'] = array_flip(CRM_Utils_Array::collect('name', self::fields())); + } + return Civi::$statics[__CLASS__]['fieldKeys']; + } + + /** + * Returns the names of this table + * + * @return string + */ + public static function getTableName() { + return self::$_tableName; + } + + /** + * Returns if this table needs to be logged + * + * @return bool + */ + public function getLog() { + return self::$_log; + } + + /** + * Returns the list of fields that can be imported + * + * @param bool $prefix + * + * @return array + */ + public static function &import($prefix = FALSE) { + $r = CRM_Core_DAO_AllCoreTables::getImports(__CLASS__, 'session', $prefix, []); + return $r; + } + + /** + * Returns the list of fields that can be exported + * + * @param bool $prefix + * + * @return array + */ + public static function &export($prefix = FALSE) { + $r = CRM_Core_DAO_AllCoreTables::getExports(__CLASS__, 'session', $prefix, []); + return $r; + } + + /** + * Returns the list of indices + * + * @param bool $localize + * + * @return array + */ + public static function indices($localize = TRUE) { + $indices = [ + 'index_session_id' => [ + 'name' => 'index_session_id', + 'field' => [ + 0 => 'session_id', + ], + 'localizable' => FALSE, + 'unique' => TRUE, + 'sig' => 'civicrm_session::1::session_id', + ], + ]; + return ($localize && !empty($indices)) ? CRM_Core_DAO_AllCoreTables::multilingualize(__CLASS__, $indices) : $indices; + } + +} diff --git a/ext/standaloneusers/CRM/Standaloneusers/Upgrader.php b/ext/standaloneusers/CRM/Standaloneusers/Upgrader.php index ff208b3c45..5a2b226056 100644 --- a/ext/standaloneusers/CRM/Standaloneusers/Upgrader.php +++ b/ext/standaloneusers/CRM/Standaloneusers/Upgrader.php @@ -189,4 +189,16 @@ class CRM_Standaloneusers_Upgrader extends CRM_Extension_Upgrader_Base { // return TRUE; // } + /** + * Create table civicrm_session + * + * @return TRUE on success + * @throws Exception + */ + public function upgrade_5691(): bool { + $this->ctx->log->info('Applying update 5691'); + $this->executeSqlFile('sql/upgrade_5691.sql'); + return TRUE; + } + } diff --git a/ext/standaloneusers/Civi/Api4/Session.php b/ext/standaloneusers/Civi/Api4/Session.php new file mode 100644 index 0000000000..882b9a6d23 --- /dev/null +++ b/ext/standaloneusers/Civi/Api4/Session.php @@ -0,0 +1,13 @@ +reset(); + session_destroy(); } /** diff --git a/ext/standaloneusers/Civi/Standalone/SessionHandler.php b/ext/standaloneusers/Civi/Standalone/SessionHandler.php new file mode 100644 index 0000000000..4d24071dd0 --- /dev/null +++ b/ext/standaloneusers/Civi/Standalone/SessionHandler.php @@ -0,0 +1,165 @@ +db->commit(); + $this->db->autoCommit(TRUE); + + if ($this->collectGarbage) { + $expiration_date = date('Y-m-d H:i:s', strtotime("- {$this->maxLifetime} seconds")); + Session::deleteExpired($this->db, $expiration_date); + } + + $this->collectGarbage = FALSE; + unset($this->data); + + return TRUE; + } + + /** + * Create a unique session ID + * + * @return string + */ + public function create_sid(): string { + return bin2hex(random_bytes(32)); + } + + /** + * Destroys a session. Called by session_regenerate_id() (with $destroy = true), + * session_destroy() and when session_decode() fails + * + * @param string $id + * @return bool + */ + public function destroy($id): bool { + Session::destroy($this->db, $id); + + return TRUE; + } + + /** + * Cleans up expired sessions. Called by session_start(), based on + * session.gc_divisor, session.gc_probability and session.gc_maxlifetime settings + * + * @param int $max_lifetime + * @return int|false + */ + public function gc($max_lifetime): int { + $this->collectGarbage = TRUE; + $this->maxLifetime = $max_lifetime; + + return 0; + } + + /** + * Re-initialize existing session, or creates a new one. Called when a session + * starts or when session_start() is invoked + * + * @param string $path + * @param string $name + * @return bool + */ + public function open($path, $name): bool { + $this->db = DB::connect(\CRM_Core_Config::singleton()->dsn); + $this->db->autoCommit(FALSE); + + return TRUE; + } + + /** + * Reads the session data from the session storage, and returns the results. + * Called right after the session starts or when session_start() is called + * + * @param string $id + * @return string|false + */ + public function read($id): string { + return $this->data ?? ''; + } + + /** + * Updates the last modification timestamp of the session. This function is + * automatically executed when a session is updated. + * + * @param string $id + * @param string $data + * @return bool + */ + public function updateTimestamp($id, $data): bool { + Session::write($this->db, $id); + + return TRUE; + } + + /** + * Validates a given session ID. A session ID is valid, if a session with that + * ID already exists. This function is automatically executed when a session + * is to be started, a session ID is supplied and session.use_strict_mode is enabled. + * + * @param string $id + * @return bool + */ + public function validateId($id): bool { + $session = Session::read($this->db, $id); + + if (is_null($session)) { + return FALSE; + } + + $this->data = $session['data']; + $max_lifetime = \Civi::settings()->get('standaloneusers_session_max_lifetime'); + + return strtotime($session['last_accessed']) >= strtotime("-$maxLifetime seconds"); + } + + /** + * Writes the session data to the session storage. Called by session_write_close(), + * when session_register_shutdown() fails, or during a normal shutdown. + * + * @param string $id + * @param string $data + * @return bool + */ + public function write($id, $data): bool { + Session::write($this->db, $id, $data); + + return TRUE; + } + +} diff --git a/ext/standaloneusers/settings/standaloneusers.setting.php b/ext/standaloneusers/settings/standaloneusers.setting.php new file mode 100644 index 0000000000..8750edce2c --- /dev/null +++ b/ext/standaloneusers/settings/standaloneusers.setting.php @@ -0,0 +1,15 @@ + [ + 'name' => 'standaloneusers_session_max_lifetime', + 'type' => 'Integer', + 'title' => ts('Maxiumum Session Lifetime'), + 'description' => ts('Duration (in seconds) until a user session expires'), + // 24 days (= Drupal default) + 'default' => 24 * 24 * 60 * 60, + 'html_type' => 'text', + 'is_domain' => 1, + 'is_contact' => 0, + ], +]; diff --git a/ext/standaloneusers/sql/auto_install.sql b/ext/standaloneusers/sql/auto_install.sql index 9a482bb76e..ecaaf3c9d6 100644 --- a/ext/standaloneusers/sql/auto_install.sql +++ b/ext/standaloneusers/sql/auto_install.sql @@ -18,6 +18,7 @@ SET FOREIGN_KEY_CHECKS=0; DROP TABLE IF EXISTS `civicrm_uf_match`; +DROP TABLE IF EXISTS `civicrm_session`; DROP TABLE IF EXISTS `civicrm_role`; SET FOREIGN_KEY_CHECKS=1; @@ -44,6 +45,23 @@ CREATE TABLE `civicrm_role` ( ) ENGINE=InnoDB; +-- /******************************************************* +-- * +-- * civicrm_session +-- * +-- * Standalone User Sessions +-- * +-- *******************************************************/ +CREATE TABLE `civicrm_session` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT 'Unique Session ID', + `session_id` char(64) NOT NULL COMMENT 'Hexadecimal Session Identifier', + `data` longtext COMMENT 'Session Data', + `last_accessed` datetime COMMENT 'Timestamp of the last session access', + PRIMARY KEY (`id`), + UNIQUE INDEX `index_session_id`(session_id) +) +ENGINE=InnoDB; + -- /******************************************************* -- * -- * civicrm_uf_match diff --git a/ext/standaloneusers/sql/auto_uninstall.sql b/ext/standaloneusers/sql/auto_uninstall.sql index 35c0c090b4..5e606032ef 100644 --- a/ext/standaloneusers/sql/auto_uninstall.sql +++ b/ext/standaloneusers/sql/auto_uninstall.sql @@ -18,6 +18,7 @@ SET FOREIGN_KEY_CHECKS=0; DROP TABLE IF EXISTS `civicrm_uf_match`; +DROP TABLE IF EXISTS `civicrm_session`; DROP TABLE IF EXISTS `civicrm_role`; SET FOREIGN_KEY_CHECKS=1; \ No newline at end of file diff --git a/ext/standaloneusers/sql/upgrade_5691.sql b/ext/standaloneusers/sql/upgrade_5691.sql new file mode 100644 index 0000000000..78312767ee --- /dev/null +++ b/ext/standaloneusers/sql/upgrade_5691.sql @@ -0,0 +1,9 @@ +CREATE TABLE `civicrm_session` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT 'Unique Session ID', + `session_id` char(64) NOT NULL COMMENT 'Hexadecimal Session Identifier', + `data` longtext COMMENT 'Session Data', + `last_accessed` datetime COMMENT 'Timestamp of the last session access', + PRIMARY KEY (`id`), + UNIQUE INDEX `index_session_id`(session_id) +) +ENGINE=InnoDB; diff --git a/ext/standaloneusers/tests/phpunit/Civi/Standalone/SessionHandlerTest.php b/ext/standaloneusers/tests/phpunit/Civi/Standalone/SessionHandlerTest.php new file mode 100644 index 0000000000..e49eb1600a --- /dev/null +++ b/ext/standaloneusers/tests/phpunit/Civi/Standalone/SessionHandlerTest.php @@ -0,0 +1,183 @@ +markTestSkipped('Test only applies on Standalone'); + } + } + + public function tearDown(): void { + Api4\Session::delete(FALSE) + ->addWhere('id', '>', 0) + ->execute(); + + parent::tearDown(); + } + + public function testHandler(): void { + $session_handler = new SessionHandler(); + + // Open a session + $session_handler->open('/path', 'CIVISOSESSID'); + + // Create a unique session ID + $session_id = $session_handler->create_sid(); + $this->assertEquals(64, strlen($session_id)); + + // Try to read data from the session + $data = $session_handler->read($session_id); + // Should return an empty string because the session doesn't exist yet + $this->assertEquals('', $data); + + // Write to the session + $session_handler->write($session_id, 'CiviCRM|a:1:{s:4:"ufID";i:1}'); + + // Close the session + $session_handler->close(); + + // Check the stored session + $session = self::getSession($session_id); + $this->assertIsArray($session); + $this->assertEquals('CiviCRM|a:1:{s:4:"ufID";i:1}', $session['data']); + + $original_timestamp = $session['last_accessed']; + + sleep(1); + + // Re-open the session + $session_handler->open('/path', 'CIVISOSESSID'); + + // Validate the session + $this->assertTrue($session_handler->validateId($session_id)); + + // Read from the session + $data = $session_handler->read($session_id); + $this->assertEquals('CiviCRM|a:1:{s:4:"ufID";i:1}', $data, 'Session should have stored data'); + + // Update the session timestamp + $session_handler->updateTimestamp($session_id, 'CiviCRM|a:1:{s:4:"ufID";i:1}'); + + // Close the session + $session_handler->close(); + + // Check the stored session + $session = self::getSession($session_id); + $this->assertIsArray($session); + $this->assertEquals('CiviCRM|a:1:{s:4:"ufID";i:1}', $session['data']); + + $updated_timestamp = $session['last_accessed']; + + $this->assertGreaterThan( + strtotime($original_timestamp), + strtotime($updated_timestamp), + 'Session timestamp should have been updated' + ); + + sleep(1); + + // Re-open the session + $session_handler->open('/path', 'CIVISOSESSID'); + + // Validate the session + $this->assertTrue($session_handler->validateId($session_id)); + + // Read from the session + $data = $session_handler->read($session_id); + $this->assertEquals('CiviCRM|a:1:{s:4:"ufID";i:1}', $data, 'Session data should not have changed'); + + // Destroy the session + $session_handler->destroy($session_id); + + // Close the session + $session_handler->close(); + + // Check the stored session + $session = self::getSession($session_id); + $this->assertNull($session, 'Session should have been deleted'); + } + + public function testGarbageCollection() { + $session_handler = new SessionHandler(); + $old_sid = $session_handler->create_sid(); + + // Create an old (expired) session + Api4\Session::create(FALSE) + ->addValue('session_id', $old_sid) + ->addValue('data', 'CiviCRM|a:0:{}') + ->addValue('last_accessed', date('Y-m-d H:i:s', strtotime('-1 week'))) + ->execute(); + + // Open a new session to trigger garbage collection + $session_handler->open('/path', 'CIVISOSESSID'); + $new_sid = $session_handler->create_sid(); + $session_handler->read($new_sid); + + // Run garbage collection with a $max_lifetime of 24 hours + $session_handler->gc(60 * 60 * 24); + + // Finish the current session lifecycle + $session_handler->write($new_sid, 'CiviCRM|a:0:{}'); + $session_handler->close(); + + // The old session should now be gone + $old_session = self::getSession($old_sid); + $this->assertNull($old_session, 'Expired session should have been deleted'); + + // The new session should still be here + $new_session = self::getSession($new_sid, 'New session should not have been deleted'); + $this->assertIsArray($new_session); + } + + public function testTransaction() { + $session_handler = new SessionHandler(); + + // Start a transaction + $tx = new \CRM_Core_Transaction(); + + // Create a new session + $session_handler->open('/path', 'CIVISOSESSID'); + $session_id = $session_handler->create_sid(); + $session_handler->read($session_id); + $session_handler->write($session_id, 'CiviCRM|a:0:{}'); + $session_handler->close(); + + // Re-open the session + $session_handler->open('/path', 'CIVISOSESSID'); + $session_handler->validateId($session_id); + $session_handler->read($session_id); + $session_handler->write($session_id, 'CiviCRM|a:1:{s:4:"ufID";i:1}'); + + // Rollback the transaction + $tx->rollback(); + + // Continue with session management + $session_handler->close(); + + // Make sure the session has not been affected by the transaction rollback + $session = self::getSession($session_id); + $this->assertIsArray($session); + $this->assertEquals('CiviCRM|a:1:{s:4:"ufID";i:1}', $session['data']); + } + + private static function getSession($session_id) { + return Api4\Session::get(FALSE) + ->addSelect('*') + ->addWhere('session_id', '=', $session_id) + ->execute() + ->first(); + } + +} diff --git a/ext/standaloneusers/xml/schema/CRM/Standaloneusers/Session.entityType.php b/ext/standaloneusers/xml/schema/CRM/Standaloneusers/Session.entityType.php new file mode 100644 index 0000000000..386a74692f --- /dev/null +++ b/ext/standaloneusers/xml/schema/CRM/Standaloneusers/Session.entityType.php @@ -0,0 +1,10 @@ + 'Session', + 'class' => 'CRM_Standaloneusers_DAO_Session', + 'table' => 'civicrm_session', + ], +]; diff --git a/ext/standaloneusers/xml/schema/CRM/Standaloneusers/Session.xml b/ext/standaloneusers/xml/schema/CRM/Standaloneusers/Session.xml new file mode 100644 index 0000000000..7a5e9ae50f --- /dev/null +++ b/ext/standaloneusers/xml/schema/CRM/Standaloneusers/Session.xml @@ -0,0 +1,52 @@ + + + + CRM/Standaloneusers + Session + civicrm_session + Standalone User Sessions + false + + + id + int + true + Unique Session ID + + Text + + + + id + true + + + + session_id + char + 64 + true + Hexadecimal Session Identifier + + Text + + + + session_id + index_session_id + true + + + + data + longtext + Session Data + + + + last_accessed + datetime + Timestamp of the last session access + + +