From 215e2c56e1e1ad0030cf5e57d6805214e9c6b226 Mon Sep 17 00:00:00 2001 From: Ryan Cramer Date: Wed, 18 Sep 2019 11:26:42 -0400 Subject: [PATCH] Update $input->cookie API variable so that it can now also set cookies (in addition to just getting them). Default cookie settings are controlled from new $config->cookieOptions array. --- wire/config.php | 24 +++ wire/core/Config.php | 3 +- wire/core/Session.php | 59 +++++- wire/core/WireInput.php | 7 +- wire/core/WireInputData.php | 30 +++ wire/core/WireInputDataCookie.php | 337 ++++++++++++++++++++++++++++++ 6 files changed, 448 insertions(+), 12 deletions(-) create mode 100644 wire/core/WireInputDataCookie.php diff --git a/wire/config.php b/wire/config.php index 6b2fac0c..f419db07 100644 --- a/wire/config.php +++ b/wire/config.php @@ -901,6 +901,30 @@ $config->wireInputOrder = 'get post'; */ $config->wireInputLazy = false; +/** + * Options for setting cookies from $input->cookie()->set(...) + * + * Additional details about some of these options can also be found on PHP’s [setcookie](https://www.php.net/manual/en/function.setcookie.php) doc page. + * + * #property int age Max age of cookies in seconds or 0 to expire with session (3600=1hr, 86400=1day, 604800=1week, 2592000=30days, etc.) + * #property string|null Cookie path or null for PW installation’s root URL (default=null). + * #property string|null|bool domain Cookie domain: null for current hostname, true for all subdomains of current domain, domain.com for domain and all subdomains, www.domain.com for www subdomain. + * #property bool|null secure Transmit cookies only over secure HTTPS connection? (true, false, or null to auto-detect, using true option for cookies set when HTTPS is active). + * #property bool httponly When true, cookie is http/server-side and not visible to JS code in most browsers. + * + * @var array + * @since 3.0.141 + * + */ +$config->cookieOptions = array( + 'age' => 604800, // Max age of cookies in seconds or 0 to expire with session (3600=1hr, 86400=1day, 604800=1week, 2592000=30days, etc.) + 'path' => null, // Cookie path/URL or null for PW installation’s root URL (default=null). + 'domain' => null, // Cookie domain: null for current hostname, true for all subdomains of current domain, domain.com for domain and all subdomains, www.domain.com for www subdomain. + 'secure' => null, // Transmit cookies only over secure HTTPS connection? (true, false, or null to auto-detect, substituting true for cookies set when HTTPS is active). + 'httponly' => false, // When true, cookie is http/server-side only and not visible to client-side JS code. + 'fallback' => true, // If set cookie fails (perhaps due to output already sent), attempt to set at beginning of next request? (default=true) +); + /*** 7. DATABASE ********************************************************************************/ diff --git a/wire/core/Config.php b/wire/core/Config.php index df938c65..9156b023 100644 --- a/wire/core/Config.php +++ b/wire/core/Config.php @@ -63,7 +63,7 @@ * @property bool|callable $sessionAllow Are sessions allowed? Typically boolean true, unless provided a callable function that returns boolean. See /wire/config.php for an example. #pw-group-session * @property int $sessionExpireSeconds How many seconds of inactivity before session expires? #pw-group-session * @property bool $sessionChallenge Should login sessions have a challenge key? (for extra security, recommended) #pw-group-session - * @property bool $sessionFingerprint Should login sessions be tied to IP and user agent? 0 or false: Fingerprint off. 1 or true: Fingerprint on with default/recommended setting (currently 10). 2: Fingerprint only the remote IP. 4: Fingerprint only the forwarded/client IP (can be spoofed). 8: Fingerprint only the useragent. 10: Fingerprint the remote IP and useragent (default). 12: Fingerprint the forwarded/client IP and useragent. 14: Fingerprint the remote IP, forwarded/client IP and useragent (all). #pw-group-session + * @property int|bool $sessionFingerprint Should login sessions be tied to IP and user agent? 0 or false: Fingerprint off. 1 or true: Fingerprint on with default/recommended setting (currently 10). 2: Fingerprint only the remote IP. 4: Fingerprint only the forwarded/client IP (can be spoofed). 8: Fingerprint only the useragent. 10: Fingerprint the remote IP and useragent (default). 12: Fingerprint the forwarded/client IP and useragent. 14: Fingerprint the remote IP, forwarded/client IP and useragent (all). #pw-group-session * @property int $sessionHistory Number of session entries to keep (default=0, which means off). #pw-group-session * @property string $sessionForceIP Force the client IP address returned by $session->getIP() to be this rather than auto-detect (useful with load balancer). Use for setting value only. #pw-group-session * @property array $loginDisabledRoles Array of role name(s) or ID(s) of roles where login is disallowed. #pw-group-session @@ -88,6 +88,7 @@ * @property int $maxUrlDepth Maximum URL/path slashes (depth) for request URLs. (Min=10, Max=60) #pw-group-URLs * @property string $wireInputOrder Order that variables with the $input API var are handled when you access $input->var. #pw-group-HTTP-and-input * @property bool $wireInputLazy Specify true for $input API var to load input data in a lazy fashion and potentially use less memory. Default is false. #pw-group-HTTP-and-input + * @property array $cookieOptions Options for setting cookies from $input->cookie #pw-group-HTTP-and-input * * @property bool $advanced Special mode for ProcessWire system development. Not recommended for regular site development or production use. #pw-group-system * @property bool $demo Special mode for demonstration use that causes POST requests to be disabled. Applies to core, but may not be safe with 3rd party modules. #pw-group-system diff --git a/wire/core/Session.php b/wire/core/Session.php index 06728729..9bcc3d05 100644 --- a/wire/core/Session.php +++ b/wire/core/Session.php @@ -63,8 +63,15 @@ class Session extends Wire implements \IteratorAggregate { * Fingerprint bitmask: Use user agent (recommended) * */ - const fingerprintUseragent = 8; - + const fingerprintUseragent = 8; + + /** + * Suffix applied to challenge cookies + * + * @since 3.0.141 + * + */ + const challengeSuffix = '_challenge'; /** * Reference to ProcessWire $config object @@ -198,7 +205,7 @@ class Session extends Wire implements \IteratorAggregate { } else { $name = $this->config->sessionName; } - if($checkLogin) $name .= "_challenge"; + if($checkLogin) $name .= self::challengeSuffix; return !empty($_COOKIE[$name]); } @@ -284,7 +291,8 @@ class Session extends Wire implements \IteratorAggregate { // check challenge cookie if($this->config->sessionChallenge) { - if(empty($_COOKIE[$sessionName . "_challenge"]) || ($this->get('_user', 'challenge') != $_COOKIE[$sessionName . "_challenge"])) { + $cookieName = $sessionName . self::challengeSuffix; + if(empty($_COOKIE[$cookieName]) || ($this->get('_user', 'challenge') != $_COOKIE[$cookieName])) { $valid = false; $reason = "Error: Invalid challenge value"; } @@ -488,6 +496,18 @@ class Session extends Wire implements \IteratorAggregate { } } + /** + * 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 * @@ -857,12 +877,12 @@ class Session extends Wire implements \IteratorAggregate { if($this->config->sessionChallenge) { // create new challenge - $pass = $this->wire(new Password()); - $challenge = $pass->randomBase64String(32); + $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) - setcookie(session_name() . '_challenge', $challenge, time()+60*60*24*30, '/', + setcookie(session_name() . self::challengeSuffix, $challenge, time()+60*60*24*30, '/', $this->config->sessionCookieDomain, $secure, true); } @@ -1055,11 +1075,32 @@ class Session extends Wire implements \IteratorAggregate { if(isset($_COOKIE[$sessionName])) { setcookie($sessionName, '', $time, '/', $this->config->sessionCookieDomain, $secure, true); } - if(isset($_COOKIE[$sessionName . "_challenge"])) { - setcookie($sessionName . "_challenge", '', $time, '/', $this->config->sessionCookieDomain, $secure, true); + if(isset($_COOKIE[$sessionName . self::challengeSuffix])) { + setcookie($sessionName . self::challengeSuffix, '', $time, '/', $this->config->sessionCookieDomain, $secure, true); } } + /** + * 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 * diff --git a/wire/core/WireInput.php b/wire/core/WireInput.php index b06991bc..9c1577fe 100644 --- a/wire/core/WireInput.php +++ b/wire/core/WireInput.php @@ -301,9 +301,12 @@ class WireInput extends Wire { * */ public function cookie($key = '', $valid = null, $fallback = null) { - if(is_null($this->cookieVars)) $this->cookieVars = $this->wire(new WireInputData($_COOKIE, $this->lazy)); + if($this->cookieVars === null) { + $this->cookieVars = $this->wire(new WireInputDataCookie($_COOKIE, $this->lazy)); + $this->cookieVars->init(); + } if(!strlen($key)) return $this->cookieVars; - if($valid === null && $fallback === null && !strpos($key, '[]')) return $this->cookieVars->__get($key); + if($valid === null && $fallback === null && !strpos($key, '[]')) return $this->cookieVars->get($key); return $this->getValidInputValue($this->cookieVars, $key, $valid, $fallback); } diff --git a/wire/core/WireInputData.php b/wire/core/WireInputData.php index 8be0b65e..e32e7462 100644 --- a/wire/core/WireInputData.php +++ b/wire/core/WireInputData.php @@ -159,6 +159,36 @@ class WireInputData extends Wire implements \ArrayAccess, \IteratorAggregate, \C if($this->lazy) $this->unlazyKeys[$key] = $key; } + /** + * Set a value + * + * @param string $key + * @param string|int|float|array|null $value + * @return $this + * @param array|int|string $options Options not currently used, but available for descending classes or future use + * @since 3.0.141 You can also use __set() or set directly for compatibility with all versions + * + */ + public function set($key, $value, $options = array()) { + if($options) {} // not currently used by this class + $this->__set($key, $value); + return $this; + } + + /** + * Get a value + * + * @param string $key + * @param array|int|string $options Options not currently used, but available for descending classes or future use + * @return string|int|float|array|null $value + * @since 3.0.141 You can also get directly or use __get(), both of which are compatible with all versions + * + */ + public function get($key, $options = array()) { + if($options) {} // not currently used by this class + return $this->__get($key); + } + /** * Clean an array of data * diff --git a/wire/core/WireInputDataCookie.php b/wire/core/WireInputDataCookie.php new file mode 100644 index 00000000..5892ae86 --- /dev/null +++ b/wire/core/WireInputDataCookie.php @@ -0,0 +1,337 @@ +cookie API variable + * + * #pw-summary Enables setting and getting cookies from the ProcessWire API using $input->cookie. + * + * #pw-body = + * + * - Whether getting or setting, cookie values are always strings. + * - Values retrieved from `$input->cookie` are user input (like PHP’s $_COOKIE) and should be sanitized and validated. + * - When removing/unsetting cookies, the path, domain, secure, and httponly options must be the same as when the cookie was set, + * as a result, it’s good to have these things predefined in `$config->cookieOptions` rather than setting during runtime. + * - Note that this class does not manage PW’s session cookies. + * + * ~~~~~ + * // setting cookies + * $input->cookie->foo = 'bar'; + * $input->cookie->set('foo', 'bar'); // same as above + * $input->cookie['foo'] = 'bar'; // same as above + * + * // setting cookies, with options + * $input->cookie->set('foo', bar', 86400); // live for 1 day + * $input->cookie->options('age', 3600); // any further set() cookies live for 1 hour (3600s) + * $input->cookie->set('foo', 'bar'); // uses setting from above options() call + * + * // getting cookies + * $bar = $input->cookie->foo; + * $bar = $input->cookie['foo']; // same as above + * $bar = $input->cookie('foo'); // same as above + * $bar = $input->cookie->get('foo'); // same as above + * $bar = $input->cookie->text('foo'); // sanitize with text() sanitizer + * + * // removing cookies + * unset($input->cookie->foo); + * $input->cookie->remove('foo'); // same as above + * $input->cookie->set('foo', null); // same as above + * $input->cookie->removeAll(); // remove all cookies + * + * // to modify default cookie settings, add this to your /site/config.php file and edit: + * $config->cookieOptions = [ + * + * // Max age of cookies in seconds or 0 to expire with session + * // 3600=1hr, 86400=1day, 604800=1week, 2592000=30days, etc. + * 'age' => 604800, + * + * // Cookie path/URL or null for PW installation’s root URL + * 'path' => null, + * + * // Cookie domain: null for current hostname, true for all subdomains of current domain, + * // domain.com for domain and all subdomains, www.domain.com for www subdomain. + * 'domain' => null, + * + * // Transmit cookies only over secure HTTPS connection? + * // Specify true, false, or null to auto-detect (uses true for cookies set when HTTPS). + * 'secure' => null, + * + * // Make cookies accessible by HTTP only? + * // When true, cookie is http/server-side only and not visible to client-side JS code. + * 'httponly' => false, + * + * // If set cookie fails (perhaps due to output already sent), + * // attempt to set at beginning of next request? + * 'fallback' => true, + * ]; + * ~~~~~ + * + * + * ProcessWire 3.x, Copyright 2019 by Ryan Cramer + * https://processwire.com + * + */ + +class WireInputDataCookie extends WireInputData { + + /** + * Are we initialized? + * + * @var bool + * + */ + protected $init = false; + + /** + * Default cookie options + * + * @var array + * + */ + protected $defaultOptions = array( + 'age' => 0, + 'path' => null, + 'domain' => null, + 'secure' => null, + 'httponly' => false, + 'fallback' => true, + ); + + /** + * Cookie options specifically set at runtime + * @var array + * + */ + protected $options = array(); + + /** + * Cookie names not be allowed to be removed + * + * @var array + * + */ + protected $skipCookies = array(); + + /** + * Construct + * + * @param array $input Associative array of variables to store + * @param bool $lazy Use lazy loading? + * + */ + public function __construct(&$input = array(), $lazy = false) { + if($lazy) {} // lazy option not used by cookie + parent::__construct($input, false); + } + + /** + * Initialize and set any pending cookies from previous request + * + * #pw-internal + * + * @since 3.0.141 + * + */ + public function init() { + $this->init = true; + /** @var Session $session */ + $session = $this->wire('session'); + $cookies = $session->getFor($this, ''); + if(!empty($cookies)) { + $this->setArray($cookies); + $session->removeAllFor($this); + } + } + + /** + * Get or set cookie options + * + * - Omit all arguments to get current options. + * - Specify string for $key (and omit $value) to get the value of one option. + * - Specify both $key and $value arguments to set one option. + * - Specify associative array for $key (and omit $value) to set multiple options. + * + * @param string|array|null $key + * @param string|array|int|float|null $value + * @return string|array|int|float|null|$this + * @since 3.0.141 + * + */ + public function options($key = null, $value = null) { + if($key === null) { + // get all + return $this->options; + } else if(is_array($key) && $value === null) { + // set multiple + $this->options = array_merge($this->options, $key); + } else if($value === null) { + // get one + return isset($this->options[$key]) ? $this->options[$key] : null; + } else { + // set one + $this->options[$key] = $value; + } + return $this; + } + + /** + * Set a cookie (directly) + * + * To set options for setting cookie, use $input->cookie->options(key, value); or $config->cookieOptions(key, value); + * Note that options set from $input->cookie->options take precedence over those set to $config. + * + * @param string $key Cookie name + * @param array|float|int|null|string $value Cookie value + * + */ + public function __set($key, $value) { + + if(!$this->init) { + // initial set of existing cookies that are present from constructor + parent::__set($key, $value); + return; + } + + $this->setCookie($key, $value, array()); + } + + /** + * Set a cookie (optionally with options) + * + * @param string $key Cookie name + * @param string $value Cookie value + * @param array|int|string $options Optionally specify max age in seconds (int) or array with any of the following options: + * - `age` (int): Max age of cookies in seconds or 0 to expire with session (3600=1hr, 86400=1day, 604800=1week, 2592000=30days, etc.) + * - `path` (string|null): Cookie path/URL or null for PW installation’s root URL. + * - `domain` (string|bool|null): Cookie domain: null for current hostname, true for all subdomains of current domain, domain.com for domain and all subdomains, www.domain.com for www subdomain. + * - `secure` (bool|null): Transmit cookies only over secure HTTPS connection? (true, false, or null to auto-detect, substituting true for cookies set when HTTPS is active). + * - `httponly` (bool): When true, cookie is http/server-side only and not visible to client-side JS code. + * - `fallback` (bool): If set cookie fails (perhaps due to output already sent), attempt to set at beginning of next request? + * @return $this + * @since 3.0.141 + * + */ + public function set($key, $value, $options = array()) { + + if(!$this->init) { + parent::__set($key, $value); + return $this; + } + + if(!is_array($options) && ctype_digit("$options")) { + $age = (int) $options; + $options = array('age' => $age); + } + + $this->setCookie($key, $value, $options); + + return $this; + } + + /** + * Set a cookie (internal) + * + * @param string $key + * @param string|array|int|float $value + * @param array $options Optionally override options from $config->cookieOptions and any previously set from an options() call: + * - `age` (int): Max age of cookies in seconds or 0 to expire with session (3600=1hr, 86400=1day, 604800=1week, 2592000=30days, etc.) + * - `path` (string|null): Cookie path/URL or null for PW installation’s root URL. + * - `domain` (string|bool|null): Cookie domain: null for current hostname, true for all subdomains of current domain, domain.com for domain and all subdomains, www.domain.com for www subdomain. + * - `secure` (bool|null): Transmit cookies only over secure HTTPS connection? (true, false, or null to auto-detect, substituting true for cookies set when HTTPS is active). + * - `httponly` (bool): When true, cookie is http/server-side only and not visible to client-side JS code. + * - `fallback` (bool): If set cookie fails (perhaps due to output already sent), attempt to set at beginning of next request? + * @return bool + * + */ + protected function setCookie($key, $value, array $options) { + + /** @var Config $config */ + $config = $this->wire('config'); + $options = array_merge($this->defaultOptions, $config->cookieOptions, $this->options, $options); + + $expires = $options['age'] ? time() + (int) $options['age'] : 0; + $path = $options['path'] === null || $options['path'] === true ? $config->urls->root : $options['path']; + $secure = $options['secure'] === null ? (bool) $config->https : (bool) $options['secure']; + $httponly = (bool) $options['httponly']; + $domain = $options['domain']; + $remove = $value === null; + + if(!$this->allowSetCookie($key)) return false; + + // determine what to use for the domain argument + if($domain === null) { + // use current http host + $domain = $config->httpHost; + } else if($domain === true) { + // allow all subdomains off current domain + $parts = explode('.', $config->httpHost); + $domain = count($parts) > 1 ? implode('.', array_slice($parts, -2)) : $config->httpHost; + } + + // remove port from domain, as it is not compatible with setcookie() + if(strpos($domain, ':') !== false) list($domain,) = explode(':', $domain, 2); + + // check if cookie should be deleted + if($remove) list($value, $expires) = array('', 1); + + // set the cookie + $result = setcookie($key, $value, $expires, $path, $domain, $secure, $httponly); + + if($result === false && $options['fallback']) { + // output must have already started, set at construct on next request + $this->wire('session')->setFor($this, $key, $value); + } + + if($remove) { + parent::offsetUnset($key); + unset($_COOKIE[$key]); + } else { + parent::__set($key, $value); + $_COOKIE[$key] = $value; + } + + return $result; + } + + /** + * Unset a cookie value + * + * #pw-internal + * + * @param mixed $key + * + */ + public function offsetUnset($key) { + if(!$this->allowSetCookie($key)) return; + parent::offsetUnset($key); + $this->setCookie($key, null, array()); + unset($_COOKIE[$key]); + } + + /** + * Allow cookie with given name to be set or unset? + * + * @param string $name + * @return bool + * + */ + protected function allowSetCookie($name) { + if(empty($this->skipCookies)) $this->skipCookies = $this->wire('session')->getCookieNames(); + return in_array($name, $this->skipCookies) ? false : true; + } + + /** + * Remove all cookies (other than those required for current session) + * + * @return $this|WireInputData + * + */ + public function removeAll() { + foreach($this as $key => $value) { + $this->offsetUnset($key); + } + return $this; + } + +} + +