├── .github └── workflows │ └── ci.yml ├── LICENSE ├── composer.json ├── phpunit.xml └── src ├── RedisSessionHandler.php └── SavePathParser.php /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: push 2 | 3 | jobs: 4 | test: 5 | name: Run tests 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Checkout 9 | uses: actions/checkout@v1 10 | - name: Bring up stack 11 | run: composer env-up 12 | - name: Execute test suite 13 | run: composer test 14 | - name: Bring down stack 15 | if: ${{ always() }} 16 | run: composer env-down 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017-2018 Marcel Hernandez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "uma/redis-session-handler", 3 | "description": "An alternative Redis session handler", 4 | "type": "library", 5 | "license": "MIT", 6 | "require": { 7 | "php": ">=5.6", 8 | "ext-redis": "*", 9 | "symfony/polyfill-php70": "^1.19" 10 | }, 11 | "autoload": { 12 | "psr-4": { 13 | "UMA\\": "src/" 14 | } 15 | }, 16 | "autoload-dev": { 17 | "psr-4": { 18 | "UMA\\RedisSessions\\Tests\\E2E\\": "tests/e2e", 19 | "UMA\\RedisSessions\\Tests\\Unit\\": "tests/unit" 20 | } 21 | }, 22 | "require-dev": { 23 | "guzzlehttp/guzzle": "^6.5", 24 | "phpunit/phpunit": "^7.0", 25 | "symfony/polyfill-php72": "^1.22" 26 | }, 27 | "scripts": { 28 | "env-up": [ 29 | "@composer install --ignore-platform-reqs", 30 | "docker-compose -f tests/docker-compose.yml pull", 31 | "docker-compose -f tests/docker-compose.yml up -d" 32 | ], 33 | "env-down": "docker-compose -f tests/docker-compose.yml down -v", 34 | "test": [ 35 | "docker-compose -f tests/docker-compose.yml exec -T -u $(id -u):$(id -g) runner sh -c \"TARGET=php71 php vendor/bin/phpunit --testdox\"", 36 | "docker-compose -f tests/docker-compose.yml exec -T -u $(id -u):$(id -g) runner sh -c \"TARGET=php72 php vendor/bin/phpunit --testdox\"", 37 | "docker-compose -f tests/docker-compose.yml exec -T -u $(id -u):$(id -g) runner sh -c \"TARGET=php73 php vendor/bin/phpunit --testdox\"", 38 | "docker-compose -f tests/docker-compose.yml exec -T -u $(id -u):$(id -g) runner sh -c \"TARGET=php74 php vendor/bin/phpunit --testdox\"", 39 | "docker-compose -f tests/docker-compose.yml exec -T -u $(id -u):$(id -g) runner sh -c \"TARGET=php80 php vendor/bin/phpunit --testdox\"", 40 | "docker-compose -f tests/docker-compose.yml exec -T -u $(id -u):$(id -g) runner sh -c \"TARGET=php81 php vendor/bin/phpunit --testdox\"" 41 | ] 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | tests/e2e 13 | tests/unit 14 | 15 | 16 | 17 | 18 | 19 | src 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/RedisSessionHandler.php: -------------------------------------------------------------------------------- 1 | = 70000 && !ini_get('session.use_strict_mode')) { 93 | ini_set('session.use_strict_mode', true); 94 | } 95 | 96 | $this->redis = new \Redis(); 97 | $this->lock_ttl = (int) ini_get('max_execution_time'); 98 | $this->session_ttl = (int) ini_get('session.gc_maxlifetime'); 99 | } 100 | 101 | /** 102 | * {@inheritdoc} 103 | */ 104 | public function open($save_path, $name) 105 | { 106 | $this->cookieName = $name; 107 | 108 | list( 109 | $host, $port, $timeout, $prefix, $auth, $database 110 | ) = SavePathParser::parse($save_path); 111 | 112 | // When $host is a Unix socket path redis->connect() will fail if 113 | // supplied with any other of the optional parameters, even if they 114 | // are the default values. 115 | if (file_exists($host)) { 116 | if (false === $this->redis->connect($host)) { 117 | return false; 118 | } 119 | } else { 120 | if (false === $this->redis->connect($host, $port, $timeout)) { 121 | return false; 122 | } 123 | } 124 | 125 | if (SavePathParser::DEFAULT_AUTH !== $auth) { 126 | $this->redis->auth($auth); 127 | } 128 | 129 | if (SavePathParser::DEFAULT_DATABASE !== $database) { 130 | $this->redis->select($database); 131 | } 132 | 133 | $this->redis->setOption(\Redis::OPT_PREFIX, $prefix); 134 | 135 | return true; 136 | } 137 | 138 | /** 139 | * {@inheritdoc} 140 | */ 141 | public function create_sid() 142 | { 143 | $id = parent::create_sid(); 144 | 145 | $this->new_sessions[$id] = true; 146 | 147 | return $id; 148 | } 149 | 150 | private function regen() 151 | { 152 | session_id($session_id = $this->create_sid()); 153 | $params = session_get_cookie_params(); 154 | setcookie( 155 | $this->cookieName, 156 | $session_id, 157 | $params['lifetime'] ? time() + $params['lifetime'] : 0, 158 | $params['path'], 159 | $params['domain'], 160 | $params['secure'], 161 | $params['httponly'] 162 | ); 163 | return $session_id; 164 | } 165 | 166 | public function validateId($sessionId) 167 | { 168 | return !$this->mustRegenerate($sessionId); 169 | } 170 | 171 | public function updateTimestamp($sessionId, $sessionData) 172 | { 173 | return parent::updateTimestamp($sessionId, $sessionData); 174 | } 175 | 176 | /** 177 | * {@inheritdoc} 178 | */ 179 | public function read($session_id) 180 | { 181 | if (PHP_VERSION_ID < 70000 && $this->mustRegenerate($session_id)) { 182 | $session_id = $this->regen(); 183 | } 184 | 185 | $this->acquireLockOn($session_id); 186 | 187 | if ($this->isNew($session_id)) { 188 | return ''; 189 | } 190 | 191 | return (string)$this->redis->get($session_id); 192 | } 193 | 194 | /** 195 | * {@inheritdoc} 196 | */ 197 | public function write($session_id, $session_data) 198 | { 199 | return true === $this->redis->setex($session_id, $this->session_ttl, $session_data); 200 | } 201 | 202 | /** 203 | * {@inheritdoc} 204 | */ 205 | public function destroy($session_id) 206 | { 207 | $this->redis->del($session_id); 208 | $this->redis->del("{$session_id}_lock"); 209 | 210 | return true; 211 | } 212 | 213 | /** 214 | * {@inheritdoc} 215 | */ 216 | public function close() 217 | { 218 | $this->releaseLocks(); 219 | 220 | $this->redis->close(); 221 | 222 | return true; 223 | } 224 | 225 | /** 226 | * {@inheritdoc} 227 | */ 228 | public function gc($maxlifetime) 229 | { 230 | // Redis does not need garbage collection, the builtin 231 | // expiration mechanism already takes care of stale sessions 232 | 233 | return true; 234 | } 235 | 236 | /** 237 | * @param string $session_id 238 | */ 239 | private function acquireLockOn($session_id) 240 | { 241 | $options = ['nx']; 242 | if (0 < $this->lock_ttl) { 243 | $options = ['nx', 'ex' => $this->lock_ttl]; 244 | } 245 | 246 | $wait = self::MIN_WAIT_TIME; 247 | while (false === $this->redis->set("{$session_id}_lock", '', $options)) { 248 | usleep($wait); 249 | 250 | if (self::MAX_WAIT_TIME > $wait) { 251 | $wait *= 2; 252 | } 253 | } 254 | 255 | $this->open_sessions[] = $session_id; 256 | } 257 | 258 | private function releaseLocks() 259 | { 260 | foreach ($this->open_sessions as $session_id) { 261 | $this->redis->del("{$session_id}_lock"); 262 | } 263 | 264 | $this->open_sessions = []; 265 | } 266 | 267 | /** 268 | * A session ID must be regenerated when it came from the HTTP 269 | * request and can not be found in Redis. 270 | * 271 | * When that happens it either means that old session data expired in Redis 272 | * before the cookie with the session ID in the browser, or a malicious 273 | * client is trying to pull off a session fixation attack. 274 | * 275 | * @param string $session_id 276 | * 277 | * @return bool 278 | */ 279 | private function mustRegenerate($session_id) 280 | { 281 | return false === $this->isNew($session_id) 282 | && false === (bool) $this->redis->exists($session_id); 283 | } 284 | 285 | /** 286 | * @param string $session_id 287 | * 288 | * @return bool 289 | */ 290 | private function isNew($session_id) 291 | { 292 | return isset($this->new_sessions[$session_id]); 293 | } 294 | } 295 | -------------------------------------------------------------------------------- /src/SavePathParser.php: -------------------------------------------------------------------------------- 1 | self::DEFAULT_TIMEOUT, 37 | 'prefix' => self::DEFAULT_PREFIX, 38 | 'auth' => self::DEFAULT_AUTH, 39 | 'database' => self::DEFAULT_DATABASE 40 | ]; 41 | 42 | /** 43 | * @param string $path The session.save_path parameter from php.ini 44 | * 45 | * @return array [host, port, connection timeout, session prefix, auth, database] 46 | * 47 | * @example 'localhost' => ['localhost', 6379, 0.0, 'PHPREDIS_SESSION:', null, 0] 48 | * @example 'tcp://1.2.3.4:5678?auth=secret&database=3' => ['1.2.3.4', 5678, 0.0, 'PHPREDIS_SESSION:', 'secret', 3] 49 | */ 50 | public static function parse($path) 51 | { 52 | // parse_url() is intended to parse URLs (but not URIs) so it cannot parse Unix 53 | // socket paths. However for backwards compatibility reasons it can still parse 54 | // URIs that start with 'file://' 55 | // 56 | // @see https://www.php.net/manual/en/function.parse-url.php 57 | if (0 === strpos($path, 'unix://')) { 58 | $path = str_replace('unix://', 'file://', $path); 59 | } 60 | 61 | $parsed_path = parse_url($path); 62 | 63 | $host = isset($parsed_path['host']) ? 64 | $parsed_path['host'] : $parsed_path['path']; 65 | 66 | $port = isset($parsed_path['port']) ? 67 | $parsed_path['port'] : static::DEFAULT_PORT; 68 | 69 | $opts = []; 70 | 71 | if (isset($parsed_path['query'])) { 72 | parse_str($parsed_path['query'], $opts); 73 | } 74 | 75 | $opts = array_merge(static::DEFAULT_OPTIONS, $opts); 76 | 77 | return [ 78 | $host, $port, (float) $opts['timeout'], $opts['prefix'], $opts['auth'], (int) $opts['database'] 79 | ]; 80 | } 81 | } 82 | --------------------------------------------------------------------------------