diff --git a/e107_handlers/session_handler.php b/e107_handlers/session_handler.php new file mode 100644 index 000000000..58a2311f8 --- /dev/null +++ b/e107_handlers/session_handler.php @@ -0,0 +1,1075 @@ + + * Session handler is validating corresponding session COOKIE + * (named as current session name, keeping the session id) + * on regular basis (session lifetime/4). If validation + * fails, corresponding cookie is destroyed (not the session itself). + * + * Initial system Session is started after language detection (see class2.php) to + * ensure proper session handling for sites using language sub-domains (e.g. fr.site.com) + * + * Some important system session data will be kept outside of the object for now (e.g. user validation data) + * + */ + + +class e_session +{ + /** + * No protection, label 'Looking for troubles' + * @var integer + */ + const SECURITY_LEVEL_NONE = 0; + + /** + * Default system protection, balanced for best user experience, + * label 'Safe mode - Balanced' + * @var integer + */ + const SECURITY_LEVEL_BALANCED = 5; + + /** + * Adds more system security, but there is a chance (minimal) to break stuff, + * label 'High Security' + * @var integer + */ + const SECURITY_LEVEL_HIGH = 7; + + /** + * High system protection, session id is regenerated on every page request, + * label 'Paranoid' + * @var integer + */ + const SECURITY_LEVEL_PARANOID = 9; + + /** + * Highest system protection, session id and token values are regenerated on every page request, + * label 'Insane' + * @var unknown_type + */ + const SECURITY_LEVEL_INSANE = 10; + + /** + * Session save path + * @var string + */ + protected $_sessionSavePath = false; + + /** + * Session save method + * @var string files|db + */ + protected $_sessionSaveMethod = 'files'; + + /** + * Session cache limiter, ignored if empty + * php.net/manual/en/function.session-cache-limiter.php + * @var string public|private_no_expire|private|nocache + */ + protected $_sessionCacheLimiter = ''; + + protected $_namespace; + protected $_name; + + /** + * Validation options + * @var boolean + */ + protected $_sessionValidateRemoteAddr = true; + protected $_sessionValidateHttpVia = true; + protected $_sessionValidateHttpXForwardedFor = true; + protected $_sessionValidateHttpUserAgent = true; + + /** + * Skip validation + * @var array + */ + protected $_sessionValidateRemoteAddrSkip = array(); + protected $_sessionValidateHttpViaSkip = array(); + protected $_sessionValidateHttpXForwardedForSkip = array(); + protected $_sessionValidateHttpUserAgentSkip = array(); + + /** + * Default session options + * @var array + */ + protected $_options = array( + 'lifetime' => 3600, // 1 hour + 'path' => '', + 'domain' => '', + 'secure' => false, + 'httponly' => false, + ); + + /** + * Session data + * @var array + */ + protected $_data = array(); + + /** + * Set default settings/options based on the current security level + * NOTE: new prefs 'session_save_path', 'session_save_method', 'session_lifetime' introduced, + * still not added to preference administration + * @return e_session + */ + public function setDefaultSystemConfig() + { + if(!$this->getSessionId()) + { + $config = array( + 'ValidateRemoteAddr' => (e_SECURITY_LEVEL >= self::SECURITY_LEVEL_BALANCED), + 'ValidateHttpVia' => (e_SECURITY_LEVEL >= self::SECURITY_LEVEL_HIGH), + 'ValidateHttpXForwardedFor' => (e_SECURITY_LEVEL >= self::SECURITY_LEVEL_BALANCED), + 'ValidateHttpUserAgent' => (e_SECURITY_LEVEL >= self::SECURITY_LEVEL_HIGH), + ); + + $options = array( + 'httponly' => (e_SECURITY_LEVEL >= self::SECURITY_LEVEL_PARANOID), + ); + + if(!defined('E107_INSTALL')) + { + $config['SavePath'] = e107::getPref('session_save_path', false); + $config['SaveMethod'] = e107::getPref('session_save_method', 'files'); + $options['lifetime'] = (integer) e107::getPref('session_lifetime', 3600); + } + + $this->setConfig($config) + ->setOptions($options); + } + + return $this; + } + + /** + * Retrieve value from current session namespace + * Equals to $_SESSION[NAMESPACE][$key] + * @param string $key + * @param boolean $clear unset key + * @return mixed + */ + public function get($key, $clear = false) + { + $ret = isset($this->_data[$key]) ? $this->_data[$key] : null; + if($clear) $this->clear($key); + return $ret; + } + + /** + * Retrieve value from current session namespace + * If key is null, returns all current session namespace data + * + * @param string|null $key + * @param boolean $clear + * @return mixed + */ + public function getData($key = null, $clear = false) + { + if(null === $key) + { + $ret = $this->_data; + if($clear) $this->clearData(); + return $ret; + } + return $this->get($key, $clear); + } + + /** + * Set value in current session namespace + * Equals to $_SESSION[NAMESPACE][$key] = $value + * @param string $key + * @param mixed $value + * @return e_session + */ + public function set($key, $value) + { + $this->_data[$key] = $value; + return $this; + } + + /** + * Set value in current session namespace + * If $key is array, the whole namespace array will be replaced with it, + * $value will be ignored + * @param string|null $key + * @param mixed $value + * @return e_session + */ + public function setData($key, $value = null) + { + if(is_array($key)) + { + $this->_data = $key; + return $this; + } + return $this->set($key, $value); + } + + /** + * Check if given key is set in current session namespace + * Equals to isset($_SESSION[NAMESPACE][$key]) + * @param string $key + * @return boolean + */ + public function is($key) + { + return isset($this->_data[$key]); + } + + /** + * Check if given key is set and not empty in current session namespace + * Equals to !empty($_SESSION[NAMESPACE][$key]) check + * @param string $key + * @return boolean + */ + public function has($key) + { + return (isset($this->_data[$key]) && $this->_data[$key]); + } + + /** + * Checks if current session namespace contains any data + * Equals to !empty($_SESSION[NAMESPACE]) check + * @return boolean + */ + public function hasData() + { + return !empty($this->_data); + } + + /** + * Unset member of current session namespace array + * Equals to unset($_SESSION[NAMESPACE][$key]) + * @param string $key + * @return e_session + */ + public function clear($key) + { + unset($this->_data[$key]); + return $this; + } + + /** + * Reset current session namespace to empty array + * @return e_session + */ + public function clearData() + { + $this->_data = array(); + return $this; + } + + /** + * Set protected class vars, prefixed with _session + * @param array $config + * @return e_session + */ + public function setConfig($config) + { + foreach ($config as $k => $v) + { + $key = '_session'.$key; + if (isset($this->$key)) $this->$key = $v; + } + return $this; + } + + /** + * Reset session options + * @param array $options + * @return e_session + */ + public function setOptions($options) + { + if (empty($options) || !is_array($options)) return $this; + foreach ($options as $k => $v) + { + switch ($k) + { + case 'lifetime': + $v = intval($v); + break; + + case 'path': + case 'domain': + $v = (string) $v; + break; + + case 'secure': + case 'httponly': + $v = $v ? true : false; + break; + + default: + continue; + break; + } + $this->_options[$k] = $v; + } + return $this; + } + + public function init($namespace, $sessionName = null) + { + $this->start($sessionName); + + if (!isset($_SESSION[$namespace])) + { + $_SESSION[$namespace] = array(); + } + $this->_data =& $_SESSION[$namespace]; + $this->_namespace = $namespace; + + $this->validate(); + $this->validateSessionCookie(); + } + + /** + * Conigure and start session + * + * @param string $sessionName optional session name + * @return e_session + */ + public function start($sessionName = null) + { + if (isset($_SESSION)) + { + return $this; + } + + if (false !== $this->_sessionSavePath && is_writable($this->_sessionSavePath)) + { + session_save_path($this->_sessionSavePath); + } + + switch ($this->_sessionSaveMethod) + { + case 'db': // TODO session db handling, more methods (e.g. memcache) + ini_set('session.save_handler', 'user'); + $session = new e_db_session; + $session->setSaveHandler(); + break; + + default: + session_module_name('files'); + break; + } + + if (empty($this->_options['domain'])) + { + // MULTILANG_SUBDOMAIN set during initial language detection in language handler + $this->_options['domain'] = deftrue('MULTILANG_SUBDOMAIN') ? '.'.e_DOMAIN : ''; + } + + if (empty($this->_options['path'])) + { + $this->_options['path'] = defined('e_HTTP') ? e_HTTP : '/'; + } + + // session name before options - problems reported on php.net + if (!empty($sessionName)) + { + $this->setSessionName($sessionName); + } + + // set session cookie params + session_set_cookie_params($this->_options['lifetime'], + $this->_options['path'], + $this->_options['domain'], + $this->_options['secure'], + $this->_options['httponly']); + + if ($this->_sessionCacheLimiter) + { + session_cache_limiter((string) $this->_sessionCacheLimiter); + } + + session_start(); + + return $this; + } + + /** + * Set session ID + * @param string $sid + * @return e_session + */ + public function setSessionId($sid = null) + { + // comma and minus allowed since 5.0 + if (!empty($sid) && preg_match('#^[0-9a-zA-Z,-]+$#', $sid)) + { + session_id($sid); + } + return $this; + } + + /** + * Retrieve current session id + * @return string + */ + public function getSessionId() + { + return session_id(); + } + + /** + * Set new session name + * @param string $name alphanumeric characters only + * @return string old session name or false on error + */ + public function setSessionName($name) + { + if (!empty($name) && preg_match('#^[0-9a-z_]+$#i', $name)) + { + $this->_name = $name; + return session_name($name); + } + return false; + } + + /** + * Retrieve current session name + * @return string + */ + public function getSessionName() + { + return session_name(); + } + + /** + * Reset session cookie lifetime + * We reset session cookie on every (session_lifetime / 4) seconds + * It's done by all session handler instances, they all share + * one and the same '_cookie_session_validate' variable (global session namespace) + * @return e_session + */ + public function validateSessionCookie() + { + if (!$this->_options['lifetime']) + { + return $this; + } + + if (empty($_SESSION['_cookie_session_validate'])) + { + $time = time() + round($this->_options['lifetime'] / 4); + $_SESSION['_cookie_session_validate'] = $time; + } + elseif ($_SESSION['_cookie_session_validate'] < time()) + { + if (!headers_sent()) + { + cookie(session_name(), session_id(), time() + $this->_options['lifetime'], $this->_options['path'], $this->_options['domain'], $this->_options['secure']); + $time = time() + round($this->_options['lifetime'] / 4); + $_SESSION['_cookie_session_validate'] = $time; + } + } + + return $this; + } + + /** + * Delete session cookie + * @return e_session + */ + public function cookieDelete() + { + cookie(session_name(), null, null, $this->_options['path'], $this->_options['domain'], $this->_options['secure']); + return $this; + } + + /** + * Validate current session + * @return e_session + */ + public function validate() + { + if (!isset($this->_data['_session_validate_data'])) + { + $this->_data['_session_validate_data'] = $this->getValidateData(); + } + elseif (!$this->_validate()) + { + $sessionData = $this->_data['_session_validate_data']; + $validateData = $this->getValidateData(); + + $details = 'USER INFORMATION: '.(isset($_COOKIE[e_COOKIE]) ? $_COOKIE[e_COOKIE] : (isset($_SESSION[e_COOKIE]) ? $_SESSION[e_COOKIE] : 'n/a'))."\n"; + $details .= "HOST: ".$_SERVER['HTTP_HOST']."\n"; + $details .= "REQUEST_URI: ".$_SERVER['REQUEST_URI']."\n"; + $details .= "SESSION OPTIONS: ".print_r($this->_options, true)."\n"; + $details .= "SESSION NAMESPACE: ".$this->_namespace."\n"; + $details .= "SESSION VALIDATION DATA SAVED: ".print_r($sessionData, true)."\n"; + $details .= "SESSION VALIDATION DATA CURRENT: ".print_r($validateData, true)."\n"; + $details .= "CURRENT NAMESPACE SESSION DATA:\n"; + $this->clear('_session_validate_data'); // already logged + $details .= print_r($this->_data, true); + $this->close(false); + $details .= "SESSION GLOBAL DATA:\n"; + $details .= print_r($_SESSION, true); + + // delete cookie, destroy session + $this->cookieDelete()->destroy(); + + // TODO event trigger + + e107::getAdminLog()->log_event('Session validation failed!', $details, E_LOG_FATAL); + // TODO session exception, handle it proper on live site + // throw new Exception(''); + + // just for now + $msg = 'Session validation failed! Go Back'; + die($msg); + } + + return $this; + } + + /** + * Validate current session based on config options + * + * @return bool + */ + protected function _validate() + { + $sessionData = $this->_data['_session_validate_data']; + $validateData = $this->getValidateData(); + $keyvar = '_sessionValidate'; + + foreach ($validateData as $vkey => $value) + { + $var = $keyvar.$vkey; + $varskip = $var.'Skip'; + if ($this->$var && $sessionData[$vkey] != $value && !in_array($value, $this->$varskip)) + { + return false; + } + } + + return true; + } + + /** + * Retrieve data for validator + * @return array + */ + public function getValidateData() + { + $data = array( + 'RemoteAddr' => '', + 'HttpVia' => '', + 'HttpXForwardedFor' => '', + 'HttpUserAgent' => '' + ); + + // collect ip data + if ($_SERVER['REMOTE_ADDR']) + { + $data['RemoteAddr'] = (string) $_SERVER['REMOTE_ADDR']; + } + if (isset($_ENV['HTTP_VIA'])) + { + $data['HttpVia'] = (string) $_ENV['HTTP_VIA']; + } + if (isset($_ENV['HTTP_X_FORWARDED_FOR'])) + { + $data['HttpXForwardedFor'] = (string) $_ENV['HTTP_X_FORWARDED_FOR']; + } + + // collect user agent data + if (isset($_SERVER['HTTP_USER_AGENT'])) + { + $data['HttpUserAgent'] = (string) $_SERVER['HTTP_USER_AGENT']; + } + + return $data; + } + + /** + * Retrieve (create if doesn't exist) XSF protection token + * @param boolean $in_form if true (default) - value for forms, else raw session value + * @return string + */ + public function getFormToken($in_form = true) + { + if(!$this->has('__form_token')) + { + $this->set('__form_token', uniqid(md5(rand()), true)); + } + return ($in_form ? md5($this->get('__form_token')) : $this->get('__form_token')); + } + + /** + * Regenerate form token value + * TODO - save old token + * @return e_session + */ + protected function _regenerateFormToken() + { + $this->set('__form_token', uniqid(md5(rand()), true)); + return $this; + } + + /** + * Do a check against passed token + * @param string $token + * @return boolean + */ + public function checkFormToken($token) + { + $utoken = $this->getFormToken(false); + return ($token === md5($utoken)); + } + + /** + * Clear and Unset current namespace, unregister session singleton + * e107::getSession('namespace') if needed. + * @param boolean $unregister if true (default) - unregister Singleton, destroy namespace, + * else alias of self::clearData() + * @return void + */ + public function close($unregister = true) + { + $this->clearData(); + if($unregister) + { + unset($_SESSION[$this->_namespace]); + e107::setRegistry('core/e107/session/'.$this->_namespace, null); + } + } + + /** + * Save session data to disk, end session. + * Sessions can't be used after this point. + * Method should be called before every header redirect. + * @return void + */ + public function end() + { + session_write_close(); + } + + /** + * Destroy all session data + * @return e_session + */ + public function destroy() + { + $this->cookieDelete()->close(); + //unset($_SESSION); + + // cleanup + cookie(e_COOKIE, null, null); // remove user auth cookie + // unset($_SESSION['_cookie_session_validate']); + + session_destroy(); + return $this; + } +} + +class e_core_session extends e_session +{ + /** + * Constructor + * 3rd party code and/or other system areas are + * able to extend the base e_session class and + * add more or override the implemented functionality, has their own + * namespace, add more session security etc. + * @param array $config session config data + */ + public function __construct($data = array()) + { + // default system configuration + $this->setDefaultSystemConfig(); + + $namespace = 'e107'; + $name = deftrue('e_COOKIE', 'e107').'SID'; + if(isset($data['namespace']) && !empty($data['namespace'])) $namespace = $data['namespace']; + // create $_SESSION['e107'] namespace by default + $this->init($namespace, $name); + } + + /** + * Session shutdown - called at the top of footer_default.php by default + * @return void + */ + public function shutdown() + { + if(!session_id()) // someone closed the session? + { + $this->init($this->_namespace, $this->_name); // restart + } + + // give 3rd party code a way to prevent token re-generation + if(e_SECURITY_LEVEL >= e_session::SECURITY_LEVEL_PARANOID && !deftrue('e_TOKEN_FREEZE')) + { + if(e_SECURITY_LEVEL == e_session::SECURITY_LEVEL_INSANE) + { + // regenerate SID + $oldSID = session_id(); // old SID + $oldSData = $_SESSION; // old session data + session_regenerate_id(false); // true don't work on php4 - so time to move on people! + $newSID = session_id(); // new SID + + // Clean + session_id($oldSID); // switch to the old session + session_destroy(); // destroy it + + // set new ID, reopen the session, set saved data + session_id($newSID); + session_start(); + $_SESSION = $oldSData; + } + $this->set('__form_token_regenerate', time()); // check() needs it to re-create token on the next request + } + // write session data + $this->end(); + } + + /** + * Core CSF protection, see class2.php + * Could be adopted by plugins for their own (different) protection logic + * @return e_core_session + */ + public function check() + { + // define('e_TOKEN_NAME', 'e107_token_'.md5($_SERVER['HTTP_HOST'].e_HTTP)); + // TODO e-token required for all system forms? + + // only if not disabled and not in 'cli' mod + if(e_SECURITY_LEVEL < e_session::SECURITY_LEVEL_BALANCED || e107::getE107('cli')) return $this; + + if($this->getSessionId()) + { + if((isset($_POST['e-token']) && !$this->checkFormToken($_POST['e-token'])) + || (isset($_GET['e-token']) && !$this->checkFormToken($_GET['e-token']))) + { + if(defsettrue('e_DEBUG')) + { + $details = "HOST: ".$_SERVER['HTTP_HOST']."\n"; + $details .= "REQUEST_URI: ".$_SERVER['REQUEST_URI']."\n"; + $details .= "_SESSION:\n"; + $details .= print_r($_SESSION,true); + $details .= "\n_POST:\n"; + $details .= print_r($_POST,true); + $details .= "\n_GET:\n"; + $details .= print_r($_GET,true); + $details .= "\nPlugins:\n"; + $details .= print_r($pref['plug_installed'],true); + + e107::getAdminLog()->log_event('Unauthorized access!', $details, E_LOG_FATAL); + } + // do not redirect, prevent dead loop, save server resources + die('Unauthorized access!'); + } + } + + if(!defined('e_TOKEN')) + { + // FREEZE token regeneration if minimal, ajax or iframe (ajax and iframe not implemented yet) request + $_toFreeze = (e107::getE107('minimal') || e107::getE107('ajax') || e107::getE107('iframe')); + if(!defined('e_TOKEN_FREEZE') && $_toFreeze) + { + define('e_TOKEN_FREEZE', true); + } + // __form_token_regenerate set in footer, so if footer is not called, token will be never regenerated! + if(e_SECURITY_LEVEL == e_session::SECURITY_LEVEL_INSANE && !deftrue('e_TOKEN_FREEZE') && $this->has('__form_token_regenerate')) + { + $this->_regenerateFormToken() + ->clear('__form_token_regenerate'); + } + define('e_TOKEN', $this->getFormToken()); + } + return $this; + } + + /** + * Creates (once per session) unique challenge string for CHAP login + * @see class2.php + * @return e_core_session + */ + public function challenge() + { + if (!$this->is('challenge')) + { + // Create a unique challenge string for CHAP login + // FIXME - session id will be regenerated if e_SECURITY_LEVEL is 'paranoid|insane' + $this->set('challenge', sha1(time().$this->getSessionId())); + } + // could go, see _validate() + $ubrowser = md5('E107'.$_SERVER['HTTP_USER_AGENT']); + if (!$this->is('ubrowser')) + { + $this->set('ubrowser', $ubrowser); + } + return $this; + } +} + +/* SQL to be added +CREATE TABLE session ( + `session_id` varchar(255) NOT NULL default '', + `session_expires` int(10) unsigned NOT NULL default 0, + `session_data` text NOT NULL, + PRIMARY KEY (`session_id`), +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + */ +class e_db_session +{ + /** + * @var e_db_mysql + */ + protected $_db = null; + + /** + * Table name + * @var string + */ + protected $_table = 'session'; + + /** + * @var integer + */ + protected $_lifetime = null; + + public function __construct() + { + $this->_db = e107::getDb('session'); + } + + public function __destruct() + { + session_write_close(); + } + + /** + * @return string + */ + public function getTable() + { + return $this->_table; + } + + /** + * @param string $table + * @return e_db_session + */ + public function setTable($table) + { + $this->_table = $table; + return $this; + } + + /** + * @return integer + */ + public function getLifetime() + { + if(null === $this->_lifetime) + { + $this->_lifetime = ini_get('session.gc_maxlifetime'); + if(!$this->_lifetime) + { + $this->_lifetime = 3600; + } + } + return (integer) $this->_lifetime; + } + + /** + * @param integer $seconds + * @return e_db_session + */ + public function setLifetime($seconds = null) + { + $this->_lifetime = $seconds; + return $this; + } + + /** + * Set session save handler + * @return e_db_session + */ + public function setSaveHandler() + { + session_set_save_handler( + array($this, 'open'), + array($this, 'close'), + array($this, 'read'), + array($this, 'write'), + array($this, 'destroy'), + array($this, 'gc') + ); + return $this; + } + + /** + * Open session, parameters are ignored (see e_session handler) + * @param string $save_path + * @param string $sess_name + * @return boolean + */ + public function open($save_path, $sess_name) + { + return true; + } + + /** + * Close session + * @return boolean + */ + public function close() + { + $this->gc($this->getLifetime()); + return true; + } + + /** + * Get session data + * @param string $session_id + * @return string + */ + public function read($session_id) + { + $data = false; + $check = $this->_db->db_Select($this->getTable(), 'session_data', "session_id='".$this->_sanitize($session_id)."' AND session_expires>".time()); + if($check) + { + $tmp = $this->_db->db_Fetch(); + $data = base64_decode($tmp['session_data']); + } + elseif(false !== $check) + { + $data = ''; + } + return $data; + } + + /** + * Write session data + * @param string $session_id + * @param string $session_data + * @return boolean + */ + public function write($session_id, $session_data) + { + $data = array( + 'data' => array( + 'session_expires' => time() + $this->getLifetime(), + 'session_data' => base64_encode($session_data), + ), + '_FIELD_TYPES' => array( + 'session_id' => 'str', + 'session_expires' => 'int', + 'session_data' => 'str' + ), + '_DEFAULT' => 'str' + ); + if(!($session_id = $this->_sanitize($session_id))) + { + return false; + } + + $check = $this->_db->db_Select($this->getTable(), 'session_id', "`session_id`='{$session_id}'"); + + if($check) + { + $data['WHERE'] = "`session_id`='{$session_id}'"; + if(false !== $this->_db->db_Update($this->getTable(), $data)) + { + return true; + } + } + else + { + $data['data']['session_id'] = $session_id; + if($this->_db->db_Insert($this->getTable(), $data)) + { + return true; + } + } + return false; + } + + /** + * Destroy session + * @param string $session_id + * @return boolean + */ + public function destroy($session_id) + { + $session_id = $this->_sanitize($session_id); + $this->_db->db_Delete($this->getTable(), "`session_id`='{$session_id}'"); + return true; + } + + /** + * Garbage collection + * @param integer $session_maxlf ignored - see write() + * @return boolean + */ + public function gc($session_maxlf) + { + $this->_db->db_Delete($this->getTable(), '`session_expires`<'.time()); + return true; + } + + /** + * Allow only well formed session id string + * @param string $session_id + * @return string + */ + protected function _sanitize($session_id) + { + return preg_replace('#[^0-9a-zA-Z,-]#', '', $session_id); + } +}