Subversion Repositories web.creative

Rev

Blame | Last modification | View Log | Download

<?php namespace ProcessWire;

/**
 * Session handler for storing sessions to database
 *
 * @see /wire/core/SessionHandler.php
 *
 * ProcessWire 3.x, Copyright 2018 by Ryan Cramer
 * https://processwire.com
 * 
 * @property int|bool $useIP Track IP address?
 * @property int|bool $useUA Track user agent?
 * @property int|bool $noPS Prevent more than one session per logged-in user?
 * @property int $lockSeconds Max number of seconds to wait to obtain DB row lock.
 *
 */

class SessionHandlerDB extends WireSessionHandler implements Module, ConfigurableModule {

  public static function getModuleInfo() {
    return array(
      'title' => 'Session Handler Database', 
      'version' => 5, 
      'summary' => "Installing this module makes ProcessWire store sessions in the database rather than the file system. Note that this module will log you out after install or uninstall.",
      'installs' => array('ProcessSessionDB')
      );
  }

  /**
   * Table created by this module
   *
   */
  const dbTableName = 'sessions';

  /**
   * Quick reference to database
   * 
   * @var WireDatabasePDO
   *
   */
  protected $database; 

  /**
   * Construct
   *
   */
  public function __construct() {
    parent::__construct();
    $this->set('useIP', 0); // track IP address?
    $this->set('useUA', 0); // track user agent?
    $this->set('noPS', 0); // disallow parallel sessions per user
    $this->set('lockSeconds', 50); // max number of seconds to wait to obtain DB row lock
  }
  
  public function wired() {
    $this->database = $this->wire('database');
    parent::wired();
  }
  
  public function init() {
    parent::init();
    // keeps session active
    $this->wire('session')->set($this, 'ts', time());
    if($this->noPS) $this->addHookAfter('Session::loginSuccess', $this, 'hookLoginSuccess');
  }

  /**
   * Read and return data for session indicated by $id
   *
   * @param string $id Session ID
   * @return string Serialized data or blank string if none
   *
   */
  public function read($id) {
    
    $table = self::dbTableName; 
    $database = $this->database;
    $data = '';
    
    $query = $database->prepare("SELECT GET_LOCK(:id, :seconds)"); 
    $query->bindValue(':id', $id);
    $query->bindValue(':seconds', $this->lockSeconds, \PDO::PARAM_INT);
    $database->execute($query);
    
    $query = $database->prepare("SELECT data FROM `$table` WHERE id=:id");
    $query->bindValue(':id', $id);
    $database->execute($query);
    
    if($query->rowCount()) {
      $data = $query->fetchColumn();
      if(empty($data)) $data = '';
    }
    $query->closeCursor();
      
    return $data; 
  }

  /**
   * Write the given $data for the given session ID
   *
   * @param string $id Session ID
   * @param string $data Serialized data to write
   * @return bool
   *
   */
  public function write($id, $data) {
    $table = self::dbTableName;
    $database = $this->database;
    $user = $this->wire('user');
    $page = $this->wire('page');
    $user_id = $user && $user->id ? (int) $user->id : 0; 
    $pages_id = $page && $page->id ? (int) $page->id : 0;
    $ua = ($this->useUA && isset($_SERVER['HTTP_USER_AGENT'])) ? substr(strip_tags($_SERVER['HTTP_USER_AGENT']), 0, 255) : '';
    $ip = $this->useIP ? ((int) $this->wire('session')->getIP(true)) : '';
  
    $binds = array(
      ':id' => $id, 
      ':user_id' => $user_id, 
      ':pages_id' => $pages_id, 
      ':data' => $data,
    );

    $s = "user_id=:user_id, pages_id=:pages_id, data=:data";
    
    if($ip) {
      $s .= ", ip=:ip";
      $binds[':ip'] = $ip;
    }
    if($ua) {
      $s .= ", ua=:ua";
      $binds[':ua'] = $ua;
    }
    
    $sql = "INSERT INTO $table SET id=:id, $s ON DUPLICATE KEY UPDATE $s, ts=NOW()";
    $query = $database->prepare($sql); 
    
    foreach($binds as $key => $value) {
      $type = is_int($value) ? \PDO::PARAM_INT : \PDO::PARAM_STR;
      $query->bindValue($key, $value, $type);
    }
    
    $result = $database->execute($query, false) ? true : false; 
    $query->closeCursor();
    
    $query = $database->prepare("DO RELEASE_LOCK(:id)");
    $query->bindValue(':id', $id, \PDO::PARAM_STR);
    $database->execute($query, false);
    $query->closeCursor();
    
    return $result; 
  }

  /**
   * Destroy the session indicated by the given session ID
   *
   * @param string $id Session ID
   * @return bool True on success, false on failure 
   *
   */
  public function destroy($id) {
    $table = self::dbTableName;
    $database = $this->database;
    $query = $database->prepare("DELETE FROM `$table` WHERE id=:id"); 
    $query->execute(array(":id" => $id));
    $secure = $this->wire('config')->sessionCookieSecure ? (bool) $this->config->https : false;
    setcookie(session_name(), '', time()-42000, '/', $this->config->sessionCookieDomain, $secure, true);
    return true; 
  }

  /**
   * Garbage collection: remove stale sessions
   *
   * @param int $seconds Max lifetime of a session
   * @return bool True on success, false on failure
   *
   */
  public function gc($seconds) {
    $table = self::dbTableName; 
    $seconds = (int) $seconds; 
    $sql = "DELETE FROM `$table` WHERE ts < DATE_SUB(NOW(), INTERVAL $seconds SECOND)";
    return $this->database->exec($sql) !== false;
  }

  /**
   * Install sessions table
   *
   */
  public function ___install() {
    
    $table = self::dbTableName;
    $charset = $this->wire('config')->dbCharset;

    $sql =  "CREATE TABLE `$table` (" . 
        "id CHAR(32) NOT NULL, " . 
        "user_id INT UNSIGNED NOT NULL, " . 
        "pages_id INT UNSIGNED NOT NULL, " . 
        "data MEDIUMTEXT NOT NULL, " . 
        "ts TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " . 
        "ip INT UNSIGNED NOT NULL DEFAULT 0, " . 
        "ua VARCHAR(250) NOT NULL DEFAULT '', " . 
        "PRIMARY KEY (id), " . 
        "INDEX (pages_id), " . 
        "INDEX (user_id), " . 
        "INDEX (ts) " . 
        ") ENGINE=InnoDB DEFAULT CHARSET=$charset";

    $this->database->query($sql); 
  }

  /**
   * Drop sessions table
   *
   */
  public function ___uninstall() {
    $this->database->query("DROP TABLE " . self::dbTableName); 
  }

  /**
   * Session configuration options
   * 
   * @param array $data
   * @return InputfieldWrapper
   *
   */
  public function getModuleConfigInputfields(array $data) {

    $form = $this->wire(new InputfieldWrapper());

    // check if their DB table is the latest version
    $query = $this->wire('database')->query("SHOW COLUMNS FROM " . self::dbTableName . " WHERE field='ip'"); 
    if(!$query->rowCount()) {
      $this->wire('modules')->error("DB format changed - You must uninstall this module and re-install before configuring."); 
      return $form;
    }

    $description = $this->_('Checking this box will enable the data to be displayed in your admin sessions list.');

    $f = $this->wire('modules')->get('InputfieldCheckbox'); 
    $f->attr('name', 'useIP'); 
    $f->attr('value', 1);
    $f->attr('checked', empty($data['useIP']) ? '' : 'checked'); 
    $f->label = $this->_('Track IP addresses in session data?');
    $f->description = $description;
    $form->add($f);

    $f = $this->wire('modules')->get('InputfieldCheckbox'); 
    $f->attr('name', 'useUA'); 
    $f->attr('value', 1);
    $f->attr('checked', empty($data['useUA']) ? '' : 'checked'); 
    $f->label = $this->_('Track user agent in session data?');
    $f->notes = $this->_('The user agent typically contains information about the browser being used.');
    $f->description = $description;
    $form->add($f);

    $f = $this->wire('modules')->get('InputfieldCheckbox');
    $f->attr('name', 'noPS');
    $f->attr('value', 1);
    $f->attr('checked', empty($data['noPS']) ? '' : 'checked');
    $f->label = $this->_('Disallow parallel sessions?');
    $f->notes = $this->_('When enabled, successful login expires all other sessions for that user on other devices/browsers.');
    $f->description = $this->_('Checking this box will allow only one single session for a logged-in user at a time.');
    $form->add($f);

    if(ini_get('session.gc_probability') == 0) {
      $form->warning(
        "Your PHP has a configuration error with regard to sessions. It is configured to never clean up old session files. " . 
        "Please correct this by adding the following to your <u>/site/config.php</u> file: " . 
        "<code>ini_set('session.gc_probability', 1);</code>", 
        Notice::allowMarkup
      );
    }

    return $form;
  }

  /**
   * Provides direct reference access to set values in the $data array
   *
   * For some reason PHP 5.4+ requires this, as it apparently doesn't see WireData
   * 
   * @param string $key
   * @param mixed $value
   * @return void
   *
   */
  public function __set($key, $value) {
    $this->set($key, $value);
  }


  /**
   * Provides direct reference access to variables in the $data array
   *
   * For some reason PHP 5.4+ requires this, as it apparently doesn't see WireData
   *
   * Otherwise the same as get()
   *
   * @param string $key
   * @return mixed
   *
   */
  public function __get($key) {
    return $this->get($key);
  }

  /**
   * Return the number of active sessions in the last 5 mins (300 seconds)
   * 
   * @param int $seconds Optionally specify number of seconds (rather than 300, 5 minutes)
   * @return int
   * 
   */
  public function getNumSessions($seconds = 300) {
    $sql = "SELECT count(*) FROM " . self::dbTableName . " WHERE ts > :ts";
    $query = $this->wire('database')->prepare($sql);
    $query->bindValue(':ts', date('Y-m-d H:i:s', (time() - $seconds)));
    $query->execute();
    list($numSessions) = $query->fetch(\PDO::FETCH_NUM); 
    return $numSessions; 
  }

  /**
   * Get the most recent sessions
   * 
   * Returns an array of array for each session, which includes all the 
   * session info except or the 'data' property. Use the getSessionData()
   * method to retrieve that. 
   * 
   * @param int $seconds Sessions up to this many seconds old
   * @param int $limit Max number of sessions to return
   * @return array Sessions newest to oldest
   * 
   */
  public function getSessions($seconds = 300, $limit = 100) {
    
    $seconds = (int) $seconds; 
    $limit = (int) $limit; 
    
    $sql =  "SELECT id, user_id, pages_id, ts, UNIX_TIMESTAMP(ts) AS tsu, ip, ua " . 
        "FROM " . self::dbTableName . " " . 
        "WHERE ts > DATE_SUB(NOW(), INTERVAL $seconds SECOND) " . 
        "ORDER BY ts DESC LIMIT $limit";
    
    $query = $this->wire('database')->prepare($sql); 
    $query->execute();
  
    $sessions = array();
    while($row = $query->fetch(\PDO::FETCH_ASSOC)) {
      $sessions[] = $row; 
    }
    
    return $sessions; 
  }

  /**
   * Return all session data for the given session ID
   * 
   * Note that the 'data' property of the returned array contains the values
   * that the user has in their $session. 
   * 
   * @param $sessionID
   * @return array Blank array on fail, populated array on success. 
   * 
   */
  public function getSessionData($sessionID) {
    $sql = "SELECT * FROM " . self::dbTableName . " WHERE id=:id";
    $query = $this->wire('database')->prepare($sql);
    $query->bindValue(':id', $sessionID); 
    $this->wire('database')->execute($query);
    if(!$query->rowCount()) return array();
    $row = $query->fetch(\PDO::FETCH_ASSOC) ;
    $sess = $_SESSION; // save
    session_decode($row['data']); 
    $row['data'] = $_SESSION; 
    $_SESSION = $sess; // restore
    return $row; 
  }

  /**
   * Upgrade module version
   * 
   * @param int $fromVersion
   * @param int $toVersion
   * @throws WireException
   * 
   */
  public function ___upgrade($fromVersion, $toVersion) {
    // $this->message("Upgrade: $fromVersion => $toVersion");
    // if(version_compare($fromVersion, "0.0.5", "<") && version_compare($toVersion, "0.0.4", ">")) {
    if($fromVersion <= 4 && $toVersion >= 5) {  
      $table = self::dbTableName;
      $database = $this->wire('database');
      $sql = "ALTER TABLE $table MODIFY data MEDIUMTEXT NOT NULL";
      $query = $database->prepare($sql);
      $query->execute();
      $this->message("Updated sessions database for larger data storage", Notice::log);
    }
  }

  /**
   * Hook called after Session::loginSuccess to enforce the noPS option
   * 
   * @param HookEvent $event
   * 
   */
  public function hookLoginSuccess(HookEvent $event) {
    if(!$this->noPS) return;
    /** @var User $user */
    $user = $event->arguments(0);
    $table = self::dbTableName;
    $query = $this->wire('database')->prepare("DELETE FROM `$table` WHERE user_id=:user_id AND id!=:id");
    $query->bindValue(':id', session_id()); 
    $query->bindValue(':user_id', $user->id, \PDO::PARAM_INT);  
    $query->execute();
    $n = $query->rowCount();
    if($n) $this->message(sprintf(
      $this->_('Previous login session for ā€œ%sā€ has been removed/logged-out.'), 
      $user->name
    ));
    $query->closeCursor();
  }

}