praiadeseselle/wire/core/Session.php

1649 lines
46 KiB
PHP
Raw Permalink Normal View History

2022-03-08 15:55:41 +01:00
<?php namespace ProcessWire;
/**
* ProcessWire Session
*
* Start a session with login/logout capability
*
* #pw-summary Maintains sessions in ProcessWire, authentication, persistent variables, notices and redirects.
*
* This should be used instead of the $_SESSION superglobal, though the $_SESSION superglobal can still be
* used, but it's in a different namespace than this. A value set in $_SESSION won't appear in $session
* and likewise a value set in $session won't appear in $_SESSION. It's also good to use this class
* over the $_SESSION superglobal just in case we ever need to replace PHP's session handling in the future.
*
* ProcessWire 3.x, Copyright 2019 by Ryan Cramer
* https://processwire.com
*
* @see https://processwire.com/api/ref/session/ Session documentation
*
* @method User login() login($name, $pass, $force = false) Login the user identified by $name and authenticated by $pass. Returns the user object on successful login or null on failure.
* @method Session logout() logout() Logout the current user, and clear all session variables.
* @method void redirect() redirect($url, $http301 = true) Redirect this session to the specified URL.
* @method void init() Initialize session (called automatically by constructor) #pw-hooker
* @method bool authenticate(User $user, $pass) #pw-hooker
* @method bool isValidSession($userID) #pw-hooker
* @method bool allowLoginAttempt($name) #pw-hooker
* @method bool allowLogin($name, User $user = null) #pw-hooker
* @method void loginSuccess(User $user) #pw-hooker
* @method void loginFailure($name, $reason) #pw-hooker
* @method void logoutSuccess(User $user) #pw-hooker
*
* @property SessionCSRF $CSRF
*
* Expected $config variables include:
* ===================================
* string $config->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 users 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 "<li>$key: $value</li>";
* }
* ~~~~~
*
* @return \ArrayObject
*
*/
public function getIterator() {
$data = $this->sessionInit ? $_SESSION[$this->sessionKey] : $this->data;
return new \ArrayObject($data);
}
/**
* Get the IP address of the current user
*
* ~~~~~
* $ip = $session->getIP();
* echo $ip; // outputs 111.222.333.444
* ~~~~~
*
* @param bool $int Return as a long integer? (default=false)
* - IPv6 addresses cannot be represented as an integer, so please note that using this int option makes it return a CRC32
* integer when using IPv6 addresses (3.0.184+).
* @param bool|int $useClient Give preference to client headers for IP? HTTP_CLIENT_IP and HTTP_X_FORWARDED_FOR (default=false)
* - Specify integer 2 to include potential multiple CSV separated IPs (when provided by client).
* @return string|int Returns string by default, or integer if $int argument indicates to.
*
*/
public function getIP($int = false, $useClient = false) {
$ip = $this->config->sessionForceIP;
$ipv6 = false;
if(!empty($ip)) {
// use IP address specified in $config->sessionForceIP and disregard other options
$useClient = false;
} else if(empty($_SERVER['REMOTE_ADDR'])) {
// when accessing via CLI Interface, $_SERVER['REMOTE_ADDR'] isn't set and trying to get it, throws a php-notice
$ip = '127.0.0.1';
} else if($useClient) {
if(!empty($_SERVER['HTTP_CLIENT_IP'])) $ip = $_SERVER['HTTP_CLIENT_IP'];
else if(!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) $ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
else if(!empty($_SERVER['REMOTE_ADDR'])) $ip = $_SERVER['REMOTE_ADDR'];
else $ip = '0.0.0.0';
// It's possible for X_FORWARDED_FOR to have more than one CSV separated IP address, per @tuomassalo
if(strpos($ip, ',') !== false && $useClient !== 2) {
list($ip) = explode(',', $ip);
}
// sanitize: if IP contains something other than digits, periods, commas, spaces,
// then don't use it and instead fallback to the REMOTE_ADDR.
$test = str_replace(array('.', ',', ' '), '', $ip);
if(!ctype_digit("$test")) {
if(strpos($test, ':') !== false) {
// ipv6 allowed
$test = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
$ip = $test === false ? $_SERVER['REMOTE_ADDR'] : $test;
} else {
$ip = $_SERVER['REMOTE_ADDR'];
}
}
} else {
$ip = $_SERVER['REMOTE_ADDR'];
}
if(strpos($ip, ':') !== false) {
// attempt to identify an IPv4 version when an integer required for return value
if($int && $ip === '::1') {
$ip = '127.0.0.1';
} else if($int && strpos($ip, '.') && preg_match('!(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})!', $ip, $m)) {
$ip = $m[1]; // i.e. 0:0:0:0:0:ffff:192.1.56.10 => 192.1.56.10
} else {
$ipv6 = true;
}
}
if($useClient === 2 && strpos($ip, ',') !== false) {
// return multiple IPs
$ips = array();
foreach(explode(',', $ip) as $ip) {
if($ipv6) {
$ip = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
if($ip !== false && $int) $ip = crc32($ip);
} else {
$ip = ip2long(trim($ip));
if(!$int) $ip = long2ip($ip);
}
if($ip !== false) $ips[] = $ip;
}
$ip = implode(',', $ips);
} else if($ipv6) {
$ip = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
if($ip === false) {
$ip = $int ? 0 : '0.0.0.0';
} else if($int) {
$ip = crc32($ip);
}
} else {
// sanitize by converting to and from integer
$ip = ip2long(trim($ip));
if(!$int) $ip = long2ip($ip);
}
return $ip;
}
/**
* Login a user with the given name and password
*
* Also sets them to the current user.
*
* ~~~~~
* $u = $session->login('bob', 'laj3939$a');
* if($u) {
* echo "Welcome Bob";
* } else {
* echo "Sorry Bob";
* }
* ~~~~~
*
* #pw-group-authentication
*
* @param string|User $name May be user name or User object.
* @param string $pass Raw, non-hashed password.
* @param bool $force Specify boolean true to login user without requiring a password ($pass argument can be blank, or anything).
* You can also use the `$session->forceLogin($user)` method to force a login without a password.
* @return User|null Return the $user if the login was successful or null if not.
* @throws WireException
*
*/
public function ___login($name, $pass, $force = false) {
/** @var User|null $user */
$user = null;
/** @var Sanitizer $sanitizer */
$sanitizer = $this->wire('sanitizer');
/** @var Users $users */
$users = $this->wire('users');
/** @var int $guestUserID */
$guestUserID = $this->wire('config')->guestUserPageID;
$fail = true;
$failReason = '';
if(is_object($name) && $name instanceof User) {
$user = $name;
$name = $user->name;
} else {
$name = $sanitizer->pageNameUTF8($name);
}
if(!strlen($name)) return null;
$allowAttempt = $this->allowLoginAttempt($name);
if($allowAttempt && is_null($user)) {
$user = $users->get('name=' . $sanitizer->selectorValue($name));
}
if(!$allowAttempt) {
$failReason = 'Blocked login attempt';
} else if(!$user || !$user->id) {
$failReason = 'Unknown user';
} else if($user->id == $guestUserID) {
$failReason = 'Guest user may not login';
} else if(!$this->allowLogin($name, $user)) {
$failReason = 'Login not allowed';
} else if($force === true || $this->authenticate($user, $pass)) {
$this->trackChange('login', $this->wire('user'), $user);
session_regenerate_id(true);
$this->set('_user', 'id', $user->id);
$this->set('_user', 'ts', time());
if($this->config->sessionChallenge) {
// create new challenge
$rand = new WireRandom();
$challenge = $rand->base64(32);
$this->set('_user', 'challenge', $challenge);
$secure = $this->config->sessionCookieSecure ? (bool) $this->config->https : false;
// set challenge cookie to last 30 days (should be longer than any session would feasibly last)
$this->setCookie(
session_name() . self::challengeSuffix,
$challenge,
time() + 60*60*24*30,
'/',
$this->config->sessionCookieDomain,
$secure,
true,
$this->config->sessionCookieSameSite
);
}
if($this->config->sessionFingerprint) {
// remember a fingerprint that tracks the user's IP and user agent
$this->set('_user', 'fingerprint', $this->getFingerprint());
}
$this->wire('user', $user);
$this->CSRF()->resetAll();
$this->loginSuccess($user);
$fail = false;
} else {
// authentication failed
$failReason = 'Invalid password';
}
if($fail) {
$this->loginFailure($name, $failReason);
$user = null;
}
return $user;
}
/**
* Login a user without requiring a password
*
* ~~~~~
* // login bob without knowing his password
* $u = $session->forceLogin('bob');
* ~~~~~
*
* #pw-group-authentication
*
* @param string|User $user Username or User object
* @return User|null Returns User object on success, or null on failure
*
*/
public function forceLogin($user) {
return $this->login($user, '', true);
}
/**
* Login success method for hooks
*
* #pw-hooker
*
* @param User $user
*
*/
protected function ___loginSuccess(User $user) {
$this->log("Successful login for '$user->name'");
}
/**
* Login failure method for hooks
*
* #pw-hooker
*
* @param string $name Attempted login name
* @param string $reason Reason for login failure
*
*/
protected function ___loginFailure($name, $reason) {
$this->log("Error: Failed login for '$name' - $reason");
}
/**
* Allow the user $name to login? Provided for use by hooks.
*
* #pw-hooker
*
* @param string $name User login name
* @param User|null $user User object
* @return bool True if allowed to login, false if not (hooks may modify this)
*
*/
public function ___allowLogin($name, $user = null) {
$allow = true;
if(!strlen($name)) return false;
if(!$user || !$user instanceof User) {
$name = $this->wire('sanitizer')->pageNameUTF8($name);
$user = $this->wire('users')->get("name=" . $this->wire('sanitizer')->selectorValue($name));
}
if(!$user || !$user->id || !$user instanceof User) return false;
if($user->isGuest()) return false;
$xroles = $this->wire('config')->loginDisabledRoles;
if(!is_array($xroles) && !empty($xroles)) $xroles = array($xroles);
if($name) {}
if($user) {
if($user->isUnpublished()) {
$allow = false;
} else if(is_array($xroles)) {
foreach($xroles as $xrole) {
if($user->hasRole($xrole)) {
$allow = false;
break;
}
}
}
}
return $allow;
}
/**
* Allow login attempt for given name at all?
*
* This method does nothing and is purely for hooks to modify return value.
*
* #pw-hooker
*
* @param string $name
* @return bool
*
*/
public function ___allowLoginAttempt($name) {
return strlen($name) > 0;
}
/**
* Return true or false whether the user authenticated with the supplied password
*
* #pw-hooker
*
* @param User $user User attempting to login
* @param string $pass Password they are attempting to login with
* @return bool
*
*/
public function ___authenticate(User $user, $pass) {
return $user->pass->matches($pass);
}
/**
* Logout the current user, and clear all session variables
*
* ~~~~~
* // logout user when "?logout=1" in URL query string
* if($input->get('logout')) {
* $session->logout();
* // good to redirect somewhere else after a login or logout
* $session->redirect('/');
* }
* ~~~~~
*
* #pw-group-authentication
*
* @param bool $startNew Start a new session after logout? (default=true)
* @return $this
* @throws WireException if session is disabled
*
*/
public function ___logout($startNew = true) {
$sessionName = session_name();
if($this->sessionInit) {
if(!$this->isExternal && !$this->isSecondary) {
$_SESSION = array();
}
} else {
$this->data = array();
}
$this->removeCookies();
$this->sessionInit = false;
if($startNew) {
session_destroy();
session_name($sessionName);
$this->init();
session_regenerate_id(true);
$_SESSION[$this->sessionKey] = array();
}
$user = $this->wire('user');
if($user) $this->logoutSuccess($user);
$guest = $this->wire('users')->getGuestUser();
if($this->wire('languages') && "$user->language" != "$guest->language") $guest->language = $user->language;
$this->wire('users')->setCurrentUser($guest);
$this->trackChange('logout', $user, $guest);
return $this;
}
/**
* Add a SetCookie response header
*
* @param string $name
* @param string|null|false $value
* @param int $expires
* @param string $path
* @param string|null $domain
* @param bool $secure
* @param bool $httponly
* @param string $samesite One of 'Strict', 'Lax', 'None'
* @return bool
* @since 3.0.178
*
*/
protected function setCookie($name, $value, $expires = 0, $path = '/', $domain = null, $secure = false, $httponly = false, $samesite = 'Lax') {
if(empty($path)) $path = '/';
$samesite = $this->sessionCookieSameSite($samesite);
if($samesite === 'None') $secure = true;
if(PHP_VERSION_ID < 70300) {
return setcookie($name, $value, $expires, "$path; SameSite=$samesite", $domain, $secure, $httponly);
}
// PHP 7.3+ supports $options array
return setcookie($name, $value, array(
'expires' => $expires,
'path' => $path,
'domain' => $domain,
'secure' => $secure,
'httponly' => $httponly,
'samesite' => $samesite,
));
}
/**
* Remove all cookies used by the session
*
*/
protected function removeCookies() {
$sessionName = session_name();
$challengeName = $sessionName . self::challengeSuffix;
$time = time() - 42000;
$domain = $this->config->sessionCookieDomain;
$secure = $this->config->sessionCookieSecure ? (bool) $this->config->https : false;
$samesite = $this->sessionCookieSameSite();
if(isset($_COOKIE[$sessionName])) {
$this->setCookie($sessionName, '', $time, '/', $domain, $secure, true, $samesite);
}
if(isset($_COOKIE[$challengeName])) {
$this->setCookie($challengeName, '', $time, '/', $domain, $secure, true, $samesite);
}
}
/**
* Get 'SameSite' value for session cookie
*
* @param string|null $value
* @return string
* @since 3.0.178
*
*/
protected function sessionCookieSameSite($value = null) {
$samesite = $value === null ? $this->config->sessionCookieSameSite : $value;
$samesite = empty($samesite) ? 'Lax' : ucfirst(strtolower($samesite));
if(!in_array($samesite, array('Strict', 'Lax', 'None'), true)) $samesite = 'Lax';
return $samesite;
}
/**
* Get the names of all cookies managed by Session
*
* #pw-internal
*
* @return array
* @since 3.0.141
*
*/
public function getCookieNames() {
$name = $this->config->sessionName;
$nameSecure = $this->config->sessionNameSecure;
if(empty($nameSecure)) $nameSecure = $this->config->sessionName . 's';
$a = array($name, $nameSecure);
if($this->config->sessionChallenge) {
$a[] = $name . self::challengeSuffix;
$a[] = $nameSecure . self::challengeSuffix;
}
return $a;
}
/**
* Logout success method for hooks
*
* #pw-hooker
*
* @param User $user User that logged in
*
*/
protected function ___logoutSuccess(User $user) {
$this->log("Logout for '$user->name'");
}
/**
* Redirect this session to another URL.
*
* Execution halts within this function after redirect has been issued.
*
* ~~~~~
* // redirect to homepage
* $session->redirect('/');
* ~~~~~
*
* @param string $url URL to redirect to
* @param bool|int $status Specify true for 301 permanent redirect, false for 302 temporary redirect, or
* in 3.0.166+ you can also specify the status code (integer) rather than boolean.
* Default is 301 (permanent).
*
*/
public function ___redirect($url, $status = 301) {
$page = $this->wire()->page;
if($status === true || "$status" === "301" || "$status" === "1") {
$status = 301;
} else if($status === false || "$status" === "302" || "$status" === "0") {
$status = 302;
} else {
$status = (int) $status;
// if invalid redirect http status code, fallback to 302
if($status < 300 || $status > 399) $status = 302;
}
// if there are notices, then queue them so that they aren't lost
if($this->sessionInit) {
$notices = $this->wire()->notices;
if($notices && count($notices)) {
foreach($notices as $notice) {
$this->queueNotice($notice);
}
}
}
// perform the redirect
if($page) {
$process = $this->wire()->process;
if("$process" !== "ProcessPageView") {
$process = $this->wire()->modules->get('ProcessPageView');
}
/** @var ProcessPageView $process */
if($process) {
// ensure ProcessPageView is properly closed down
$process->setResponseType(ProcessPageView::responseTypeRedirect);
$process->finished();
// retain modal=1 get variables through redirects (this can be moved to a hook later)
$input = $this->wire()->input;
if($page->template == 'admin' && $input && $input->get('modal') && strpos($url, '//') === false) {
if(!strpos($url, 'modal=')) $url .= (strpos($url, '?') !== false ? '&' : '?') . 'modal=1';
}
}
}
$this->wire()->setStatus(ProcessWire::statusFinished, array(
'redirectUrl' => $url,
'redirectType' => $status,
));
// note for 302 redirects we send no header other than 'Location: url'
$http = new WireHttp();
$this->wire($http);
$http->sendStatusHeader($status);
$http->sendHeader("Location: $url");
exit(0);
}
/**
* Perform a temporary (302) redirect
*
* This is an alias of `$session->redirect($url, false);` that sends only the
* location header, which translates to a 302 redirect.
*
* @param string $url
* @since 3.0.166
*
*/
public function location($url) {
$this->redirect($url, false);
}
/**
* Manually close the session, before program execution is done
*
* #pw-internal
*
*/
public function close() {
if($this->sessionInit) session_write_close();
}
/**
* Queue notice text to be shown the next time this session class is instantiated
*
* #pw-internal
*
* @param string $text
* @param string $type One of "messages", "errors" or "warnings"
* @param int $flags
*
*/
protected function queueNoticeText($text, $type, $flags) {
if(!$this->sessionInit) return;
$items = $this->getFor('_notices', $type);
if(is_null($items)) $items = array();
$item = array('text' => $text, 'flags' => $flags);
$items[] = $item;
$this->setFor('_notices', $type, $items);
}
/**
* Queue a Notice object to be shown the next time this session class is instantiated
*
* #pw-internal
*
* @param Notice $notice
*
*/
protected function queueNotice(Notice $notice) {
if(!$this->sessionInit) return;
$type = $notice->getName();
$items = $this->getFor('_notices', $type);
if(is_null($items)) $items = array();
$items[] = $notice->getArray();
$this->setFor('_notices', $type, $items);
}
/**
* Pull queued notices and convert them to notices for this request
*
* #pw-internal
*
*/
protected function wakeupNotices() {
/** @var Notices $notices */
$notices = $this->wire('notices');
if(!$notices) return;
$types = array(
'messages' => 'NoticeMessage',
'errors' => 'NoticeError',
'warnings' => 'NoticeWarning',
);
foreach($types as $type => $className) {
$items = $this->getFor('_notices', $type);
if(!is_array($items)) continue;
foreach($items as $item) {
if(!isset($item['text'])) continue;
$class = wireClassName($className, true);
$notice = $this->wire(new $class(''));
$notice->setArray($item);
$notices->add($notice);
}
}
}
/**
* Queue a message to appear on the next pageview
*
* #pw-group-notices
*
* @param string $text Message to queue
* @param int $flags Optional flags, See Notice::flags
* @return $this
*
*/
public function message($text, $flags = 0) {
$this->queueNoticeText($text, 'messages', $flags);
return $this;
}
/**
* Queue an error to appear on the next pageview
*
* #pw-group-notices
*
* @param string $text Error to queue
* @param int $flags See Notice::flags
* @return $this
*
*/
public function error($text, $flags = 0) {
$this->queueNoticeText($text, 'errors', $flags);
return $this;
}
/**
* Queue a warning to appear the next pageview
*
* #pw-group-notices
*
* @param string $text Warning to queue
* @param int $flags See Notice::flags
* @return $this
*
*/
public function warning($text, $flags = 0) {
$this->queueNoticeText($text, 'warnings', $flags);
return $this;
}
/**
* Session maintenance
*
* This is automatically called by ProcessWire at the end of the request,
* no need to call it on your own.
*
* Keep track of session history, if $config->sessionHistory is used.
* It can be retrieved with the $session->getHistory() method.
*
* #pw-internal
*
* @todo add extra gc checks
*
*/
public function maintenance() {
if($this->skipMaintenance || !$this->sessionInit) return;
// prevent multiple calls, just in case
$this->skipMaintenance = true;
$config = $this->wire()->config;
$historyCnt = (int) ($config ? $config->sessionHistory : 0);
if($historyCnt) {
$sanitizer = $this->wire()->sanitizer;
$input = $this->wire()->input;
$page = $this->wire()->page;
if(!$sanitizer || !$input || !$page) return;
$history = $this->get('_user', 'history');
if(!is_array($history)) $history = array();
$item = array(
'time' => time(),
'url' => $sanitizer->entities($input->httpUrl()),
'page' => $page->id,
);
$cnt = count($history);
if($cnt) {
end($history);
$lastKey = key($history);
$nextKey = $lastKey+1;
if($cnt >= $historyCnt) {
if($historyCnt > 1) {
$history = array_slice($history, -1 * ($historyCnt - 1), null, true);
} else {
$history = array();
}
}
} else {
$nextKey = 0;
}
$history[$nextKey] = $item;
$this->set('_user', 'history', $history);
}
}
/**
* Get the session history (if enabled)
*
* Applicable only if `$config->sessionHistory > 0`.
*
* ~~~~~
* $history = $session->getHistory();
* print_r($history);
* // outputs the following:
* array(
* 0 => array(
* 'url' => 'http://domain.com/path/to/page/', // URL
* 'page' => 1234, // page ID
* 'time' => 234993498, // unix timestamp
* ),
* 1 => array(
* // ...
* ),
* 2 => array(
* // ...
* ),
* ...
* );
* ~~~~~
*
* @return array Array of arrays containing history entries.
*
*/
public function getHistory() {
$value = $this->get('_user', 'history');
if(!is_array($value)) $value = array();
return $value;
}
/**
* Remove queued notices
*
* Call this after displaying queued message, error or warning notices.
* This prevents them from re-appearing on the next request.
*
* #pw-group-notices
*
*/
public function removeNotices() {
$this->removeAllFor('_notices');
}
/**
* Return an instance of ProcessWires CSRF object, which provides an API for cross site request forgery protection.
*
* ~~~~
* // output somewhere in <form> markup when rendering a form
* echo $session->CSRF->renderInput();
* ~~~~
* ~~~~
* // when processing form (POST request), check to see if token is present
* if($session->CSRF->hasValidToken()) {
* // form submission is valid
* // okay to process
* } else {
* // form submission is NOT valid
* throw new WireException('CSRF check failed!');
* }
* ~~~~
*
* @return SessionCSRF
* @see SessionCSRF::renderInput(), SessionCSRF::validate(), SessionCSRF::hasValidToken()
*
*/
public function CSRF() {
if(!$this->sessionInit) $this->init(); // init required for CSRF
if(is_null($this->CSRF)) $this->CSRF = $this->wire(new SessionCSRF());
return $this->CSRF;
}
/**
* Get or set current session handler instance (WireSessionHandler)
*
* #pw-internal
*
* @param WireSessionHandler|null $sessionHandler Specify only when setting, omit to get session handler.
* @return null|WireSessionHandler Returns WireSessionHandler instance, or
* returns null when session handler is not yet known or is PHP (file system)
* @since 3.0.166
*
*/
public function sessionHandler(WireSessionHandler $sessionHandler = null) {
if($sessionHandler) $this->sessionHandler = $sessionHandler;
return $this->sessionHandler;
}
}