I have tried to write a small light weighted php session handling class that use PHP's session_set_save_handler()
function to overwrite the default session handling functionalities and usage Database to store the sessions data instead of default files system. It checks for possible session hijacking attempts and renews session periodically. I want to know how feasible my class is and improvements that can be incorporated to make it more robust and secure.
sessionmanager.lib.php:
<?php
trait Singleton
{
private static $_instance;
public static function getInstance($config = array())
{
if (!(self::$_instance instanceof self)) {
self::$_instance = new self($config);
}
return self::$_instance;
}
}
/**
* @category Sessionmanager
* @version 1.0
* @author Anirban Nath <[email protected]>
* */
class sessionmanager
{
use Singleton;
/**
* [$_db PDO object holder]
* @var [object]
*/
private $_db;
/**
* [$_https Cookie secure flag holder]
* @var [boolean]
*/
private $_https;
/**
* [$_user_agent User Agent holder]
* @var [string]
*/
private $_user_agent;
/**
* [$_ip_address Client Machien IP address]
* @var [string]
*/
private $_ip_address;
/**
* [$_expiry Session LIfetime, default 2 Hours ]
* @var integer
*/
private $_expiry = 7200;
/**
* [$_session_cookie_ttl Session Cookie Lifetime , default (0:Clear the session cookies on browser close) ]
* @var integer
*/
private $_session_cookie_ttl = 0;
/**
* [$_refresh_interval Refresh Interval toi regenerate Session Id, default 10 minutes]
* @var integer
*/
private $_refresh_interval = 600;
/**
* [$_table_name Tbale name for storing Session information]
* @var string
*/
private $_table_name = "sessions";
/**
* [$_session_id Holder for session_id]
* @var [string]
*/
private $_session_id;
/**
* Sescure session Salt
* @ClassConstant
*/
const SECURE_SESSION = '--$ecure$ess10n--';
/**
* [__construct ,Pass configuration values to __setconfig, register session handlers and starts the sesssion]
* @param array $config [array of configuartion params]
*/
public function __construct(array $config)
{
session_set_save_handler(
array($this, 'open'),
array($this, 'close'),
array($this, 'read'),
array($this, 'write'),
array($this, 'destroy'),
array($this, 'gc')
);
$this->_setConfig($config);
session_start();
}
/**
* [_setConfig Sets up the configurations values passed in by __contsruct function and creates a session storage MySql table]
* @param [Array] $config [Configs params holder]
*/
private function _setConfig($config)
{
$this->_db = $config['dbconnector'];
$this->_expiry = (isset($config['expiry']))? $config['expiry'] : $this->_expiry ;
$this->_session_cookie_ttl = (isset($config['session_cookie_ttl']))? $config['session_cookie_ttl'] : $this->_session_cookie_ttl ;
$this->_https = (isset($_SERVER['HTTPS'])) ? TRUE: FALSE;
$this->_refresh_interval = (isset($config['refresh_interval'])) ? $config['refresh_interval']: $this->_refresh_interval;
$this->_user_agent = isset($config['user_agent']) ? $config['user_agent'] : $_SERVER['HTTP_USER_AGENT'];
$this->_ip_address = $this->_getRealIpAddr();
ini_set('session.cookie_lifetime', $this->_session_cookie_ttl);
ini_set('session.gc_maxlifetime', $this->_expiry);
ini_set('session.cookie_httponly', 1);
ini_set('session.entropy_file', '/dev/urandom');
ini_set('session.hash_function', 'whirlpool');
ini_set('session.use_only_cookies', 1);
ini_set('session.cookie_secure', $this->_https);
ini_set('session.entropy_length' ,512);
ini_set('session.use_trans_sid', false);
$stmt_create = "CREATE TABLE IF NOT EXISTS {$this->_table_name} (
`session_id` varchar(255) NOT NULL,
`data` text,
`user_agent` varchar(255) NOT NULL,
`ip_address` varbinary(16) NOT NULL,
`last_updated` int(11) NOT NULL,
`fingerprint` varchar(255) NOT NULL,
PRIMARY KEY (`session_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8";
$this->_db->exec($stmt_create);
}
/**
* [_getRealIpAddr Get the IP address of the user]
* @return [strings] [IP address of the client]
*/
private function _getRealIpAddr()
{
if (!empty($_SERVER['HTTP_CLIENT_IP']))
{
/*check ip from share internet*/
$ip = $_SERVER['HTTP_CLIENT_IP'];
}
elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR']))
{
/*to check ip is pass from proxy*/
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
}
else
{
$ip = $_SERVER['REMOTE_ADDR'];
}
return $ip;
}
/**
* [open The open callback works like a constructor in classes and is executed when the session is being opened.
* It is the first callback function executed when the session is started automatically or manually with session_start().
* Return value is TRUE for success, FALSE for failure. ]
* @param [string] $path [Path for saving session file]
* @param [string] $name [Session Name]
* @return [boolean]
*/
public function open($path, $name)
{
return true;
}
/**
* [close The close callback works like a destructor in classes and is executed after the session write callback has been called.
* It is also invoked when session_write_close() is called. Return value should be TRUE for success, FALSE for failure. ]
* @return [boolean]
*/
public function close()
{
/*calling explicitly method gc(),that will clear all expired sessions*/
$this->gc();
return true;
}
/**
* [_refresh Whenever new session id is required we can call this method, sets new session id]
*/
private function _refresh()
{
session_regenerate_id(true);
$this->_session_id = session_id();
}
/**
* [_needRenewal method for checking if the session needs Renewal from $_refresh_interval]
* @param [int] $id [session_id]
* @return [boolean]
*/
private function _needRenewal($id)
{
$stmt = $this->_db->prepare("SELECT last_updated FROM {$this->_table_name} WHERE session_id = ?");
$stmt->execute(array($id));
$record = current($stmt->fetchAll());
if ($record !== FALSE && count($record) > 0)
{
/*Checks if the session ID has exceeded it's permitted lifespan.*/
if((time() - $this->_refresh_interval) > $record['last_updated'])
{
/*Regenerates a new session ID*/
$this->_refresh();
$sql = "UPDATE {$this->_table_name} SET session_id =:session_id, last_updated =:last_updated WHERE session_id = '$id'";
$stmt = $this->_db->prepare($sql);
$stmt->bindParam(':last_updated', $id , PDO::PARAM_INT);
$stmt->bindParam(':session_id', $this->_session_id , PDO::PARAM_STR); //this is what will be returned by Refresh
$stmt->execute();
return true;
}
}
return false;
}
/**
* [_isExpired method for checking if the current session is expired]
* @param [array] $record [session info array passed in by _read()]
* @return boolean
*/
private function _isExpired($record)
{
$ses_life = time() - $this->_expiry;
$stmt = $this->_db->prepare("SELECT session_id FROM {$this->_table_name} WHERE last_updated < ? AND session_id = ?");
$stmt->execute(array($ses_life, $record['session_id']));
$record = current($stmt->fetchAll());
if($record)
return true;
else
return false;
}
/**
* [read The read callback must always return a session encoded (serialized) string,
* or an empty string if there is no data to read.
* This callback is called internally by PHP when the session starts or when session_start() is called.
* Before this callback is invoked PHP will invoke the open callback. ]
* @param [string] $id [session_id]
*/
public function read($id)
{
try
{
$stmt = $this->_db->prepare("SELECT session_id, fingerprint, data, user_agent, INET6_NTOA(ip_address), last_updated FROM {$this->_table_name} WHERE session_id = ?");
$stmt->execute(array($id));
$record = current($stmt->fetchAll());
if(empty($record['session_id']))
{
$this->_refresh();
return '';
}
else
{
if($this->_isSuspicious($record['fingerprint']) || $this->_isExpired($record))
{
$this->destroy($id);
throw new Exception('Possible Session Hijack attempt/Session expired/Some mismatch.');
}
else
{
/*Need a renewal ?*/
if($this->_needRenewal($id))
{
/*recursive call*/
$this->read($this->_session_id);
}
return $record['data'];
}
}
}
catch(PDOException $e)
{
echo $e->getMessage();
$this->_refresh();
return '';
}
catch(Exception $e)
{
echo $e->getMessage();
$this->_refresh();
return '';
}
}
/**
* [_getFingerPrint generates session fingerprints md5(USER AGENT + SECURE SESSION + IP ADDRESS)]
* @return [strings] [encryted session info fingerprint]
*/
private function _getFingerPrint()
{
return md5($this->_user_agent.self::SECURE_SESSION . $this->_ip_address);
}
/**
* [_isSuspicious check for possible session hijack attempt, by comaparing encrypted user system specific values against exoisting records ]
* @param [string] $fp [session fingerprint]
* @return boolean
*/
private function _isSuspicious($fp)
{
return ($fp != $this->_getFingerPrint()) ? True : False;
}
/**
* [write The write callback is called when the session needs to be saved and closed.
* This callback receives the current session ID a serialized version the $_SESSION superglobal.
* The serialization method used internally by PHP is specified in the session.serialize_handler ini setting.
* Here we are storing/updating the session data against the session id]
* @param [string] $id [session id]
* @param [serilized data] $data [The serialized session data passed to this callback should be stored against the passed session ID]
* @return [boolean]
*/
public function write($id, $data)
{
try
{
$sql = "INSERT INTO {$this->_table_name} (session_id, user_agent, ip_address, last_updated, data, fingerprint)
VALUES (:session_id, :user_agent, INET6_ATON(:ip_address), :last_updated, :data,:fingerprint)
ON DUPLICATE KEY UPDATE data =VALUES(data), last_updated=VALUES(last_updated)";
$time = time();
$fp = $this->_getFingerPrint();
$stmt = $this->_db->prepare($sql);
$stmt->bindParam(':session_id', $id , PDO::PARAM_STR);
$stmt->bindParam(':user_agent', $this->_user_agent, PDO::PARAM_STR);
$stmt->bindParam(':ip_address' , $this->_ip_address , PDO::PARAM_STR);
$stmt->bindParam(':last_updated', $time , PDO::PARAM_INT);
$stmt->bindParam(':data', $data , PDO::PARAM_STR);
$stmt->bindParam(':fingerprint', $fp, PDO::PARAM_STR);
$stmt->execute();
return true;
}
catch(PDOException $e)
{
echo $e->getMessage();
return false;
}
catch(Exception $e)
{
echo $e->getMessage();
return false;
}
}
/**
* [destroy deletes the current session id from the database]
* @param [string] $id [session_id]
* @return [boolean]
*/
public function destroy($id)
{
/**/
$stmt = $this->_db->prepare("DELETE FROM {$this->_table_name} WHERE session_id = ?");
$session_res = $stmt->execute(array($id));
if (!$session_res)
return false;
else
return true;
}
/**
* [gc The garbage collector callback is invoked internally by PHP periodically in order to purge old session data.
* The frequency is controlled by session.gc_probability and session.gc_divisor.
* The value of lifetime which is passed to this callback can be set in session.gc_maxlifetime.
* here we are calling this via _close(), to delete all expired sessions from DB
* Return value should be TRUE for success, FALSE for failure. ]
* @return [boolean]
*/
public function gc()
{
$ses_life = time() - $this->_expiry;
$stmt = $this->_db->prepare("DELETE FROM {$this->_table_name} WHERE last_updated < ?");
$session_res = $stmt->execute(array($ses_life));
return true;
}
/**
* [__destruct register_shutdown_function() the following prevents unexpected effects when using objects as save handlers
* Session data is usually stored after script terminated without the need to call session_write_close(),
* but as session data is locked to prevent concurrent writes only one script may operate on a session at any time.
* When using framesets together with sessions you will experience the frames loading one by one due to this locking.
* You can reduce the time needed to load all the frames by ending the session as soon as all changes to session variables are done. ]
*/
public function __destruct()
{
register_shutdown_function('session_write_close');
}
}
try
{
$pdo = new PDO('mysql:host=localhost;dbname=test', '', '');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
$config['dbconnector'] = $pdo;
}
catch(PDOException $e)
{
echo $e->getMessage();
die;
}
$s = sessionmanager::getInstance($config);
Usage example: example.php:
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
error_reporting(-1);
include('sessionmanager.lib.php');
$_SESSION['Motto'] = 'Lets Do it !';
var_dump($_SESSION);
exit;