sessionName Name of session on http * string $config->sessionNameSecure Name of session on https * int $config->sessionExpireSeconds Number of seconds of inactivity before session expires * bool $config->sessionChallenge True if a separate challenge cookie should be used for validating sessions * bool $config->sessionFingerprint True if a fingerprint should be kept of the user's IP & user agent to validate sessions * bool $config->sessionCookieSecure Use secure cookies or session? (default=true) * * @todo enable login/forceLogin to recognize non-HTTP use of login, when no session needs to be maintained * @todo add a default $config->apiUser to be used when non-HTTP/bootstrap usage * */ class Session extends Wire implements \IteratorAggregate { /** * Fingerprint bitmask: Use remote addr (recommended) * */ const fingerprintRemoteAddr = 2; /** * Fingerprint bitmask: Use client provided addr * */ const fingerprintClientAddr = 4; /** * Fingerprint bitmask: Use user agent (recommended) * */ const fingerprintUseragent = 8; /** * Fingerprint bitmask: Use “accept” content-types header * * @since 3.0.159 * */ const fingerprintAccept = 16; /** * Suffix applied to challenge cookies * * @since 3.0.141 * */ const challengeSuffix = '_challenge'; /** * Reference to ProcessWire $config object * * For convenience, since our __get() does not reference the Fuel, unlike other Wire derived classes. * */ protected $config; /** * Instance of the SessionCSRF protection class, instantiated when requested from $session->CSRF. * */ protected $CSRF = null; /** * Set to true when maintenance should be skipped * * @var bool * */ protected $skipMaintenance = false; /** * Has the Session::init() method been called? * * @var bool * */ protected $sessionInit = false; /** * Are sessions allowed? * * @var bool|null * */ protected $sessionAllow = null; /** * Instance of custom session handler module, when in use (null when not) * * @var WireSessionHandler|null * */ protected $sessionHandler = null; /** * Name of key/index within $_SESSION where PW keeps its session data * * @var string * */ protected $sessionKey = ''; /** * Data storage when no session initialized * * @var array * */ protected $data = array(); /** * True if there is an external session provider * * @var bool * */ protected $isExternal = false; /** * True if this is a secondary instance of ProcessWire * * @var bool * */ protected $isSecondary = false; /** * Start the session and set the current User if a session is active * * Assumes that you have already performed all session-specific ini_set() and session_name() calls * * @param ProcessWire $wire * @throws WireException * */ public function __construct(ProcessWire $wire) { $wire->wire($this); $this->config = $wire->wire()->config; $this->sessionKey = $this->className(); $instanceID = $wire->getProcessWireInstanceID(); if($instanceID) { $this->isSecondary = true; $this->sessionKey .= $instanceID; } $user = null; $sessionAllow = $this->config->sessionAllow; if(is_null($sessionAllow)) { $sessionAllow = true; } else if(is_bool($sessionAllow)) { // okay, keep as-is } else if(is_callable($sessionAllow)) { // call function that returns boolean $sessionAllow = call_user_func_array($sessionAllow, array($this)); if(!is_bool($sessionAllow)) throw new WireException("\$config->sessionAllow callable must return boolean"); } else { $sessionAllow = true; } $this->sessionAllow = $sessionAllow; if($sessionAllow) { $this->init(); if(empty($_SESSION[$this->sessionKey])) $_SESSION[$this->sessionKey] = array(); $userID = $this->get('_user', 'id'); if($userID) { if($this->isValidSession($userID)) { $user = $this->wire('users')->get($userID); } else { $this->logout(); } } } if(!$user || !$user->id) $user = $this->wire('users')->getGuestUser(); $this->wire('users')->setCurrentUser($user); if($sessionAllow) $this->wakeupNotices(); $this->setTrackChanges(true); } /** * Are session cookie(s) present? * * @param bool $checkLogin Specify true to check instead for challenge cookie (which indicates login may be active). * @return bool Returns true if session cookie present, false if not. * */ public function hasCookie($checkLogin = false) { if($this->config->https && $this->config->sessionCookieSecure) { $name = $this->config->sessionNameSecure; if(!$name) $name = $this->config->sessionName . 's'; } else { $name = $this->config->sessionName; } if($checkLogin) $name .= self::challengeSuffix; return !empty($_COOKIE[$name]); } /** * Is a session login cookie present? * * This only indicates the user was likely logged in at some point, and may not indicate an active login. * This method is more self describing version of `$session->hasCookie(true);` * * @return bool * @since 3.0.175 * */ public function hasLoginCookie() { return $this->hasCookie(true); } /** * Start the session * * Provided here in any case anything wants to hook in before session_start() * is called to provide an alternate save handler. * * #pw-hooker * */ public function ___init() { if($this->sessionInit || !$this->sessionAllow) return; if(!$this->config->sessionName) return; $this->sessionInit = true; if(function_exists("\\session_status")) { // abort session init if there is already a session active // note: there is no session_status() function prior to PHP 5.4 if(session_status() === PHP_SESSION_ACTIVE) { // use a more unique sessionKey when there is an external session provider $this->isExternal = true; $this->sessionKey = str_replace($this->className(), 'ProcessWire', $this->sessionKey); return; } } if($this->config->https && $this->config->sessionCookieSecure) { ini_set('session.cookie_secure', 1); // #1264 if($this->config->sessionNameSecure) { session_name($this->config->sessionNameSecure); } else { session_name($this->config->sessionName . 's'); } } else { session_name($this->config->sessionName); } ini_set('session.use_cookies', true); ini_set('session.use_only_cookies', 1); ini_set('session.cookie_httponly', 1); ini_set('session.gc_maxlifetime', $this->config->sessionExpireSeconds); if($this->config->sessionCookieDomain) { ini_set('session.cookie_domain', $this->config->sessionCookieDomain); } if(ini_get('session.save_handler') == 'files') { if(ini_get('session.gc_probability') == 0) { // Some debian distros replace PHP's gc without fully implementing it, // which results in broken garbage collection if the save_path is set. // As a result, we avoid setting the save_path when this is detected. } else { ini_set("session.save_path", rtrim($this->config->paths->sessions, '/')); } } $options = array(); $cookieSameSite = $this->sessionCookieSameSite(); if(PHP_VERSION_ID < 70300) { $cookiePath = ini_get('session.cookie_path'); if(empty($cookiePath)) $cookiePath = '/'; $options['cookie_path'] = "$cookiePath; SameSite=$cookieSameSite"; } else { $options['cookie_samesite'] = $cookieSameSite; } @session_start($options); if(!empty($this->data)) { foreach($this->data as $key => $value) $this->set($key, $value); } } /** * Checks if the session is valid based on a challenge cookie and fingerprint * * These items may be disabled at the config level, in which case this method always returns true * * #pw-hooker * * @param int $userID * @return bool * */ protected function ___isValidSession($userID) { $valid = true; $reason = ''; $sessionName = session_name(); // check challenge cookie if($this->config->sessionChallenge) { $cookieName = $sessionName . self::challengeSuffix; if(empty($_COOKIE[$cookieName]) || ($this->get('_user', 'challenge') != $_COOKIE[$cookieName])) { $valid = false; $reason = "Error: Invalid challenge value"; } } // check fingerprint if(!$this->isValidFingerprint()) { $reason = "Error: Session fingerprint changed (IP address or useragent)"; $valid = false; } // check session expiration if($this->config->sessionExpireSeconds) { $ts = (int) $this->get('_user', 'ts'); if($ts < (time() - $this->config->sessionExpireSeconds)) { // session time expired $valid = false; $this->error($this->_('Session timed out')); $reason = "Session timed out (session older than {$this->config->sessionExpireSeconds} seconds)"; } } if($valid) { // if valid, update last request time $this->set('_user', 'ts', time()); } else if($reason && $userID && $userID != $this->wire('config')->guestUserPageID) { // otherwise log the invalid session $user = $this->wire('users')->get((int) $userID); if($user && $user->id) $reason = "User '$user->name' - $reason"; $reason .= " (IP: " . $this->getIP() . ")"; $this->log($reason); } return $valid; } /** * Returns whether or not the current session fingerprint is valid * * @return bool * */ protected function isValidFingerprint() { $fingerprint = $this->getFingerprint(); if($fingerprint === false) return true; // fingerprints off if($fingerprint !== $this->get('_user', 'fingerprint')) return false; return true; } /** * Generate a session fingerprint * * If the `$mode` argument is omitted, the mode is pulled from `$config->sessionFingerprint`. * If using the mode argument, specify one of the following: * * - 2: Remote IP * - 4: Forwarded/client IP (can be spoofed) * - 8: Useragent * - 16: Accept header * * To use the custom `$mode` settings above, select one or more of those you want * to fingerprint, note the numbers, and determine the `$mode` like this: * ~~~~~~ * // to fingerprint just remote IP * $mode = 2; * * // to fingerprint remote IP and useragent: * $mode = 2 | 8; * * // to fingerprint remote IP, useragent and accept header: * $mode = 2 | 8 | 16; * ~~~~~~ * If using fingerprint in an environment where the user’s IP address may * change during the session, you should fingerprint only the useragent * and/or accept header, or disable fingerprinting. * * If using fingerprint with an AWS load balancer, you should use one of * the options that uses the “client IP” rather than the “remote IP”, * fingerprint only useragent and/or accept header, or disable fingerprinting. * * #pw-internal * * @param int|bool|null $mode Optionally specify fingerprint mode (default=$config->sessionFingerprint) * @param bool $debug Return non-hashed fingerprint for debugging purposes? (default=false) * @return bool|string Returns false if fingerprints not enabled. Returns string if enabled. * */ public function getFingerprint($mode = null, $debug = false) { $debugInfo = array(); $useFingerprint = $mode === null ? $this->config->sessionFingerprint : $mode; if(!$useFingerprint) return false; if($useFingerprint === true || $useFingerprint === 1 || $useFingerprint === "1") { // default (boolean true or int 1) $useFingerprint = self::fingerprintRemoteAddr | self::fingerprintUseragent; if($debug) $debugInfo[] = 'default'; } $fingerprint = ''; if($useFingerprint & self::fingerprintRemoteAddr) { $fingerprint .= $this->getIP(true); if($debug) $debugInfo[] = 'remote-addr'; } if($useFingerprint & self::fingerprintClientAddr) { $fingerprint .= $this->getIP(false, 2); if($debug) $debugInfo[] = 'client-addr'; } if($useFingerprint & self::fingerprintUseragent) { $fingerprint .= isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : ''; if($debug) $debugInfo[] = 'useragent'; } if($useFingerprint & self::fingerprintAccept) { $fingerprint .= isset($_SERVER['HTTP_ACCEPT']) ? $_SERVER['HTTP_ACCEPT'] : ''; if($debug) $debugInfo[] = 'accept'; } if($debug) { $fingerprint = implode(',', $debugInfo) . ': ' . $fingerprint; } else { $fingerprint = md5($fingerprint); } return $fingerprint; } /** * Get a session variable * * - This method returns the value of the requested session variable, or NULL if it's not present. * - You can optionally use a namespace with this method, to avoid collisions with other session variables. * But if using namespaces we recommended using the dedicated getFor() and setFor() methods instead. * - You can also get or set non-namespaced session values directly (see examples). * * ~~~~~ * // Set value "Bob" to session variable named "firstName" * $session->set('firstName', 'Bob'); * * // You can retrieve the firstName now, or any later request * $firstName = $session->get('firstName'); * * // outputs: Hello Bob * echo "Hello $firstName"; * ~~~~~ * ~~~~~ * // Setting and getting a session value directly * $session->firstName = 'Bob'; * $firstName = $session->firstName; * ~~~~~ * * @param string|object $key Name of session variable to retrieve (or object if using namespaces) * @param string $_key Name of session variable to get if first argument is namespace, omit otherwise. * @return mixed Returns value of seession variable, or NULL if not found. * */ public function get($key, $_key = null) { if($key === 'CSRF') { return $this->CSRF(); } else if(!is_null($_key)) { // namespace return $this->getFor($key, $_key); } if($this->sessionInit) { $value = isset($_SESSION[$this->sessionKey][$key]) ? $_SESSION[$this->sessionKey][$key] : null; } else { if($key == 'config') return $this->config; $value = isset($this->data[$key]) ? $this->data[$key] : null; } if(is_null($value) && is_null($_key) && strpos($key, '_user_') === 0) { // for backwards compatiblity with non-core modules or templates that may be checking _user_[property] // not currently aware of any instances, but this is just a precaution return $this->get('_user', str_replace('_user_', '', $key)); } return $value; } /** * Get a session variable or return $val argument if session value not present * * This is the same as get() except that it lets you specify a fallback return value in the method call. * For a namespace version use `Session::getValFor()` instead. * * @param string $key Name of session variable to retrieve. * @param mixed $val Fallback value to return if session does not have it. * @return mixed Returns value of seession variable, or NULL if not found. * @since 3.0.133 * */ public function getVal($key, $val = null) { $value = $this->get($key); if($value === null) $value = $val; return $value; } /** * Get all session variables in an associative array * * @param object|string $ns Optional namespace * @return array * */ public function getAll($ns = null) { if(!is_null($ns)) return $this->getFor($ns, ''); if($this->sessionInit) { return $_SESSION[$this->sessionKey]; } else { return $this->data; } } /** * Get all session variables for given namespace and return associative array * * @param string|Wire $ns * @return array * @since 3.0.141 Method added for consistency, but any version can do this with $session->getFor($ns, ''); * */ public function getAllFor($ns) { return $this->getFor($ns, ''); } /** * Set a session variable * * - You can optionally use a namespace with this method, to avoid collisions with other session variables. * But if using namespaces we recommended using the dedicated getFor() and setFor() methods instead. * - You can also get or set non-namespaced session values directly (see examples). * * ~~~~~ * // Set value "Bob" to session variable named "firstName" * $session->set('firstName', 'Bob'); * * // You can retrieve the firstName now, or any later request * $firstName = $session->get('firstName'); * * // outputs: Hello Bob * echo "Hello $firstName"; * ~~~~~ * ~~~~~ * // Setting and getting a session value directly * $session->firstName = 'Bob'; * $firstName = $session->firstName; * ~~~~~ * * @param string|object $key Name of session variable to set (or object for namespace) * @param string|mixed $value Value to set (or name of variable, if first argument is namespace) * @param mixed $_value Value to set if first argument is namespace. Omit otherwise. * @return $this * */ public function set($key, $value, $_value = null) { if(!is_null($_value)) return $this->setFor($key, $value, $_value); $oldValue = $this->get($key); if($value !== $oldValue) $this->trackChange($key, $oldValue, $value); if($this->sessionInit) { $_SESSION[$this->sessionKey][$key] = $value; } else { $this->data[$key] = $value; } return $this; } /** * Get a session variable within a given namespace * * ~~~~~ * // Retrieve namespaced session value * $firstName = $session->getFor($this, 'firstName'); * ~~~~~ * * @param string|object $ns Namespace string or object * @param string $key Specify variable name to retrieve, or blank string to return all variables in the namespace. * @return mixed * */ public function getFor($ns, $key) { $ns = $this->getNamespace($ns); $data = $this->get($ns); if(!is_array($data)) $data = array(); if($key === '') return $data; return isset($data[$key]) ? $data[$key] : null; } /** * Get a session variable or return $val argument if session value not present * * This is the same as get() except that it lets you specify a fallback return value in the method call. * For a namespace version use `Session::getValFor()` instead. * * @param string|object $ns Namespace string or object * @param string $key Specify variable name to retrieve * @param mixed $val Fallback value if session does not have one * @return mixed * @since 3.0.133 * */ public function getValFor($ns, $key, $val = null) { $value = $this->getFor($ns, $key); if($value === null) $value = $val; return $value; } /** * Set a session variable within a given namespace * * To remove a namespace, use `$session->remove($namespace)`. * * ~~~~~ * // Set a session value for a namespace * $session->setFor($this, 'firstName', 'Bob'); * ~~~~~ * * @param string|object $ns Namespace string or object. * @param string $key Name of session variable you want to set. * @param mixed $value Value you want to set, or specify null to unset. * @return $this * */ public function setFor($ns, $key, $value) { $ns = $this->getNamespace($ns); $data = $this->get($ns); if(!is_array($data)) $data = array(); if(is_null($value)) unset($data[$key]); else $data[$key] = $value; return $this->set($ns, $data); } /** * Unset a session variable * * ~~~~~ * // Unset a session var * $session->remove('firstName'); * * // Unset a session var in a namespace * $session->remove($this, 'firstName'); * * // Unset all session vars in a namespace * $session->remove($this, true); * ~~~~~ * * @param string|object $key Name of session variable you want to remove (or namespace string/object) * @param string|bool|null $_key Omit this argument unless first argument is a namespace. Otherwise specify one of: * - If first argument is namespace and you want to remove a property from the namespace, provide key here. * - If first argument is namespace and you want to remove all properties from the namespace, provide boolean TRUE. * @return $this * */ public function remove($key, $_key = null) { if($this->sessionInit) { if(is_null($_key)) { unset($_SESSION[$this->sessionKey][$key]); } else if(is_bool($_key)) { unset($_SESSION[$this->sessionKey][$this->getNamespace($key)]); } else { unset($_SESSION[$this->sessionKey][$this->getNamespace($key)][$_key]); } } else { if(is_null($_key)) { unset($this->data[$key]); } else if(is_bool($_key)) { unset($this->data[$this->getNamespace($key)]); } else { unset($this->data[$this->getNamespace($key)][$_key]); } } return $this; } /** * Unset a session variable within a namespace * * @param string|object $ns Namespace * @param string $key Provide name of variable to remove, or boolean true to remove all in namespace. * @return $this * */ public function removeFor($ns, $key) { return $this->remove($ns, $key); } /** * Remove all session variables in given namespace * * @param string|object $ns * @return $this * */ public function removeAllFor($ns) { $this->remove($ns, true); return $this; } /** * Given a namespace object or string, return the namespace string * * @param object|string $ns * @return string * @throws WireException if given invalid namespace type * */ protected function getNamespace($ns) { if(is_object($ns)) { if($ns instanceof Wire) $ns = $ns->className(); else $ns = wireClassName($ns, false); } else if(is_string($ns)) { // good } else { throw new WireException("Session namespace must be string or object"); } return $ns; } /** * Provide non-namespaced $session->variable get access * * @param string $key * @return SessionCSRF|mixed|null * */ public function __get($key) { return $this->get($key); } /** * Provide non-namespaced $session->variable = variable set access * * @param string $key * @param mixed $value * @return $this * */ public function __set($key, $value) { return $this->set($key, $value); } /** * Allow iteration of session variables * * ~~~~~ * foreach($session as $key => $value) { * echo "