allowed session id regenerate (rotate) options */ private const ALLOWED_REGENERATE_OPTIONS = ['none', 'random', 'interval']; /** @var int default random interval */ public const DEFAULT_REGENERATE_RANDOM = 100; /** @var int default rotate internval in minutes */ public const DEFAULT_REGENERATE_INTERVAL = 5 * 60; /** @var int maximum time for regenerate interval is one hour */ public const MAX_REGENERATE_INTERAL = 60 * 60; /** * init a session, if array is empty or array does not have session_name set * then no auto init is run * * @param string $session_name if set and not empty, will start session * @param array{auto_write_close?:bool,session_strict?:bool,regenerate?:string,regenerate_interval?:int} $options */ public function __construct( string $session_name, array $options = [] ) { $this->setOptions($options); $this->initSession($session_name); } // MARK: private methods /** * set session class options * * @param array{auto_write_close?:bool,session_strict?:bool,regenerate?:string,regenerate_interval?:int} $options * @return void */ private function setOptions(array $options): void { if ( !isset($options['auto_write_close']) || !is_bool($options['auto_write_close']) ) { $options['auto_write_close'] = false; } $this->auto_write_close = $options['auto_write_close']; if ( !isset($options['session_strict']) || !is_bool($options['session_strict']) ) { $options['session_strict'] = true; } // set strict options, on not started sessiononly if ( $options['session_strict'] && $this->getSessionStatus() === PHP_SESSION_NONE ) { // use cookies to store session IDs ini_set('session.use_cookies', 1); // use cookies only (do not send session IDs in URLs) ini_set('session.use_only_cookies', 1); // do not send session IDs in URLs ini_set('session.use_trans_sid', 0); } // session regenerate id options if ( empty($options['regenerate']) || !in_array($options['regenerate'], self::ALLOWED_REGENERATE_OPTIONS) ) { $options['regenerate'] = 'never'; } $this->regenerate = (string)$options['regenerate']; // for regenerate: 'random' (default 100) // regenerate_interval must be between (1 = always) and 100 (1 in 100) // for regenerate: 'interval' (default 5min) // regenerate_interval must be 0 = always, to 3600 (every hour) if ( $options['regenerate'] == 'random' && ( !isset($options['regenerate_interval']) || !is_numeric($options['regenerate_interval']) || $options['regenerate_interval'] < 0 || $options['regenerate_interval'] > 100 ) ) { $options['regenerate_interval'] = self::DEFAULT_REGENERATE_RANDOM; } if ( $options['regenerate'] == 'interval' && ( !isset($options['regenerate_interval']) || !is_numeric($options['regenerate_interval']) || $options['regenerate_interval'] < 1 || $options['regenerate_interval'] > self::MAX_REGENERATE_INTERAL ) ) { $options['regenerate_interval'] = self::DEFAULT_REGENERATE_INTERVAL; } $this->regenerate_interval = (int)($options['regenerate_interval'] ?? 0); } /** * Start session * startSession should be called for complete check * If this is called without any name set before the php.ini name is * used. * * @return void */ private function startSessionCall(): void { session_start(); } /** * get current set session id or false if none started * * @return string|false */ public function getSessionIdCall(): string|false { return session_id(); } /** * automatically closes a session if the auto write close flag is set * * @return bool */ private function closeSessionCall(): bool { if ($this->auto_write_close) { return $this->writeClose(); } return false; } // MARK: regenerate session /** * auto rotate session id * * @return void * @throws \RuntimeException failure to regenerate session id * @throws \UnexpectedValueException failed to get new session id * @throws \RuntimeException failed to set new sesson id * @throws \UnexpectedValueException new session id generated does not match the new set one */ private function sessionRegenerateSessionId() { // never if ($this->regenerate == 'never') { return; } // regenerate if ( !( // is not session obsolete empty($_SESSION['SESSION_REGENERATE_OBSOLETE']) && ( ( // random $this->regenerate == 'random' && mt_rand(1, $this->regenerate_interval) == 1 ) || ( // interval type $this->regenerate == 'interval' && ($_SESSION['SESSION_REGENERATE_TIMESTAMP'] ?? 0) + $this->regenerate_interval < time() ) ) ) ) { return; } // Set current session to expire in 1 minute $_SESSION['SESSION_REGENERATE_OBSOLETE'] = true; $_SESSION['SESSION_REGENERATE_EXPIRES'] = time() + 60; $_SESSION['SESSION_REGENERATE_TIMESTAMP'] = time(); // Create new session without destroying the old one if (session_regenerate_id(false) === false) { throw new \RuntimeException('[SESSION] Session id regeneration failed', 1); } // Grab current session ID and close both sessions to allow other scripts to use them if (false === ($new_session_id = $this->getSessionIdCall())) { throw new \UnexpectedValueException('[SESSION] getSessionIdCall did not return a session id', 2); } $this->writeClose(); // Set session ID to the new one, and start it back up again if (($get_new_session_id = session_id($new_session_id)) === false) { throw new \RuntimeException('[SESSION] set session_id failed', 3); } if ($get_new_session_id != $new_session_id) { throw new \UnexpectedValueException('[SESSION] new session id does not match the new set one', 4); } $this->session_id = $new_session_id; $this->startSessionCall(); // Don't want this one to expire unset($_SESSION['SESSION_REGENERATE_OBSOLETE']); unset($_SESSION['SESSION_REGENERATE_EXPIRES']); } // MARK: session validation /** * check if session name is valid * * As from PHP 8.1/8.0/7.4 error * INVALID CHARS: =,; \t\r\n\013\014 * NOTE: using . will fail even thought valid * we allow only alphanumeric with - (dash) and 1 to 128 characters * * @param string $session_name any string, not null * @return bool True for valid, False for invalid */ public static function checkValidSessionName(string $session_name): bool { // check if ( // must only have those !preg_match('/^[-a-zA-Z0-9]{1,128}$/', $session_name) || // cannot be only numbers preg_match('/^[0-9]+$/', $session_name) ) { return false; } return true; } /** * validate _SESSION key, must be valid variable * * @param int|float|string $key * @return true */ private function checkValidSessionEntryKey(int|float|string $key): true { if (!is_string($key) || is_numeric($key)) { throw new \UnexpectedValueException( '[SESSION] Given key for _SESSION is not a valid value for a varaible: ' . $key, 1 ); } return true; } // MARK: init session (on class start) /** * stinitart session with given session name if set * aborts on command line or if sessions are not enabled * also aborts if session cannot be started * On sucess returns the session id * * @param string $session_name * @return void */ private function initSession(string $session_name): void { // we can't start sessions on command line if ($this->checkCliStatus()) { throw new \RuntimeException('[SESSION] No sessions in php cli', 1); } // if session are OFF if ($this->getSessionStatus() === PHP_SESSION_DISABLED) { throw new \RuntimeException('[SESSION] Sessions are disabled', 2); } // session_status // initial the session if there is no session running already if (!$this->checkActiveSession()) { // invalid session name, abort if (!$this->checkValidSessionName($session_name)) { throw new \UnexpectedValueException('[SESSION] Invalid session name: ' . $this->session_name, 3); } // set session name $this->session_name = $session_name; session_name($this->session_name); // start session $this->startSessionCall(); // if we faild to start the session if (!$this->checkActiveSession()) { throw new \RuntimeException('[SESSION] Failed to activate session', 5); } if ( !empty($_SESSION['SESSION_REGENERATE_OBSOLETE']) && !empty($_SESSION['SESSION_REGENERATE_EXPIRES']) && $_SESSION['SESSION_REGENERATE_EXPIRES'] < time() ) { $this->sessionDestroy(); throw new \RuntimeException('[SESSION] Expired session found', 6); } } elseif ($session_name != $this->getSessionName()) { throw new \UnexpectedValueException( '[SESSION] Another session exists with a different name: ' . $this->getSessionName(), 4 ); } // check session id if (false === ($session_id = $this->getSessionIdCall())) { throw new \UnexpectedValueException('[SESSION] getSessionIdCall did not return a session id', 7); } // set session id $this->session_id = $session_id; // run session id re-create from time to time $this->sessionRegenerateSessionId(); // if flagged auto close, write close session if ($this->auto_write_close) { $this->writeClose(); } } // MARK: public set/get status /** * start session, will only run after initSession * * @return bool True if started, False if alrady running */ public function restartSession(): bool { if (!$this->checkActiveSession()) { if (empty($this->session_name)) { throw new \RuntimeException('[SESSION] Cannot restart session without a session name', 1); } $this->startSessionCall(); return true; } return false; } /** * current set session id * * @return string */ public function getSessionId(): string { return $this->session_id; } /** * set the auto write close flag * * @param bool $flag * @return Session */ public function setAutoWriteClose(bool $flag): Session { $this->auto_write_close = $flag; return $this; } /** * return the auto write close flag * * @return bool */ public function checkAutoWriteClose(): bool { return $this->auto_write_close; } /** * get set session name or false if none started * * @return string|bool */ public function getSessionName(): string|bool { return session_name(); } /** * Checks if there is an active session. * Does not check if we can have a session * * @return bool True if there is an active session, else false */ public function checkActiveSession(): bool { if ($this->getSessionStatus() === PHP_SESSION_ACTIVE) { return true; } else { return false; } } /** * check if we are in CLI, we set this, so we can mock this * Not this is just a wrapper for the static System::checkCLI call * * @return bool True if we are in a CLI enviroment, or false for everything else */ public function checkCliStatus(): bool { return \CoreLibs\Get\System::checkCLI(); } /** * get session status * PHP_SESSION_DISABLED if sessions are disabled. * PHP_SESSION_NONE if sessions are enabled, but none exists. * PHP_SESSION_ACTIVE if sessions are enabled, and one exists. * * https://www.php.net/manual/en/function.session-status.php * * @return int See possible return int values above */ public function getSessionStatus(): int { return session_status(); } // MARK: write close session /** * unlock the session file, so concurrent AJAX requests can be done * NOTE: after this has been called, no changes in _SESSION will be stored * NOTE: a new session with a different name can be started after this one is called * if problem, run ob_flush() and flush() too * * @return bool True und sucess, false on failure */ public function writeClose(): bool { return session_write_close(); } // MARK: session close and clean up /** * Proper destroy a session * - unset the _SESSION array * - unset cookie if cookie on and we have not strict mode * - unset session_name and session_id internal vars * - destroy session * * @return bool True on successful session destroy */ public function sessionDestroy(): bool { // abort to false if not unsetable if (!session_unset()) { return false; } $this->clear(); if ( ini_get('session.use_cookies') && !ini_get('session.use_strict_mode') ) { $session_name = $this->getSessionName(); if ($session_name === false) { $session_name = ''; } $params = session_get_cookie_params(); setcookie( (string)$session_name, '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly'] ); } // unset internal vars $this->session_name = ''; $this->session_id = ''; return session_destroy(); } // MARK: _SESSION set/unset methods /** * unset all _SESSION entries * * @return void */ public function clear(): void { $this->restartSession(); if (!session_unset()) { throw new \RuntimeException('[SESSION] Cannot unset session vars', 1); } if (!empty($_SESSION)) { $_SESSION = []; } $this->closeSessionCall(); } /** * set _SESSION entry 'name' with any value * * @param string $name array name in _SESSION * @param mixed $value value to set (can be anything) * @return Session */ public function set(string $name, mixed $value): Session { $this->checkValidSessionEntryKey($name); $this->restartSession(); $_SESSION[$name] = $value; $this->closeSessionCall(); return $this; } /** * set many session entries in one set * * @param array $set key is the key in the _SESSION, value is any data to set * @return void */ public function setMany(array $set): void { $this->restartSession(); // skip any that are not valid foreach ($set as $key => $value) { $this->checkValidSessionEntryKey($key); $_SESSION[$key] = $value; } $this->closeSessionCall(); } /** * get _SESSION 'name' entry or empty string if not set * * @param string $name value key to get from _SESSION * @return mixed value stored in _SESSION, if not found set to null */ public function get(string $name): mixed { return $_SESSION[$name] ?? null; } /** * get multiple session entries * * @param array $set * @return array */ public function getMany(array $set): array { return array_intersect_key($_SESSION, array_flip($set)); } /** * Check if a name is set in the _SESSION array * * @param string $name Name to check for * @return bool True for set, False fornot set */ public function isset(string $name): bool { return isset($_SESSION[$name]); } /** * unset one _SESSION entry 'name' if exists * * @param string $name _SESSION key name to remove * @return Session */ public function unset(string $name): Session { if (!isset($_SESSION[$name])) { return $this; } $this->restartSession(); unset($_SESSION[$name]); $this->closeSessionCall(); return $this; } /** * reset many session entry * * @param array $set list of session keys to reset * @return void */ public function unsetMany(array $set): void { $this->restartSession(); foreach ($set as $key) { if (!isset($_SESSION[$key])) { continue; } unset($_SESSION[$key]); } $this->closeSessionCall(); } } // __END__