Ensuring ‘unique’ session id in high traffic sites with PHP Session handler & MYSQL Database

Addendum

So finally I cracked a way for it

The algorithm is like this

1.just prior to session_start, a cookie cookie_start_time is set to current time. life time of this cookie will be same as that of session. Both uses the variable $this->cookieLifeTime to set life time

setcookie($cookie_name/*cookie_start_time */, $cookie_value/*$this->cookieStartTime*/, time() + $this->cookieLifeTime /* (86400 * 30) */, "/"); // 86400 = 1 day

2. in session '_write' we will set that value to db table field cookie_start_time same as $this->cookieStartTime

3. in session '_read' we do a check

if($getRowsOfSession[0]['cookie_start_time'] != $this->cookieStartTime).

if it returns true, that means this is a duplicate session and the user is redirected to destroy the session and again redirected to start a new session.(2 redirections total)

So Let us go to the scripts and database in detail

Set up data base table

<pre class="wp-block-syntaxhighlighter-code">DROP TABLE IF EXISTS <code>sessions_with_db</code>;

CREATE TABLE IF NOT EXISTS <code>sessions_with_db</code> (
      <code>id</code> varchar(32) COLLATE utf8_unicode_ci NOT NULL,
      <code>access</code> datetime NOT NULL,
      <code>data</code> text COLLATE utf8_unicode_ci NOT NULL,
      <code>cookie_start_time</code> datetime NOT NULL DEFAULT '1970-01-01 00:00:00',
      UNIQUE KEY <code>id</code> (<code>id</code>)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;</pre>

Session Handler Class MySessionHandler.php

to test the working change

replace $this->cookieLifeTime

in

setcookie($cookie_name, $cookie_value, time() + $this->cookieLifeTime

to 60 (1 minute)

Dont forget to set it back after testing 🙂

<pre class="wp-block-syntaxhighlighter-code"><?php
/***
  https://stackoverflow.com/questions/36753513/how-do-i-save-php-session-data-to-a-database-instead-of-in-the-file-system
  https://culttt.com/2013/02/04/how-to-save-php-sessions-to-a-database/
  DROP TABLE IF EXISTS <code>sessions_with_db</code>;
    CREATE TABLE IF NOT EXISTS <code>sessions_with_db</code> (
      <code>id</code> varchar(32) COLLATE utf8_unicode_ci NOT NULL,
      <code>access</code> datetime NOT NULL,
      <code>data</code> text COLLATE utf8_unicode_ci NOT NULL,
      <code>cookie_start_time</code> datetime NOT NULL DEFAULT '1970-01-01 00:00:00',
      UNIQUE KEY <code>id</code> (<code>id</code>)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
*/

/***
<?php
//testUniqueSessionWithCookie.php
ob_start();
require "MySessionHandler.php";
$site_options = array();
$site_options["debug_mode"] = true;
$testSessId = "";
$dbDetails = array();
            $dbDetails['DB_USER'] = "root";
            $dbDetails['DB_PASS'] = "";
            $dbDetails['DB_SERVER'] = "localhost";
            $dbDetails['DB_NAME'] = "tests";//http://localhost/phpmyadmin/db_structure.php?server=1&db=tests
MySessionHandler::checkIfDuplicateSession($dbDetails);
$HSH = new MySessionHandler($dbDetails);
print "<p>Hello this is an index page</p>";
$_SESSION['horses'] = "treesx3";
$_SESSION['tiespan'] = !isset($_SESSION['tiespan'])?0:((int)$_SESSION['tiespan']+7);

echo "tiespan after change is ".$_SESSION['tiespan']."<br />";

print "<p>There should be some session data in the database now. <a href='index3.php'>link</a></p>";
var_dump($_SESSION);
?>
 ***/
require_once "mysqli_db_class.php";
class MySessionHandler /* implements SessionHandlerInterface */ {
    private $database = null;
    //ALTER TABLE <code>sessions_with_db</code> ADD <code>cookie_start_time</code> DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' AFTER <code>data</code>; 
    private $cookieStartTime = null;
    private $cookieLifeTime = 86400;//60 * 60 * 24;//60;
    private $freshSession =  false;
    private $sessionIdName =  null;
    public static function checkIfDuplicateSession($dbDetails)
    {
        global $testSessId;
        if(isset($_REQUEST['destroySession']) && $_REQUEST['destroySession'] == 'true')
        {

            //https://stackoverflow.com/questions/33517997/how-do-i-delete-phpsessid-on-client-computers
            session_start(); // initialize session

            //Warning: session_id(): Cannot change session id when session is active
            /* $sessId = $testSessId;//session_id();
            if($sessId !="")
            {
                session_id($sessId);
            } */
            $sessId = session_id();
            session_destroy(); // destroy session
            setcookie("PHPSESSID","",time()-3600,"/"); // delete session cookie
            //delete from DB too
            $database = MySQL::getInstance($dbDetails);
            $database->connectdb();

            //$this->_destroy($sessId);
            $deleteSQL = "DELETE FROM sessions_with_db WHERE id = ? LIMIT 1";
            //echo $deleteSQL. " " . $sessId."<br />";
        if ($database->executePS($deleteSQL, "s",$sessId)) {
            /* return true;
        } else {

            return false; */
        }

            //$js = "document.cookie = 'PHPSESSID=;Path=/cv;expires=Thu, 01 Jan 1970 00:00:01 GMT;';\n";// this is not working
            $jsMessage = "alert('Session destroyed id: ".$sessId ." ');\r\n";//".$sess_id ."
            //die ($jsMessage);
                $js = $jsMessage . "window.location.href='testUniqueSessionWithCookie.php?destroySession=false'";
                die ("<script>{$js}</script>");
            //die("Session destroyed");

        }//if(isset($_REQUEST['destroySession']))

    }//public static function checkIfDuplicateSession()

    public function __construct($dbDetails){

         global $testSessId;

            $user_tz = new DateTimeZone('Asia/Kolkata');//'America/New_York');
            $user = new DateTime('now', $user_tz);
            $this->cookieStartTime = $user->format('Y-m-d H:i:s');//date("Y-m-d H:i:s",time());

        /***
         * Just setting up my own database connection. Use yours as you need.
         ***/ 

            $this->database = MySQL::getInstance($dbDetails);
            $this->database->connectdb();

        // Set handler to overide SESSION
        session_set_save_handler(
            array($this, "_open"),  
            array($this, "_close"),  
            array($this, "_read"),  
            array($this, "_write"),  
            array($this, "_destroy"),  
            array($this, "_gc") 
        );
        register_shutdown_function('session_write_close');

        $cookie_name = "cookie_start_time" ;
        $cookie_value = $this->cookieStartTime;//date('Y-m-d H:i:s',time());
        if(!isset($_COOKIE['cookie_start_time']))
        {
            setcookie($cookie_name, $cookie_value, time() + $this->cookieLifeTime /* (86400 * 30) */, "/"); // 86400 = 1 day
        }
        else
        {
            $this->cookieStartTime = $_COOKIE['cookie_start_time'];
        }//if(!isset($_COOKIE['cookie_start_time']))
        $sessId = $testSessId;//session_id();
        if($sessId !="")
        {
            session_id($sessId);
        }

        session_start([//https://www.php.net/manual/en/function.session-start.php#example-5976
            'cookie_lifetime' =>  $this->cookieLifeTime //86400,//60 * 60 * 24 //* 7  // 7 day cookie lifetime
        ]);
        //$this->sessionIdName = session_name();
        //echo "Session name is ".$this->sessionIdName." ".__LINE__."</br>";
        /*  
            //https://stackoverflow.com/questions/37789172/php-session-randomly-dies-when-read-and-close-is-active
            $SESSION = [
            'name' => 'my_session_name',
            'storage' => 'default',
                'options' => [
                    'read_and_close' => true,
                    'cookie_lifetime' => false,
                    'use_strict_mode' => true,
                    'use_only_cookies' => 1,
                    'cookie_httponly' => 1,
                    'use_trans_sid' => 0,
                    //Ensure this is true for production:
                    'cookie_secure' => false
                ],
            ]; 

            session_name($SESSION['name']);
            session_start($SESSION['options']);*/
    }

    /**
     * Open
     */
    public function _open($savepath, $id){
        global $site_options;if($site_options["debug_mode"])echo "</p><b> _open</b></p>";
        // If successful

        return !is_null($this->database);
       // return true;
    }
    /**
     * Read
     */
    public function _read($id)
    {
        global $site_options;
        if($site_options["debug_mode"])
        {
            if(isset($_COOKIE['cookie_start_time']))echo "cookie_start_time is ".$_COOKIE['cookie_start_time']."<br />";
            else echo "cookie_start_time not set <br /> ";
            echo "</p><b> _read</b></p>";
        }//if($site_options["debug_mode"])
        // Set query

        $getRowsOfSession = $this->database->selectPS("SELECT <code>data</code>,<code>cookie_start_time</code> FROM sessions_with_db WHERE id = ? LIMIT 1","s",$id);

        if(count($getRowsOfSession)== 1)
        {
            //check with $this->cookieStartTime
            if($getRowsOfSession[0]['cookie_start_time'] !=  $this->cookieStartTime)
            {
                //echo "Session name is ".$this->sessionIdName." ".__LINE__."</br>";
                //$alertText = " ". $getRowsOfSession[0]['cookie_start_time'] . " != ".$this->cookieStartTime;
                $js = "alert('Same session id(".$this->sessionIdName.") exists ".$getRowsOfSession[0]['cookie_start_time'] . " " . $this->cookieStartTime."');\r\n";
                $js .= "window.location.href='testUniqueSessionWithCookie.php?destroySession=true'";
                die ("<script>{$js}</script>");
                //header("location: testUniqueSessionWithCookie.php")
            }//if($getRowsOfSession[0]['cookie_start_time'] !=  $this->cookieStartTime)
            return $getRowsOfSession[0]['data'];
        } else {
            $this->freshSession = true;
            return '';
        }
    }

    /**
     * Write
     */
    public function _write($id, $data)
    {
        global $site_options;if($site_options["debug_mode"])echo "</p><b> _write</b></p>";
        // Create time stamp
        $access = time();

        // Set query
        $dataReplace[0] = $id;
        //$dataReplace[1] = $access;
        $dataReplace[1] = $data;
        //if ($this->database->noReturnQuery('REPLACE INTO sessions_with_db(id,access,<code>data</code>) VALUES (?, ?, ?)', $dataReplace)) {
        /*
        REPLACE works exactly like INSERT , except that if an old row in the table has the same value as a new row for a PRIMARY KEY or a UNIQUE index, the old row is deleted before the new row is inserted.
        */
        //echo "REPLACE INTO sessions_with_db(id,access,<code>data</code>) VALUES ('{$id}', now(), '{$data}')";
        /* date_default_timezone_set('Asia/Kolkata'); // IST
        $max= 3600;
        $old = time() - $max;
        $dateOfOld = date('Y-m-d H:i:s',$old);
        echo "DELETE FROM sessions_with_db WHERE access < '".$dateOfOld."'<br />"; */
        if(/* $this->freshSession && */ $this->database->executePS('REPLACE INTO sessions_with_db(id,access,<code>data</code>,<code>cookie_start_time</code>) VALUES (?, now(), ?,?)',"sss",$id,$data,$this->cookieStartTime))
        {
            return true;
        } else {
            return false;
        }
        /* if($this->database->executePS('REPLACE INTO sessions_with_db(id,access,<code>data</code>,) VALUES (?, now(), ?)',"ss",$id,$data))
        {
            return true;
        } else {
            return false;
        } */
    }

    /**
     * Destroy
     */
    public function _destroy($id)
    {
        global $site_options;if($site_options["debug_mode"])echo "</p><b> _destroy</b></p>";
        // Set query
        //if ($this->database->noReturnQuery('DELETE FROM sessions_with_db WHERE id = ? LIMIT 1', $id)) {
        if ($this->database->executePS('DELETE FROM sessions_with_db WHERE id = ? LIMIT 1', "s",$id)) {
            return true;
        } else {

            return false;
        }
    }
    /**
     * Close
     */
    public function _close(){
        global $site_options;if($site_options["debug_mode"])echo "</p><b> _close</b></p>";
        // Close the database connection
        //if($this->database->dbiLink->close){
        if($this->database->disconnect()){
            // Return True
            return true;
        }
        // Return False
        return false;
    }

    /**
     * Garbage Collection
     */
    public function _gc($max)
    {
        global $site_options;if($site_options["debug_mode"])echo "</p><b> _gc</b></p>";
        // Calculate what is to be deemed old
        date_default_timezone_set('Asia/Kolkata'); // IST
        $old = time() - $max;
        $dateOfOld = date('Y-m-d H:i:s',$old);
        //echo "DELETE FROM sessions_with_db WHERE access < '".$dateOfOld."'<br />";
        //if ($this->database->noReturnQuery('DELETE FROM sessions_with_db WHERE access < ?', $old)) {
        if ($this->database->executePS('DELETE FROM sessions_with_db WHERE access < ?', "s",$dateOfOld)) {
            return true;
        } else {
            return false;
        }
    }

    public function __destruct()
    {
        global $site_options;if($site_options["debug_mode"])echo "</p><b> __destruct</b></p>";
        $this->_close();
    }

}
?></pre>

Finally testUniqueSessionWithCookie.php

<?php
ob_start();
require "MySessionHandler.php";
$site_options = array();
$site_options["debug_mode"] = true;
$testSessId = "";
$dbDetails = array();
            $dbDetails['DB_USER'] = "root";
            $dbDetails['DB_PASS'] = "";
            $dbDetails['DB_SERVER'] = "localhost";
            $dbDetails['DB_NAME'] = "tests";//http://localhost/phpmyadmin/db_structure.php?server=1&db=tests
MySessionHandler::checkIfDuplicateSession($dbDetails);
$HSH = new MySessionHandler($dbDetails);
print "<p>Hello this is an index page</p>";
$_SESSION['horses'] = "treesx3";
$_SESSION['tiespan'] = !isset($_SESSION['tiespan'])?0:((int)$_SESSION['tiespan']+7);

echo "tiespan after change is ".$_SESSION['tiespan']."<br />";

print "<p>There should be some session data in the database now. <a href='index3.php'>link</a></p>";
var_dump($_SESSION);
?>

mysqli_db_class.php is a utility class used in session handler

<?php
/**
*Class that could simplify mysql database operations.Used in attached page-nav class
*
*
https://www.w3schools.com/php/php_mysql_prepared_statements.asp
Sample

    $dbDetails['DB_USER'] = username;
    $dbDetails['DB_PASS'] = password;
    $dbDetails['DB_SERVER'] = server;
    $dbDetails['DB_NAME'] = database;
    $db = new MySQL($dbDetails);    
    $db->connectdb();
    //select query usage
    $sql = "SELECT * FROM updates  ";
    $latest_updates = $db->select_sql($sql);
    $row_latest_updates = $latest_updates[0];
    $totalRows_latest_updates = count($latest_updates);

    //other querie usage
    $SQL="delete fromcurrent_users where  DATE_ADD(last_visited,INTERVAL 1 year)<NOW()";
    $db->execute_sql($SQL);

*
* @package database and pagenav class
* @author Sreekanth Dayanand <brownbedi@gmail.com>
* @version 1.0 <2009/09/18>
* @copyright GNU General Public License (GPL)
**/
class MySQL {
    var $user;
    var $pass;
    var $server;
    var $db;
    var $conn;
    var $query;

    var $connected = false;
    private static $inst =  null;
    public static function getInstance($dbDetails)
    {
        /* 
        if(ClassPageConfig::$inst ==  null)
        {
            ClassPageConfig::$inst = new ClassPageConfig();
        }//if(ClassPageConfig::$inst ==  null)
        return ClassPageConfig::$inst;
        */
        //https://www.php.net/manual/en/reflectionclass.newinstancewithoutconstructor.php &
        //https://refactoring.guru/design-patterns/singleton/php/example
        $ref  = new \ReflectionClass( get_called_class() ) ;

        $reflectionProperty = new \ReflectionProperty(static::class, 'inst');
        $reflectionProperty->setAccessible(true);
        //echo $reflectionProperty->getValue();
        $inst =   $reflectionProperty->getValue();;//$reflectedClass->getStaticPropertyValue('inst');

        if (  $inst == null)
        {

            // The magic.
            //$ctor->setAccessible( true ) ;
            //$inst = new static();
            $inst = new static($dbDetails);
            //echo "INSTANTIATED ".print_r($inst,true) ."<br />";

            $reflectionProperty->setValue(null/* null for static var */, $inst);

        }

        return $inst ;
    }
    //--------------------------------------------------------
    private function __construct($dbDetails)//MySQL(
    //--------------------------------------------------------
    {
        $this->user = $dbDetails['DB_USER'];
        $this->pass = $dbDetails['DB_PASS'];
        $this->server = $dbDetails['DB_SERVER'];
        $this->db = $dbDetails['DB_NAME'];
    }

    //--------------------------------------------------------
    function connectdb()
    //--------------------------------------------------------
    {
        // Create connection
        $this->conn = new mysqli($this->server, $this->user, $this->pass, $this->db);// or die("Failed to connect to database".$this->db);

        // Check connection
        if ($this->conn->connect_error) {
            die("Connection failed: " . $this->conn->connect_error);
            $this->connected = true;
        }
        else{
            //die("Connection success");
        }
    }
    function getVersion()
    {
        return $this->conn->server_info;;
    }

    //--------------------------------------------------------
    function disconnect()
    //--------------------------------------------------------
    {
        /* echo "Disconnect called<br />";
        throw new Exception(); */
        if($this->connected)$this->conn->close();
        $this->connected = false;
    }
    //--------------------------------------------------------
    //https://www.javatpoint.com/php-variable-length-argument-function
    //function setquery($sql,...$bindArgs) 
    function executePS($sql,$types="",...$bindArgs) 
    //--------------------------------------------------------
    {   global $site_options;
        $this->query=$sql;
        $stmt = $this->conn->prepare($sql) or die(sprintf("Error: %s.\n", $this->conn->error)."<br /> ".$sql."<br /> types :{$types }<br /> bindArgs <pre>".print_r($bindArgs,true)."</pre>");

        if(count($bindArgs)>0)//https://stackoverflow.com/questions/17226762/mysqli-bind-param-for-array-of-strings
        {
            //$types = str_repeat('s', count($bindArgs)); //types
            $stmt->bind_param($types, ...$bindArgs); // bind array at once

        }//if(count($bindArgs)>0)
        //Error: 1062 : Duplicate entry '...' for key 'link_is_unique'.
        if($stmt->execute())// or die(sprintf("Error: %s.\n", $stmt->errno ." : ". $stmt->error)."<br /> ".$sql."<br /> types :{$types }<br /> bindArgs <pre>".print_r($bindArgs,true)."</pre>");
        {
            if($site_options["debug_mode"]) echo $sql."<br />";
            $stmt->close();

            $resultGot = array("status"=> "success","insert_id" => $this->lastInsertId());

        }
        else //if($stmt->execute())
        {
            $resultGot = array("status"=> "failed","error_no" => $stmt->errno , "error_desc" => $stmt->error);
        }//if($stmt->execute())
        return $resultGot;
    }
    /**
    *returns the resultset as an array
    *@access public
    *@param integer height
    *@return boolean
    **/
    //----------------------------------------------------------------------------
    function selectPS($sql,$types="",...$bindArgs)
    //----------------------------------------------------------------------------
    {
        global $site_options;
        $this->query=$sql;
        $stmt = $this->conn->prepare($sql);

        if(count($bindArgs)>0)//https://stackoverflow.com/questions/17226762/mysqli-bind-param-for-array-of-strings
        {
            //$types = str_repeat('s', count($bindArgs)); //types
            $stmt->bind_param($types, ...$bindArgs); // bind array at once

        }//if(count($bindArgs)>0)
        $stmt->execute() or die(sprintf("Error: %s.\n", $stmt->errno ." : ". $stmt->error)."<br /> ".$sql."<br /> types :{$types }<br /> bindArgs <pre>".print_r($bindArgs,true)."</pre>");
        if($site_options["debug_mode"]) echo $sql."<br />";

        $ret = array();
        // Call to undefined method mysqli_stmt::get_result() 
        /* $result = $stmt->get_result();

        //echo "<pre>".print_r($result,true)."</pre>";
        if($result->num_rows >0){
            while( $row = $result->fetch_assoc())
            {
                $ret[] = $row;
            }
        } */
        $stmt->store_result();

        while($assoc_array = $this->fetchAssocStatement($stmt))
        {
            $ret[] = $assoc_array;
        }//while($assoc_array = $this->fetchAssocStatement($stmt))

        $stmt->close();
        //die("<pre>".print_r($ret,true)."</pre>");
        return $ret;
    }//function selectPS($sql,...$bindArgs)

    //https://stackoverflow.com/questions/8321096/call-to-undefined-method-mysqli-stmtget-result
        function fetchAssocStatement($stmt)
        {
            //die("num rows is  " . $stmt->num_rows);
            if($stmt->num_rows>0)
            {
                $result = array();
                $md = $stmt->result_metadata();
                $params = array();
                while($field = $md->fetch_field()) {
                    $params[] = &$result[$field->name];
                }
                call_user_func_array(array($stmt, 'bind_result'), $params);
                if($stmt->fetch())
                    //die("<pre>".print_r($result,true)."</pre>");
                    return $result;
            }

            return null;
        }

    //For insert, update, delete
    //--------------------------------------------------------
    function sqlnodie()
    //--------------------------------------------------------
    {
        mysql_query($this->query);
    }

    function lastInsertId()
    {
        return $this->conn->insert_id;
    }

}
?>