Add a custom SessionHandler for user sessions
authorManuel Flandorfer <mflandor@greenpeace.org>
Wed, 6 Dec 2023 16:16:54 +0000 (16:16 +0000)
committerRich Lott / Artful Robot <code.commits@artfulrobot.uk>
Thu, 7 Dec 2023 16:06:18 +0000 (16:06 +0000)
15 files changed:
CRM/Logging/Schema.php
CRM/Utils/System/Standalone.php
ext/standaloneusers/CRM/Standaloneusers/BAO/Session.php [new file with mode: 0644]
ext/standaloneusers/CRM/Standaloneusers/DAO/Session.php [new file with mode: 0644]
ext/standaloneusers/CRM/Standaloneusers/Upgrader.php
ext/standaloneusers/Civi/Api4/Session.php [new file with mode: 0644]
ext/standaloneusers/Civi/Authx/Standalone.php
ext/standaloneusers/Civi/Standalone/SessionHandler.php [new file with mode: 0644]
ext/standaloneusers/settings/standaloneusers.setting.php [new file with mode: 0644]
ext/standaloneusers/sql/auto_install.sql
ext/standaloneusers/sql/auto_uninstall.sql
ext/standaloneusers/sql/upgrade_5691.sql [new file with mode: 0644]
ext/standaloneusers/tests/phpunit/Civi/Standalone/SessionHandlerTest.php [new file with mode: 0644]
ext/standaloneusers/xml/schema/CRM/Standaloneusers/Session.entityType.php [new file with mode: 0644]
ext/standaloneusers/xml/schema/CRM/Standaloneusers/Session.xml [new file with mode: 0644]

index 70cc37c30a7f36c8fa1a642d48b69ae62170998f..d539e1b393c4ce68c740027fee2fbab0ee7ad8a0 100644 (file)
@@ -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, []);
index 18f5b7bfc7e6933dd94d9c50d4802b09ff01f80e..070f596d07cf268d7ad66927a52475b65a0dbada 100644 (file)
@@ -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 (file)
index 0000000..bd877ee
--- /dev/null
@@ -0,0 +1,74 @@
+<?php
+
+class CRM_Standaloneusers_BAO_Session extends CRM_Standaloneusers_DAO_Session {
+
+  /**
+   * Delete all expired sessions
+   *
+   * @param DB_mysqli $db
+   * @param string $expiration_date
+   * @return void
+   */
+  public static function deleteExpired($db, $expiration_date) {
+    $table_name = self::$_tableName;
+    $stmt = $db->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 (file)
index 0000000..230951f
--- /dev/null
@@ -0,0 +1,262 @@
+<?php
+
+/**
+ * @package CRM
+ * @copyright CiviCRM LLC https://civicrm.org/licensing
+ *
+ * Generated from standaloneusers/xml/schema/CRM/Standaloneusers/Session.xml
+ * DO NOT EDIT.  Generated by CRM_Core_CodeGen
+ * (GenCodeChecksum:aa80480a6d443480358eca06cc367bdd)
+ */
+use CRM_Standaloneusers_ExtensionUtil as E;
+
+/**
+ * Database access object for the Session entity.
+ */
+class CRM_Standaloneusers_DAO_Session extends CRM_Core_DAO {
+  const EXT = E::LONG_NAME;
+  const TABLE_ADDED = '';
+
+  /**
+   * Static instance to hold the table name.
+   *
+   * @var string
+   */
+  public static $_tableName = 'civicrm_session';
+
+  /**
+   * Should CiviCRM log any modifications to this table in the civicrm_log table.
+   *
+   * @var bool
+   */
+  public static $_log = FALSE;
+
+  /**
+   * Unique Session ID
+   *
+   * @var int|string|null
+   *   (SQL type: int)
+   *   Note that values will be retrieved from the database as a string.
+   */
+  public $id;
+
+  /**
+   * Hexadecimal Session Identifier
+   *
+   * @var string
+   *   (SQL type: char(64))
+   *   Note that values will be retrieved from the database as a string.
+   */
+  public $session_id;
+
+  /**
+   * Session Data
+   *
+   * @var string|null
+   *   (SQL type: longtext)
+   *   Note that values will be retrieved from the database as a string.
+   */
+  public $data;
+
+  /**
+   * Timestamp of the last session access
+   *
+   * @var string|null
+   *   (SQL type: datetime)
+   *   Note that values will be retrieved from the database as a string.
+   */
+  public $last_accessed;
+
+  /**
+   * Class constructor.
+   */
+  public function __construct() {
+    $this->__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;
+  }
+
+}
index ff208b3c45a3ea96977d18a7deaaa2c14bd10c3b..5a2b2260565a4cf8ce1a5ec300dba75c3b2dec54 100644 (file)
@@ -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 (file)
index 0000000..882b9a6
--- /dev/null
@@ -0,0 +1,13 @@
+<?php
+namespace Civi\Api4;
+
+/**
+ * Session entity.
+ *
+ * Provided by the Standalone Users extension.
+ *
+ * @package Civi\Api4
+ */
+class Session extends Generic\DAOEntity {
+
+}
index f3156c5127ac2c2fd90559c1d93bd53778591f49..0fe4c66466b2890e9a399de325883c6655c689e9 100644 (file)
@@ -37,6 +37,7 @@ class Standalone implements AuthxInterface {
    */
   public function logoutSession() {
     \CRM_Core_Session::singleton()->reset();
+    session_destroy();
   }
 
   /**
diff --git a/ext/standaloneusers/Civi/Standalone/SessionHandler.php b/ext/standaloneusers/Civi/Standalone/SessionHandler.php
new file mode 100644 (file)
index 0000000..4d24071
--- /dev/null
@@ -0,0 +1,165 @@
+<?php
+
+namespace Civi\Standalone;
+
+use CRM_Standaloneusers_BAO_Session as Session;
+use DB;
+use SessionHandlerInterface;
+use SessionIdInterface;
+use SessionUpdateTimestampHandlerInterface;
+
+class SessionHandler implements SessionHandlerInterface, SessionIdInterface, SessionUpdateTimestampHandlerInterface {
+
+  /**
+   * @var bool
+   */
+  private $collectGarbage = FALSE;
+
+  /**
+   * @var string
+   */
+  private $data;
+
+  /**
+   * @var \DB_mysqli
+   */
+  private $db;
+
+  /**
+   * @var int
+   */
+  private $maxLifetime;
+
+  /**
+   * Closes the current session. This function is automatically executed when
+   * closing the session, or explicitly via session_write_close()
+   *
+   * @return bool
+   */
+  public function close(): bool {
+    $this->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 (file)
index 0000000..8750edc
--- /dev/null
@@ -0,0 +1,15 @@
+<?php
+
+return [
+  'standaloneusers_session_max_lifetime' => [
+    '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,
+  ],
+];
index 9a482bb76e37c6923844f6b396d6b0e4ca34b3d8..ecaaf3c9d6c88796bb6334eefe8de4609507fef1 100644 (file)
@@ -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
index 35c0c090b4cf90af67f3bf04db4e1a9f244d459c..5e606032ef5d5d806129f37ffce2ed0a6802d7f3 100644 (file)
@@ -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 (file)
index 0000000..7831276
--- /dev/null
@@ -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 (file)
index 0000000..e49eb16
--- /dev/null
@@ -0,0 +1,183 @@
+<?php
+
+namespace Civi\Standalone;
+
+use Civi\Api4;
+use Civi\Test\EndToEndInterface;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @group e2e
+ */
+class SessionHandlerTest extends TestCase implements EndToEndInterface {
+
+  public function setUp(): void {
+    parent::setUp();
+
+    if (CIVICRM_UF !== 'Standalone') {
+      $this->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 (file)
index 0000000..386a746
--- /dev/null
@@ -0,0 +1,10 @@
+<?php
+// This file declares a new entity type. For more details, see "hook_civicrm_entityTypes" at:
+// https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_entityTypes
+return [
+  [
+    'name' => '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 (file)
index 0000000..7a5e9ae
--- /dev/null
@@ -0,0 +1,52 @@
+<?xml version="1.0" encoding="iso-8859-1" ?>
+
+<table>
+  <base>CRM/Standaloneusers</base>
+  <class>Session</class>
+  <name>civicrm_session</name>
+  <comment>Standalone User Sessions</comment>
+  <log>false</log>
+
+  <field>
+    <name>id</name>
+    <type>int</type>
+    <required>true</required>
+    <comment>Unique Session ID</comment>
+    <html>
+      <type>Text</type>
+    </html>
+  </field>
+  <primaryKey>
+    <name>id</name>
+    <autoincrement>true</autoincrement>
+  </primaryKey>
+
+  <field>
+    <name>session_id</name>
+    <type>char</type>
+    <length>64</length>
+    <required>true</required>
+    <comment>Hexadecimal Session Identifier</comment>
+    <html>
+      <type>Text</type>
+    </html>
+  </field>
+  <index>
+    <fieldName>session_id</fieldName>
+    <name>index_session_id</name>
+    <unique>true</unique>
+  </index>
+
+  <field>
+    <name>data</name>
+    <type>longtext</type>
+    <comment>Session Data</comment>
+  </field>
+
+  <field>
+    <name>last_accessed</name>
+    <type>datetime</type>
+    <comment>Timestamp of the last session access</comment>
+  </field>
+
+</table>