diff --git a/modules/redis_sessions/README.md b/modules/redis_sessions/README.md new file mode 100644 index 0000000..256df1a --- /dev/null +++ b/modules/redis_sessions/README.md @@ -0,0 +1,44 @@ +CONTENTS OF THIS FILE +--------------------- + + * Introduction + * Requirements + * Installation + * Configuration + + +INTRODUCTION +------------ +The Redis Sessions module creates an alternative to database storage for user +sessions. It uses native a PHP Redis sessions manager and custom settings to +use Redis for sessions handling and storage. + + +REQUIREMENTS +------------ + +This module requires the following modules: + + * Redis (https://drupal.org/project/redis) + + +INSTALLATION +------------ + + * Install as you would normally install a contributed Drupal module. See: + https://www.drupal.org/docs/8/extending-drupal-8/installing-modules + for further information. + + + +CONFIGURATION +------------- + + * By default, Redis Sessions will attempt to use the redis.connection host. + * OPTIONAL: You can add the save_path to your settings.php file, especially if + you want to use a different Redis service than what is used for cache. + ``` + $settings['redis_sessions'] = [ + 'save_path' => 'tcp://redis:6379', + ]; + ``` diff --git a/modules/redis_sessions/redis_sessions.info.yml b/modules/redis_sessions/redis_sessions.info.yml new file mode 100644 index 0000000..be805ba --- /dev/null +++ b/modules/redis_sessions/redis_sessions.info.yml @@ -0,0 +1,8 @@ +name: "Redis Sessions" +description: "A module to change PHP's session handling to use Redis" +package: "Performance and scalability" +type: module +version: 1.0 +core: 8.x +dependencies: + - redis \ No newline at end of file diff --git a/modules/redis_sessions/redis_sessions.install b/modules/redis_sessions/redis_sessions.install new file mode 100644 index 0000000..862fb8e --- /dev/null +++ b/modules/redis_sessions/redis_sessions.install @@ -0,0 +1,49 @@ + "Redis Sessions", + 'value' => t("Connected, using the @name client.", array('@name' => ClientFactory::getClientName())), + 'severity' => REQUIREMENT_OK, + ); + } + else { + $requirements['redis_sessions_redis'] = array( + 'title' => "Redis Sessions", + 'value' => t("Not connected."), + 'severity' => REQUIREMENT_WARNING, + 'description' => t("No Redis client connected, this module is useless thereof. Ensure that you enabled module using it or disable it."), + ); + } + + $settings = \Drupal\Core\Site\Settings::get('redis_sessions'); + if (empty($settings['save_path'])) { + $requirements['redis_sessions_save_path'] = array( + 'title' => "Redis Sessions", + 'value' => t("Redis Sessions has not been configured with a save_path setting. See the CONFIGURATION section of this module's README.md."), + 'severity' => REQUIREMENT_OK, + ); + } + + return $requirements; +} diff --git a/modules/redis_sessions/redis_sessions.services.yml b/modules/redis_sessions/redis_sessions.services.yml new file mode 100644 index 0000000..4eb30a8 --- /dev/null +++ b/modules/redis_sessions/redis_sessions.services.yml @@ -0,0 +1,8 @@ +services: + + # Decorate the core session_manager service to use our extended class. + redis_sessions.session_manager: + class: Drupal\redis_sessions\RedisSessionsSessionManager + decorates: session_manager + decoration_priority: -10 + arguments: ['@redis_sessions.session_manager.inner', '@request_stack', '@database', '@session_manager.metadata_bag', '@session_configuration', '@session_handler'] diff --git a/modules/redis_sessions/src/RedisSessionsSessionManager.php b/modules/redis_sessions/src/RedisSessionsSessionManager.php new file mode 100644 index 0000000..e08234f --- /dev/null +++ b/modules/redis_sessions/src/RedisSessionsSessionManager.php @@ -0,0 +1,301 @@ +innerService = $session_manager; + parent::__construct($request_stack, $connection, $metadata_bag, $session_configuration, $handler); + + $save_path = $this->getSavePath(); + if (ClientFactory::hasClient()) { + if (!empty($save_path)) { + ini_set('session.save_path', $save_path); + ini_set('session.save_handler', 'redis'); + $this->redis = ClientFactory::getClient(); + } + else { + throw new \Exception("Redis Sessions has not been configured. See 'CONFIGURATION' in README.md in the redis_sessions module for instructions."); + } + } + else { + throw new \Exception("Redis client is not found. Is Redis module enabled and configured?"); + } + } + + /** + * Overloads existing session manager methods. + * + * This will intercept all method calls not defined in this service and + * redirect them to the innerService. + * + * @param string $method + * The name of the called method. + * @param array $args + * An array of all the arguments passed to the called method. + */ + public function __call($method, $args) { + return call_user_func_array(array($this->innerService, $method), $args); + } + + /** + * Return the session.save_path string for PHP native session handling. + * + * Get save_path from site settings, since we can't inject it into the + * service directly. + * + * @return string + * A string of the full URL to the redis service. + */ + private function getSavePath() { + // Use the save_path value from settings.php first. + $settings = \Drupal\Core\Site\Settings::get('redis_sessions'); + if ($settings['save_path']) { + $save_path = $settings['save_path']; + } + else { + // If no save_path from settings.php, use Redis module's settings. + $settings = \Drupal\Core\Site\Settings::get('redis.connection'); + $save_path = "tcp://${settings['host']}:6379"; + } + + return $save_path; + } + + /** + * Return a key prefix to use in redis keys. + * + * @return string + * A string of the redis key prefix, with a trailing colon. + */ + private function getNativeSessionKey($suffix = '') { + // TODO: Get the string from a config option, or use the default string. + return 'PHPREDIS_SESSION:' . $suffix; + } + + /** + * Return the redis key for the current session ID. + * + * @return string + * A string of the redis key for the current session ID. + */ + private function getKey() { + return $this->getNativeSessionKey($this->getId()); + } + + /** + * Return a Drupal-specific key prefix to use in redis keys. + * + * @return string + * A string of the redis key prefix, with a trailing colon. + */ + private function getUidSessionKeyPrefix($suffix = '') { + // TODO: Get Redis module prefix value to add to the $sid Redis key prefix. + // TODO: Get the string from a config option, or use the default string. + return 'DRUPAL_REDIS_SESSION:' . $suffix; + } + + /** + * Return the redis key for the current session ID. + * + * @return string + * A string of the redis key for the current session ID. + */ + private function getUidSessionKey() { + $uid = $this->getSessionBagUid(); + return $this->getUidSessionKeyPrefix(Crypt::hashBase64($uid)); + } + + /** + * Get the User ID from the session metadata bags. + * + * Fetch the User ID from the metadata bags rather than a tradtional user + * lookup in case the UID is in the process of changing (logging in or out). + * + * @return int + * User id as passed to the constructor in a metadata bag. + */ + private function getSessionBagUid() { + foreach ($this->bags as $bag) { + if ($bag->getName() == 'attributes') { + $attributes = $bag->all(); + if (!empty($attributes['uid'])) { + return $attributes['uid']; + } + } + } + return 0; + } + + /** + * {@inheritdoc} + */ + public function isSessionObsolete() { + $bag_uid = $this->getSessionBagUid(); + $current_uid = \Drupal::currentUser()->id(); + + return ($bag_uid == 0 && $current_uid == 0); + } + + /** + * {@inheritdoc} + */ + public function save() { + $uid = $this->getSessionBagUid(); + + if ($this->isCli()) { + // We don't have anything to do if we are not allowed to save the session. + return; + } + + if ($this->isSessionObsolete()) { + // There is no session data to store, destroy the session if it was + // previously started. + if ($this->getSaveHandler()->isActive()) { + $this->destroy(); + } + } + else { + // There is session data to store. Start the session if it is not already + // started. + if (!$this->getSaveHandler()->isActive()) { + $this->startNow(); + } + // Write the session data. + $this->innerService->save(); + } + + $this->startedLazy = FALSE; + + // Write a key:value pair to be able to find the UID by the SID later. + // NOTE: Checking for $uid here ensures that only sessions for logged-in + // users will have lookup keys. Anonymous sessions (if they exist at all) + // are transient and will be cleaned up via garbage collection. + // TODO: Add EX Seconds to the set() method for session life length. + // TODO: After adding EX and PX seconds, add 'NX'. + // See: https://redis.io/commands/set. + if ($uid) { + if (\Drupal::currentUser()->id()) { + $this->redis->set($this->getUidSessionKey(), $this->getKey()); + } + else { + $this->destroyObsolete($this->redis->get($this->getUidSessionKey())); + } + } + } + + /** + * {@inheritdoc} + */ + public function delete($uid) { + // Nothing to do if we are not allowed to change the session. + if ($this->isCli() || $this->innerService->isCli()) { + return; + } + + // Get the session key by $uid. + $sid = $this->redis->get($this->getUidSessionKey()); + + // Delete both key/value pairs associated with the session ID. + $this->redis->del($sid); + $this->redis->del($this->getKey()); + } + + /** + * {@inheritdoc} + */ + public function destroy() { + $uid = $this->getSessionBagUid(); + $this->redis->set("SESS_DESTROY:$uid:" . \Drupal::currentUser()->id()); + + if ($uid) { + if (\Drupal::currentUser()->id() == 0) { + $sid = $this->redis->get($this->getUidSessionKey()); + + $this->redis->del($sid); + $this->redis->del($this->getUidSessionKey()); + $this->redis->del($this->getKey()); + } + } + + $this->innerService->destroy(); + } + + /** + * Removes obsolete sessions. + * + * @param string $old_session_id + * The old session ID. + */ + public function destroyObsolete($old_session_id) { + $this->redis->del($old_session_id); + $this->redis->del($this->getUidSessionKey()); + } + + /** + * Migrates the current session to a new session id. + * + * @param string $old_session_id + * The old session ID. The new session ID is $this->getId(). + * + * @see https://api.drupal.org/api/drupal/core%21lib%21Drupal%21Core%21Session%21SessionManager.php/function/SessionManager%3A%3AmigrateStoredSession/8.2.x + */ + protected function migrateStoredSession($old_session_id) { + // The original session has been copied to a new session with a new key; + // remove the original session ID key. + // Test: redis-cli KEYS "*SESS*" | xargs redis-cli DEL && redis-cli. + $this->redis->del($this->getNativeSessionKey($old_session_id)); + } + +}