Rev 1 | Blame | Compare with Previous | Last modification | View Log | Download
<?php namespace ProcessWire;/*** Session handler for storing sessions to database** @see /wire/core/SessionHandler.php** ProcessWire 3.x, Copyright 2021 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.* @property int $retrySeconds Seconds after which to retry after a lock fail.**/class SessionHandlerDB extends WireSessionHandler implements Module, ConfigurableModule {public static function getModuleInfo() {return array('title' => 'Session Handler Database','version' => 6,'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$this->set('retrySeconds', 30); // seconds after which to retry on a lock fail}public function wired() {$this->database = $this->wire()->database;parent::wired();}public function init() {parent::init();// keeps session active$this->wire()->session->setFor($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);$locked = $query->fetchColumn();$query->closeCursor();if(!$locked) {// 0: attempt timed out (for example, because another client has previously locked the name)// null: error occurred (such as running out of memory or the thread was killed with mysqladmin kill)$this->wire()->shutdown->setFatalErrorResponse(array('code' => 429, // http status 429: Too Many Requests (RFC 6585)'headers' => array("Retry-After: $this->retrySeconds"),));throw new WireException("Unable to obtain lock for session (retry in {$this->retrySeconds}s)", 429);}$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 = '';if($this->useIP) {$session = $this->wire()->session;$ip = $session->getIP();$ip = (strlen($ip) && strpos($ip, ':') === false ? ip2long($ip) : '');// @todo DB schema for ipv6}$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()";try {$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();} catch(\Exception $e) {$result = false;$this->trackException($e);}try {$query = $database->prepare("DO RELEASE_LOCK(:id)");$query->bindValue(':id', $id, \PDO::PARAM_STR);$database->execute($query, false);$query->closeCursor();} catch(\Exception $e) {$this->trackException($e);}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) {$config = $this->wire()->config;$table = self::dbTableName;$database = $this->database;$query = $database->prepare("DELETE FROM `$table` WHERE id=:id");$query->execute(array(":id" => $id));$secure = $config->sessionCookieSecure ? (bool) $config->https : false;$expires = time() - 42000;$samesite = $config->sessionCookieSameSite ? ucfirst(strtolower($config->sessionCookieSameSite)) : 'Lax';if($samesite === 'None') $secure = true;if(PHP_VERSION_ID < 70300) {setcookie(session_name(), '', $expires, "/; SameSite=$samesite", $config->sessionCookieDomain, $secure, true);} else {setcookie(session_name(), '', array('expires' => $expires,'path' => '/','domain' => $config->sessionCookieDomain,'secure' => $secure,'httponly' => true,'samesite' => $samesite));}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) {$modules = $this->wire()->modules;$form = $this->wire(new InputfieldWrapper());// check if their DB table is the latest version$query = $this->database->query("SHOW COLUMNS FROM " . self::dbTableName . " WHERE field='ip'");if(!$query->rowCount()) {$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.');/** @var InputfieldCheckbox $f */$f = $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 = $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 = $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);/** @var InputfieldInteger $f */$f = $modules->get('InputfieldInteger');$f->attr('name', 'lockSeconds');$f->attr('value', $this->lockSeconds);$f->label = $this->_('Session lock timeout (seconds)');$f->description = sprintf($this->_('If a DB lock for the session cannot be obtained in this many seconds, a ā%sā error will be sent, telling the client to retry again in %d seconds.'),$this->_('429: Too Many Requests'),30);$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) {$seconds = (int) $seconds;$sql = "SELECT COUNT(*) FROM " . self::dbTableName . " WHERE ts > DATE_SUB(NOW(), INTERVAL $seconds SECOND)";$query = $this->database->query($sql);$numSessions = (int) $query->fetchColumn();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->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->database->prepare($sql);$query->bindValue(':id', $sessionID);$this->database->execute($query);if(!$query->rowCount()) return array();$row = $query->fetch(\PDO::FETCH_ASSOC) ;$sess = $_SESSION; // savesession_decode($row['data']);$row['data'] = $_SESSION;$_SESSION = $sess; // restorereturn $row;}/*** Upgrade module version** @param int $fromVersion* @param int $toVersion**/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->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->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();}}