artabro/wire/core/Session.php
2024-08-27 11:35:37 +02:00

1715 lines
47 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php namespace ProcessWire;
/**
* ProcessWire Session
*
* Start a session with login/logout capability
*
* #pw-summary Maintains sessions in ProcessWire, authentication, persistent variables, notices and redirects.
* #pw-order-groups redirects,get,set,remove,info,notices,authentication,advanced,hooker
*
* 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 2022 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) {
parent::__construct();
$wire->wire($this);
$users = $wire->wire()->users;
$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 = $users->get($userID);
} else {
$this->logout();
}
}
}
if(!$user || !$user->id) $user = $users->getGuestUser();
$users->setCurrentUser($user);
if($sessionAllow) $this->wakeupNotices();
$this->setTrackChanges(true);
}
/**
* Are session cookie(s) present?
*
* #pw-group-info
*
* @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);`
*
* #pw-group-info
*
* @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, '/'));
}
} else {
if(!ini_get('session.gc_probability')) ini_set('session.gc_probability', 1);
if(!ini_get('session.gc_divisor')) ini_set('session.gc_divisor', 100);
}
$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;
* ~~~~~
*
* #pw-group-get
*
* @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($_key !== null) {
// 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.
*
* #pw-group-get
*
* @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
*
* #pw-group-get
*
* @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
*
* #pw-group-get
*
* @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;
* ~~~~~
*
* #pw-group-set
*
* @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');
* ~~~~~
*
* #pw-group-get
*
* @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.
*
* #pw-group-get
*
* @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');
* ~~~~~
*
* #pw-group-set
*
* @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);
* ~~~~~
*
* #pw-group-remove
*
* @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
*
* #pw-group-remove
*
* @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
*
* #pw-group-remove
*
* @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
*
* #pw-group-retrieval
*
* @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 $name
* @return SessionCSRF|mixed|null
*
*/
public function __get($name) {
return $this->get($name);
}
/**
* 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>";
* }
* ~~~~~
*
* #pw-internal
*
* @return \ArrayObject
*
*/
#[\ReturnTypeWillChange]
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
* ~~~~~
*
* #pw-group-info
*
* @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 {
$ip = $_SERVER['REMOTE_ADDR'];
}
// 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;
$sanitizer = $this->wire()->sanitizer;
$users = $this->wire()->users;
$guestUserID = $this->wire()->config->guestUserPageID;
$fail = true;
$failReason = '';
if($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 instanceof User) {
$sanitizer = $this->wire()->sanitizer;
$name = $sanitizer->pageNameUTF8($name);
$user = $this->wire()->users->get('name=' . $sanitizer->selectorValue($name));
}
if(!$user instanceof User || !$user->id) return false;
if($user->isGuest() || $user->isUnpublished()) return false;
$xroles = $this->config->loginDisabledRoles;
if(!is_array($xroles) && !empty($xroles)) {
$xroles = array($xroles);
}
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;
$users = $this->wire()->users;
if($user) $this->logoutSuccess($user);
$guest = $users->getGuestUser();
if($this->wire()->languages && "$user->language" != "$guest->language") {
$guest->language = $user->language;
}
$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('/');
* ~~~~~
*
* #pw-group-redirects
*
* @param string $url URL to redirect to
* @param bool|int $status One of the following (or omit for 301):
* - `true` (bool): Permanent redirect (same as 301).
* - `false` (bool): Temporary redirect (same as 302).
* - `301` (int): Permanent redirect using GET. (3.0.166+)
* - `302` (int): “Found”, Temporary redirect using GET. (3.0.166+)
* - `303` (int): “See other”, Temporary redirect using GET. (3.0.166+)
* - `307` (int): Temporary redirect using current request method such as POST (repeat that request). (3.0.166+)
* @see Session::location()
*
*/
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 redirect
*
* This is an alias of `$session->redirect($url, false);` that sends only the
* location header, which translates to a 302 redirect.
*
* #pw-group-redirects
*
* @param string $url
* @param int $status One of the following HTTP status codes, or omit for 302 (added 3.0.192):
* - `302` (int): “Found”, Temporary redirect using GET. (default)
* - `303` (int): “See other”, Temporary redirect using GET.
* - `307` (int): Temporary redirect using current request method such as POST (repeat that request).
* @since 3.0.166
* @see Session::redirect()
*
*/
public function location($url, $status = 302) {
$this->redirect($url, $status);
}
/**
* Manually close the session, before program execution is done
*
* A user session is limited to rendering one page at a time, unless the session is closed
* early. Use this when you have a request that may take awhile to render (like a request
* rendering a sitemap, etc.) and you don't need to get/save session data. By closing the session
* before starting a render, you can release the session to be available for the user to view
* other pages while the slower page render continues.
*
*/
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() {
$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;
$historyCnt = (int) ($this->config ? $this->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(
* // ...
* ),
* ...
* );
* ~~~~~
*
* #pw-group-advanced
*
* @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!');
* }
* ~~~~
*
* #pw-group-advanced
*
* @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;
}
}