483 lines
14 KiB
Text
483 lines
14 KiB
Text
<?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; // save
|
|
session_decode($row['data']);
|
|
$row['data'] = $_SESSION;
|
|
$_SESSION = $sess; // restore
|
|
return $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();
|
|
}
|
|
|
|
}
|