artabro/wire/modules/Session/SessionHandlerDB/SessionHandlerDB.module
2024-08-27 11:35:37 +02:00

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();
}
}