├── .github └── workflows │ └── ruby.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .rubocop_todo.yml ├── .simplecov ├── AUTHORS.md ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── lib └── redis-session-store.rb ├── redis-session-store.gemspec └── spec ├── redis_session_store_spec.rb ├── spec_helper.rb └── support.rb /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: Ruby 9 | 10 | on: 11 | push: 12 | branches: [ master ] 13 | pull_request: 14 | branches: [ master ] 15 | 16 | jobs: 17 | test: 18 | 19 | runs-on: ubuntu-latest 20 | strategy: 21 | matrix: 22 | ruby: 23 | - 2.5 24 | - 2.6 25 | - 2.7 26 | - '3.0' 27 | - 3.1 28 | - head 29 | - jruby-9.2.20.1 30 | - jruby-head 31 | 32 | steps: 33 | - uses: actions/checkout@v3 34 | - name: Set up Ruby 35 | uses: ruby/setup-ruby@v1 36 | with: 37 | ruby-version: ${{ matrix.ruby }} 38 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 39 | continue-on-error: true 40 | - name: Run tests 41 | run: bundle exec rake 42 | continue-on-error: true 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | --format documentation 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | require: 4 | - rubocop-rake 5 | - rubocop-rspec 6 | 7 | AllCops: 8 | DisplayCopNames: true 9 | Exclude: 10 | - 'Rakefile' 11 | - 'vendor/**/*' 12 | 13 | Naming/FileName: 14 | Enabled: false 15 | 16 | Style/DoubleNegation: 17 | Enabled: false 18 | 19 | Metrics/BlockLength: 20 | Exclude: 21 | - 'spec/**/*.rb' 22 | 23 | Layout/LineLength: 24 | Max: 100 25 | 26 | Metrics/ClassLength: 27 | Max: 140 28 | 29 | RSpec/MultipleExpectations: 30 | Max: 3 31 | 32 | Security/MarshalLoad: 33 | Enabled: false 34 | 35 | Style/FrozenStringLiteralComment: 36 | Enabled: false 37 | 38 | Style/PercentLiteralDelimiters: 39 | Enabled: false 40 | 41 | Style/SafeNavigation: 42 | Enabled: false 43 | 44 | Style/YodaCondition: 45 | # temporary work around for rubocop bug 'An error occurred while Style/YodaCondition...' (v0.49.1) 46 | Exclude: 47 | - 'spec/redis_session_store_spec.rb' 48 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2022-01-29 11:55:33 UTC using RuboCop version 1.25.0. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 1 10 | # Configuration parameters: Include. 11 | # Include: **/*.gemspec 12 | Gemspec/RequiredRubyVersion: 13 | Exclude: 14 | - 'redis-session-store.gemspec' 15 | 16 | # Offense count: 6 17 | # Configuration parameters: Prefixes. 18 | # Prefixes: when, with, without 19 | RSpec/ContextWording: 20 | Exclude: 21 | - 'spec/redis_session_store_spec.rb' 22 | 23 | # Offense count: 2 24 | # Configuration parameters: CountAsOne. 25 | RSpec/ExampleLength: 26 | Max: 9 27 | 28 | # Offense count: 5 29 | # Configuration parameters: AssignmentOnly. 30 | RSpec/InstanceVariable: 31 | Exclude: 32 | - 'spec/redis_session_store_spec.rb' 33 | 34 | # Offense count: 8 35 | # Configuration parameters: . 36 | # SupportedStyles: have_received, receive 37 | RSpec/MessageSpies: 38 | EnforcedStyle: receive 39 | 40 | # Offense count: 5 41 | RSpec/MultipleExpectations: 42 | Max: 2 43 | 44 | # Offense count: 17 45 | # Configuration parameters: AllowSubject. 46 | RSpec/MultipleMemoizedHelpers: 47 | Max: 10 48 | 49 | # Offense count: 13 50 | RSpec/NestedGroups: 51 | Max: 5 52 | 53 | # Offense count: 2 54 | RSpec/StubbedMock: 55 | Exclude: 56 | - 'spec/redis_session_store_spec.rb' 57 | 58 | # Offense count: 20 59 | RSpec/SubjectStub: 60 | Exclude: 61 | - 'spec/redis_session_store_spec.rb' 62 | 63 | # Offense count: 16 64 | # Configuration parameters: IgnoreNameless, IgnoreSymbolicNames. 65 | RSpec/VerifiedDoubles: 66 | Exclude: 67 | - 'spec/redis_session_store_spec.rb' 68 | -------------------------------------------------------------------------------- /.simplecov: -------------------------------------------------------------------------------- 1 | if ENV['COVERAGE'] 2 | SimpleCov.start do 3 | add_filter '/spec/' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | Redis Session Store authors 2 | =========================== 3 | 4 | - Ben Marini 5 | - Bill Ruddock 6 | - Dan Buch 7 | - Donald Plummer 8 | - Edwin Cruz 9 | - Gonçalo Silva 10 | - Ian C. Anderson 11 | - Jesse Doyle 12 | - Jorge Pardiñas 13 | - Justin McNally 14 | - Mathias Meyer 15 | - Michael Fields 16 | - Michael Xavier 17 | - Olek Poplavsky 18 | - Tim Lossen 19 | - Todd Bealmear 20 | - Aleksey Dashkevych 21 | - Olle Jonsson 22 | - Nicolas Rodriguez 23 | - Sergey Nebolsin 24 | - Anton Kolodii 25 | - Peter Karman 26 | - Zach Margolis 27 | - Zachary Belzer 28 | - Yutaka Kamei 29 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | **ATTN**: This project uses [semantic versioning](http://semver.org/). 4 | 5 | ## [Unreleased] 6 | 7 | # [0.11.6] - 2024-11-12 8 | - Fix secure session using private_id 9 | - Support Rails 8 10 | 11 | # [0.11.5] - 2022-11-27 12 | 13 | ### Changed 14 | 15 | - Support redis 5 16 | - Actionpack more than or equal to 6 17 | 18 | ## [0.11.4] - 2022-01-29 19 | ### Fixed 20 | - Use AbstractSecureStore for security fix 21 | 22 | ### Changed 23 | - Support actionpack 7.x 24 | - Move from TravisCI to Github Actions 25 | - Drop support for ruby 2.3, 2.4 26 | 27 | ## [0.11.3] - 2020-07-23 28 | ### Fixed 29 | - https://github.com/roidrage/redis-session-store/issues/121 30 | 31 | ## [0.11.2] - 2020-07-22 32 | ### Changed 33 | - Silence deprecation warning when using with redis gem v4.2+ 34 | 35 | ## [0.11.1] - 2019-08-22 36 | ### Changed 37 | - Remove the `has_rdoc` parameter from the `.gemspec` file as it has been deprecated. 38 | - Actionpack to '>= 3', remove upper dependency 39 | 40 | ## [0.11.0] - 2018-08-13 41 | ### Changed 42 | - JRuby to jruby-9.2.0.0 43 | - Travis Ruby support: 2.3.7, 2.4.4, 2.5.1 44 | 45 | ### Added 46 | - :ttl configuration option 47 | 48 | ## [0.10.0] - 2018-04-14 49 | ### Changed 50 | - JRuby to jruby-9.1.15.0 51 | - Redis to '>= 3', '< 5' 52 | - Actionpack to '>= 3', '< 6' 53 | - Rake to 12 54 | 55 | ### Added 56 | - with_indifferent_access if defined ActiveSupport 57 | 58 | ## [0.9.2] - 2017-10-31 59 | ### Changed 60 | - Actionpack to 5.1 61 | - Travis use jruby 9.1.13.0 62 | 63 | ## [0.9.1] - 2016-07-03 64 | ### Added 65 | - More specific runtime dependencies 66 | 67 | ### Changed 68 | - Documentation and whitespace 69 | 70 | ## [0.9.0] - 2016-07-02 71 | ### Added 72 | - [`CODE_OF_CONDUCT.md`](./CODE_OF_CONDUCT.md) 73 | - Method alias for `#delete_session` -> `#destroy_session` 74 | 75 | ### Changed 76 | - Tested version of Ruby 2 up to 2.3.1 77 | - Session config examples to use `redis: { url: '...' }` 78 | 79 | ### Removed 80 | - Ruby 1.9.3 support due to Rack 2 requirements 81 | 82 | ## [0.8.1] - 2016-01-25 83 | ### Added 84 | - Support for Rails 5 and Rack 2 85 | 86 | ### Changed 87 | - Error support for redis-rb v3 gem 88 | 89 | ## [0.8.0] - 2014-08-28 90 | ### Added 91 | - Allow for injection of custom redis client 92 | - Explicitly declare actionpack dependency 93 | 94 | ### Changed 95 | - Spec updates for rspec 3 96 | 97 | ## [0.7.0] - 2014-04-22 98 | ### Fixed 99 | - Issue #38, we now delay writing to redis until a session exists. This is a 100 | backwards-incompatible change, as it removes the `on_sid_collision` option. 101 | There is now no checking for sid collisions, however that is very unlikely. 102 | 103 | ## [0.6.6] - 2014-04-08 104 | ### Fixed 105 | - Issue #37, use correct constant for `ENV_SESSION_OPTIONS_KEY` if not passed. 106 | 107 | ## [0.6.5] - 2014-04-04 108 | ### Fixed 109 | - Issue #36, use setnx to get a new session id instead of get. This prevents a 110 | very rare id collision. 111 | 112 | ## [0.6.4] - 2014-04-04 113 | ### Removed 114 | - `#setnx` usage in v0.6.3 so we can change our sessions 115 | 116 | ## [0.6.3] - 2014-04-01 117 | ### Changed 118 | - Setting session ID with a multi-call `#setnx` and `#expire` instead of 119 | `#setex`. 120 | 121 | ### Removed 122 | - `#setnx` change in v0.6.2 as it behaved badly under load, hitting yet another 123 | race condition issue and pegging the CPU. 124 | 125 | ## [0.6.2] - 2014-03-31 126 | ### Changed 127 | - Use `#setnx` instead of `#get` when checking for session ID collisions, which 128 | is slightly more paranoid and should help avoid a particularly nasty edge 129 | case. 130 | 131 | ## [0.6.1] - 2014-03-17 132 | ### Fixed 133 | - Compatibility with `ActionDispatch::Request::Session::Options` when destroying 134 | sessions. 135 | 136 | ## [0.6.0] - 2014-03-17 137 | ### Added 138 | - Custom serializer configuration 139 | - Custom handling capability for session load errors 140 | 141 | ### Changed 142 | - Always destroying sessions that cannot be loaded 143 | 144 | ## [0.5.0] - 2014-03-16 145 | ### Added 146 | - Support for `on_sid_collision` handler option 147 | - Support for `on_redis_down` handler option 148 | 149 | ### Changed 150 | - Keep generating session IDs until one is found that doesn't collide 151 | with existing session IDs 152 | 153 | ### Removed 154 | - **BACKWARD INCOMPATIBLE** Drop support for `:raise_errors` option 155 | 156 | ## [0.4.2] - 2014-03-14 157 | ### Changed 158 | - Renaming `load_session` method to not conflict with AbstractStore 159 | 160 | ## [0.4.1] - (2014-03-13) [YANKED] 161 | ### Changed 162 | - Regenerate session ID when session is missing 163 | 164 | ## [0.4.0] - 2014-02-19 165 | ### Added 166 | - Support for `ENV_SESSION_OPTIONS_KEY` rack env option 167 | - Support for `:raise_errors` session option (kinda like Dalli) 168 | 169 | ### Changed 170 | - Increasing test coverage 171 | 172 | ## [0.3.1] - 2014-02-19 173 | ### Added 174 | - `#destroy_session` method 175 | 176 | ### Changed 177 | - Clean up remaining RuboCop offenses 178 | - Documentation updates 179 | 180 | ## [0.3.0] - 2014-02-13 181 | ### Added 182 | - Rails 3 compatibility 183 | - Add test coverage 184 | 185 | ### Changed 186 | - Switch from minitest to rspec 187 | - RuboCop cleanup 188 | 189 | ## [0.2.4] - 2014-03-16 190 | ### Changed 191 | - Keep generating session IDs until one is found that doesn't collide 192 | with existing session IDs 193 | 194 | ## [0.2.3] - 2014-03-14 195 | ### Changed 196 | - Renaming `load_session` method to not conflict with AbstractStore 197 | 198 | ## [0.2.2] - 2014-03-13 [YANKED] 199 | ### Changed 200 | - Regenerate session ID when session is missing 201 | 202 | ## [0.2.1] - 2013-09-17 203 | ### Added 204 | - Explicit MIT license metadata in gemspec 205 | 206 | ## [0.2.0] - 2013-09-13 207 | ### Added 208 | - Gemfile, gemspec, and git updates 209 | - `#destroy` method 210 | - Travis integration 211 | - Some minimal tests to ensure backward compatibility session options 212 | 213 | ### Changed 214 | - Nest redis-specific options inside a `:redis` key of session options 215 | - Rescue only `Errno::ECONNREFUSED` exceptions 216 | - Handle `nil` cookies during `#destroy` 217 | 218 | ## [0.1.9] - 2012-03-06 219 | ### Changed 220 | - Use `@redis.setex` when expiry provided, else `@redis.set` 221 | - gemification 222 | - Options hash to no longer expect redis options at same level 223 | 224 | ## [0.1.8] - 2010-12-09 225 | ### Removed 226 | - Use of `@redis.pipelined` 227 | 228 | ## 0.1.7 - 2010-12-08 229 | ### Changed 230 | - Using latest redis gem API 231 | 232 | ## 0.1.6 - 2010-04-18 233 | ### Changed 234 | - Using pipelined format with `set` and `expire` 235 | - Changing default port from 6370 to 6379 236 | 237 | ## 0.1.5 - 2010-04-07 238 | 239 | ## 0.1.4 - 2010-03-26 240 | ### Changed 241 | - Redis parameter from `:server` to `:host` 242 | 243 | ## 0.1.3 - 2009-12-30 244 | ### Changed 245 | - Documentation updates 246 | 247 | ## 0.1.2 - 2009-12-30 248 | ### Changed 249 | - Documentation updates 250 | 251 | ## 0.1.1 - 2009-12-30 252 | ### Changed 253 | - library file renamed to `redis-session-store.rb` to play nicely with 254 | rails require 255 | 256 | ## 0.1 - 2009-12-30 257 | ### Added 258 | - first working version 259 | 260 | [Unreleased]: https://github.com/roidrage/redis-session-store/compare/v0.11.1...HEAD 261 | [0.11.1]: https://github.com/roidrage/redis-session-store/compare/v0.11.0...v0.11.1 262 | [0.11.0]: https://github.com/roidrage/redis-session-store/compare/v0.10.0...v0.11.0 263 | [0.10.0]: https://github.com/roidrage/redis-session-store/compare/v0.9.2...v0.10.0 264 | [0.9.2]: https://github.com/roidrage/redis-session-store/compare/v0.9.1...v0.9.2 265 | [0.9.1]: https://github.com/roidrage/redis-session-store/compare/v0.9.0...v0.9.1 266 | [0.9.0]: https://github.com/roidrage/redis-session-store/compare/v0.8.1...v0.9.0 267 | [0.8.1]: https://github.com/roidrage/redis-session-store/compare/v0.8.0...v0.8.1 268 | [0.8.0]: https://github.com/roidrage/redis-session-store/compare/v0.7.0...v0.8.0 269 | [0.7.0]: https://github.com/roidrage/redis-session-store/compare/v0.6.6...v0.7.0 270 | [0.6.6]: https://github.com/roidrage/redis-session-store/compare/v0.6.5...v0.6.6 271 | [0.6.5]: https://github.com/roidrage/redis-session-store/compare/v0.6.4...v0.6.5 272 | [0.6.4]: https://github.com/roidrage/redis-session-store/compare/v0.6.3...v0.6.4 273 | [0.6.3]: https://github.com/roidrage/redis-session-store/compare/v0.6.2...v0.6.3 274 | [0.6.2]: https://github.com/roidrage/redis-session-store/compare/v0.6.1...v0.6.2 275 | [0.6.1]: https://github.com/roidrage/redis-session-store/compare/v0.6.0...v0.6.1 276 | [0.6.0]: https://github.com/roidrage/redis-session-store/compare/v0.5.0...v0.6.0 277 | [0.5.0]: https://github.com/roidrage/redis-session-store/compare/v0.4.2...v0.5.0 278 | [0.4.2]: https://github.com/roidrage/redis-session-store/compare/v0.4.1...v0.4.2 279 | [0.4.1]: https://github.com/roidrage/redis-session-store/compare/v0.4.0...v0.4.1 280 | [0.4.0]: https://github.com/roidrage/redis-session-store/compare/v0.3.1...v0.4.0 281 | [0.3.1]: https://github.com/roidrage/redis-session-store/compare/v0.3.0...v0.3.1 282 | [0.3.0]: https://github.com/roidrage/redis-session-store/compare/v0.2.4...v0.3.0 283 | [0.2.4]: https://github.com/roidrage/redis-session-store/compare/v0.2.3...v0.2.4 284 | [0.2.3]: https://github.com/roidrage/redis-session-store/compare/v0.2.2...v0.2.3 285 | [0.2.2]: https://github.com/roidrage/redis-session-store/compare/v0.2.1...v0.2.2 286 | [0.2.1]: https://github.com/roidrage/redis-session-store/compare/v0.2.0...v0.2.1 287 | [0.2.0]: https://github.com/roidrage/redis-session-store/compare/v0.1.9...v0.2.0 288 | [0.1.9]: https://github.com/roidrage/redis-session-store/compare/v0.1.8...v0.1.9 289 | [0.1.8]: https://github.com/roidrage/redis-session-store/compare/v0.1.7...v0.1.8 290 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team via [github 59 | issues](https://github.com/roidrage/redis-session-store/issues). All 60 | complaints will be reviewed and investigated and will result in a response that 61 | is deemed necessary and appropriate to the circumstances. The project team is 62 | obligated to maintain confidentiality with regard to the reporter of an incident. 63 | Further details of specific enforcement policies may be posted separately. 64 | 65 | Project maintainers who do not follow or enforce the Code of Conduct in good 66 | faith may face temporary or permanent repercussions as determined by other 67 | members of the project's leadership. 68 | 69 | ## Attribution 70 | 71 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 72 | available at [http://contributor-covenant.org/version/1/4][version] 73 | 74 | [homepage]: http://contributor-covenant.org 75 | [version]: http://contributor-covenant.org/version/1/4/ 76 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Redis Session Store contribution guidelines 2 | =========================================== 3 | 4 | - Pull requests welcome! 5 | - Please add yourself to the [AUTHORS.md](AUTHORS.md) file 6 | alphabetically by first name. 7 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Mathias Meyer and contributors 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redis Session Store 2 | 3 | [![Code Climate](https://codeclimate.com/github/roidrage/redis-session-store.svg)](https://codeclimate.com/github/roidrage/redis-session-store) 4 | [![Gem Version](https://badge.fury.io/rb/redis-session-store.svg)](http://badge.fury.io/rb/redis-session-store) 5 | 6 | A simple Redis-based session store for Rails. But why, you ask, 7 | when there's [redis-store](http://github.com/jodosha/redis-store/)? 8 | redis-store is a one-size-fits-all solution, and I found it not to work 9 | properly with Rails, mostly due to a problem that seemed to lie in 10 | Rack's `Abstract::ID` class. I wanted something that worked, so I 11 | blatantly stole the code from Rails' `MemCacheStore` and turned it 12 | into a Redis version. No support for fancy stuff like distributed 13 | storage across several Redis instances. Feel free to add what you 14 | see fit. 15 | 16 | This library doesn't offer anything related to caching, and is 17 | only suitable for Rails applications. For other frameworks or 18 | drop-in support for caching, check out 19 | [redis-store](http://github.com/jodosha/redis-store/). 20 | 21 | ## Installation 22 | 23 | For Rails 3+, adding this to your `Gemfile` will do the trick. 24 | 25 | ``` ruby 26 | gem 'redis-session-store' 27 | ``` 28 | 29 | ## Configuration 30 | 31 | See `lib/redis-session-store.rb` for a list of valid options. 32 | In your Rails app, throw in an initializer with the following contents: 33 | 34 | ``` ruby 35 | Rails.application.config.session_store :redis_session_store, 36 | key: 'your_session_key', 37 | redis: { 38 | expire_after: 120.minutes, # cookie expiration 39 | ttl: 120.minutes, # Redis expiration, defaults to 'expire_after' 40 | key_prefix: 'myapp:session:', 41 | url: 'redis://localhost:6379/0', 42 | } 43 | ``` 44 | 45 | ### Redis unavailability handling 46 | 47 | If you want to handle cases where Redis is unavailable, a custom 48 | callable handler may be provided as `on_redis_down`: 49 | 50 | ``` ruby 51 | Rails.application.config.session_store :redis_session_store, 52 | # ... other options ... 53 | on_redis_down: ->(e, env, sid) { do_something_will_ya!(e) } 54 | redis: { 55 | # ... redis options ... 56 | } 57 | ``` 58 | 59 | ### Serializer 60 | 61 | By default the Marshal serializer is used. With Rails 4, you can use JSON as a 62 | custom serializer: 63 | 64 | * `:json` - serialize cookie values with `JSON` (Requires Rails 4+) 65 | * `:marshal` - serialize cookie values with `Marshal` (Default) 66 | * `:hybrid` - transparently migrate existing `Marshal` cookie values to `JSON` (Requires Rails 4+) 67 | * `CustomClass` - You can just pass the constant name of any class that responds to `.load` and `.dump` 68 | 69 | ``` ruby 70 | Rails.application.config.session_store :redis_session_store, 71 | # ... other options ... 72 | serializer: :hybrid 73 | redis: { 74 | # ... redis options ... 75 | } 76 | ``` 77 | 78 | **Note**: Rails 4 is required for using the `:json` and `:hybrid` serializers 79 | because the `Flash` object doesn't serialize well in 3.2. See [Rails #13945](https://github.com/rails/rails/pull/13945) for more info. 80 | 81 | ### Session load error handling 82 | 83 | If you want to handle cases where the session data cannot be loaded, a 84 | custom callable handler may be provided as `on_session_load_error` which 85 | will be given the error and the session ID. 86 | 87 | ``` ruby 88 | Rails.application.config.session_store :redis_session_store, 89 | # ... other options ... 90 | on_session_load_error: ->(e, sid) { do_something_will_ya!(e) } 91 | redis: { 92 | # ... redis options ... 93 | } 94 | ``` 95 | 96 | **Note** The session will *always* be destroyed when it cannot be loaded. 97 | 98 | ### Other notes 99 | 100 | It returns with_indifferent_access if ActiveSupport is defined. 101 | 102 | ## Rails 2 Compatibility 103 | 104 | This gem is currently only compatible with Rails 3+. If you need 105 | Rails 2 compatibility, be sure to pin to a lower version like so: 106 | 107 | ``` ruby 108 | gem 'redis-session-store', '< 0.3' 109 | ``` 110 | 111 | ## Contributing, Authors, & License 112 | 113 | See [CONTRIBUTING.md](CONTRIBUTING.md), [AUTHORS.md](AUTHORS.md), and 114 | [LICENSE](LICENSE), respectively. 115 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rspec/core/rake_task' 3 | require 'rubocop/rake_task' 4 | 5 | task default: [:rubocop, :coverage] 6 | 7 | RSpec::Core::RakeTask.new 8 | 9 | desc 'Run specs with coverage' 10 | task :coverage do 11 | ENV['COVERAGE'] = '1' 12 | Rake::Task['spec'].invoke 13 | end 14 | 15 | RuboCop::RakeTask.new 16 | -------------------------------------------------------------------------------- /lib/redis-session-store.rb: -------------------------------------------------------------------------------- 1 | require 'redis' 2 | 3 | # Redis session storage for Rails, and for Rails only. Derived from 4 | # the MemCacheStore code, simply dropping in Redis instead. 5 | class RedisSessionStore < ActionDispatch::Session::AbstractSecureStore 6 | VERSION = '0.11.6'.freeze 7 | # Rails 3.1 and beyond defines the constant elsewhere 8 | unless defined?(ENV_SESSION_OPTIONS_KEY) 9 | ENV_SESSION_OPTIONS_KEY = if Rack.release.split('.').first.to_i > 1 10 | Rack::RACK_SESSION_OPTIONS 11 | else 12 | Rack::Session::Abstract::ENV_SESSION_OPTIONS_KEY 13 | end 14 | end 15 | 16 | USE_INDIFFERENT_ACCESS = defined?(ActiveSupport).freeze 17 | # ==== Options 18 | # * +:key+ - Same as with the other cookie stores, key name 19 | # * +:redis+ - A hash with redis-specific options 20 | # * +:url+ - Redis url, default is redis://localhost:6379/0 21 | # * +:key_prefix+ - Prefix for keys used in Redis, e.g. +myapp:+ 22 | # * +:expire_after+ - A number in seconds for session timeout 23 | # * +:client+ - Connect to Redis with given object rather than create one 24 | # * +:on_redis_down:+ - Called with err, env, and SID on Errno::ECONNREFUSED 25 | # * +:on_session_load_error:+ - Called with err and SID on Marshal.load fail 26 | # * +:serializer:+ - Serializer to use on session data, default is :marshal. 27 | # 28 | # ==== Examples 29 | # 30 | # Rails.application.config.session_store :redis_session_store, 31 | # key: 'your_session_key', 32 | # redis: { 33 | # expire_after: 120.minutes, 34 | # key_prefix: 'myapp:session:', 35 | # url: 'redis://localhost:6379/0' 36 | # }, 37 | # on_redis_down: ->(*a) { logger.error("Redis down! #{a.inspect}") }, 38 | # serializer: :hybrid # migrate from Marshal to JSON 39 | # 40 | def initialize(app, options = {}) 41 | super 42 | 43 | @default_options[:namespace] = 'rack:session' 44 | @default_options.merge!(options[:redis] || {}) 45 | init_options = options[:redis]&.reject { |k, _v| %i[expire_after key_prefix].include?(k) } || {} 46 | @redis = init_options[:client] || Redis.new(init_options) 47 | @on_redis_down = options[:on_redis_down] 48 | @serializer = determine_serializer(options[:serializer]) 49 | @on_session_load_error = options[:on_session_load_error] 50 | verify_handlers! 51 | end 52 | 53 | attr_accessor :on_redis_down, :on_session_load_error 54 | 55 | private 56 | 57 | attr_reader :redis, :key, :default_options, :serializer 58 | 59 | # overrides method defined in rack to actually verify session existence 60 | # Prevents needless new sessions from being created in scenario where 61 | # user HAS session id, but it already expired, or is invalid for some 62 | # other reason, and session was accessed only for reading. 63 | def session_exists?(env) 64 | value = current_session_id(env) 65 | 66 | !!( 67 | value && !value.empty? && 68 | key_exists_with_fallback?(value) 69 | ) 70 | rescue Errno::ECONNREFUSED, Redis::CannotConnectError => e 71 | on_redis_down.call(e, env, value) if on_redis_down 72 | 73 | true 74 | end 75 | 76 | def key_exists_with_fallback?(value) 77 | return false if private_session_id?(value.public_id) 78 | 79 | key_exists?(value.private_id) || key_exists?(value.public_id) 80 | end 81 | 82 | def key_exists?(value) 83 | if redis.respond_to?(:exists?) 84 | # added in redis gem v4.2 85 | redis.exists?(prefixed(value)) 86 | else 87 | # older method, will return an integer starting in redis gem v4.3 88 | redis.exists(prefixed(value)) 89 | end 90 | end 91 | 92 | def private_session_id?(value) 93 | value.match?(/\A\d+::/) 94 | end 95 | 96 | def verify_handlers! 97 | %w(on_redis_down on_session_load_error).each do |h| 98 | next unless (handler = public_send(h)) && !handler.respond_to?(:call) 99 | 100 | raise ArgumentError, "#{h} handler is not callable" 101 | end 102 | end 103 | 104 | def prefixed(sid) 105 | "#{default_options[:key_prefix]}#{sid}" 106 | end 107 | 108 | def session_default_values 109 | [generate_sid, USE_INDIFFERENT_ACCESS ? {}.with_indifferent_access : {}] 110 | end 111 | 112 | def get_session(env, sid) 113 | sid && (session = load_session_with_fallback(sid)) ? [sid, session] : session_default_values 114 | rescue Errno::ECONNREFUSED, Redis::CannotConnectError => e 115 | on_redis_down.call(e, env, sid) if on_redis_down 116 | session_default_values 117 | end 118 | alias find_session get_session 119 | 120 | def load_session_with_fallback(sid) 121 | return nil if private_session_id?(sid.public_id) 122 | 123 | load_session_from_redis( 124 | key_exists?(sid.private_id) ? sid.private_id : sid.public_id 125 | ) 126 | end 127 | 128 | def load_session_from_redis(sid) 129 | data = redis.get(prefixed(sid)) 130 | begin 131 | data ? decode(data) : nil 132 | rescue StandardError => e 133 | destroy_session_from_sid(sid, drop: true) 134 | on_session_load_error.call(e, sid) if on_session_load_error 135 | nil 136 | end 137 | end 138 | 139 | def decode(data) 140 | session = serializer.load(data) 141 | USE_INDIFFERENT_ACCESS ? session.with_indifferent_access : session 142 | end 143 | 144 | def set_session(env, sid, session_data, options = nil) 145 | expiry = get_expiry(env, options) 146 | if expiry 147 | redis.setex(prefixed(sid.private_id), expiry, encode(session_data)) 148 | else 149 | redis.set(prefixed(sid.private_id), encode(session_data)) 150 | end 151 | sid 152 | rescue Errno::ECONNREFUSED, Redis::CannotConnectError => e 153 | on_redis_down.call(e, env, sid) if on_redis_down 154 | false 155 | end 156 | alias write_session set_session 157 | 158 | def get_expiry(env, options) 159 | session_storage_options = options || env.fetch(ENV_SESSION_OPTIONS_KEY, {}) 160 | session_storage_options[:ttl] || session_storage_options[:expire_after] 161 | end 162 | 163 | def encode(session_data) 164 | serializer.dump(session_data) 165 | end 166 | 167 | def destroy_session(env, sid, options) 168 | destroy_session_from_sid(sid.public_id, (options || {}).to_hash.merge(env: env, drop: true)) 169 | destroy_session_from_sid(sid.private_id, (options || {}).to_hash.merge(env: env)) 170 | end 171 | alias delete_session destroy_session 172 | 173 | def destroy(env) 174 | if env['rack.request.cookie_hash'] && 175 | (sid = env['rack.request.cookie_hash'][key]) 176 | sid = Rack::Session::SessionId.new(sid) 177 | destroy_session_from_sid(sid.private_id, drop: true, env: env) 178 | destroy_session_from_sid(sid.public_id, drop: true, env: env) 179 | end 180 | false 181 | end 182 | 183 | def destroy_session_from_sid(sid, options = {}) 184 | redis.del(prefixed(sid)) 185 | (options || {})[:drop] ? nil : generate_sid 186 | rescue Errno::ECONNREFUSED, Redis::CannotConnectError => e 187 | on_redis_down.call(e, options[:env] || {}, sid) if on_redis_down 188 | end 189 | 190 | def determine_serializer(serializer) 191 | serializer ||= :marshal 192 | case serializer 193 | when :marshal then Marshal 194 | when :json then JsonSerializer 195 | when :hybrid then HybridSerializer 196 | else serializer 197 | end 198 | end 199 | 200 | # Uses built-in JSON library to encode/decode session 201 | class JsonSerializer 202 | def self.load(value) 203 | JSON.parse(value, quirks_mode: true) 204 | end 205 | 206 | def self.dump(value) 207 | JSON.generate(value, quirks_mode: true) 208 | end 209 | end 210 | 211 | # Transparently migrates existing session values from Marshal to JSON 212 | class HybridSerializer < JsonSerializer 213 | MARSHAL_SIGNATURE = "\x04\x08".freeze 214 | 215 | def self.load(value) 216 | if needs_migration?(value) 217 | Marshal.load(value) 218 | else 219 | super 220 | end 221 | end 222 | 223 | def self.needs_migration?(value) 224 | value.start_with?(MARSHAL_SIGNATURE) 225 | end 226 | end 227 | end 228 | -------------------------------------------------------------------------------- /redis-session-store.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |gem| 2 | gem.name = 'redis-session-store' 3 | gem.authors = ['Mathias Meyer'] 4 | gem.email = ['meyer@paperplanes.de'] 5 | gem.summary = 'A drop-in replacement for e.g. MemCacheStore to ' \ 6 | 'store Rails sessions (and Rails sessions only) in Redis.' 7 | gem.description = "#{gem.summary} For great glory!" 8 | gem.homepage = 'https://github.com/roidrage/redis-session-store' 9 | gem.license = 'MIT' 10 | 11 | gem.extra_rdoc_files = %w(LICENSE AUTHORS.md CONTRIBUTING.md) 12 | 13 | gem.files = `git ls-files -z`.split("\x0") 14 | gem.require_paths = %w(lib) 15 | gem.version = File.read('lib/redis-session-store.rb') 16 | .match(/^ VERSION = '(.*)'/)[1] 17 | 18 | gem.add_runtime_dependency 'actionpack', '>= 5.2.4.1', '< 9' 19 | gem.add_runtime_dependency 'redis', '>= 3', '< 6' 20 | 21 | gem.add_development_dependency 'fakeredis', '~> 0.8' 22 | gem.add_development_dependency 'rake', '~> 13' 23 | gem.add_development_dependency 'rspec', '~> 3' 24 | gem.add_development_dependency 'rubocop', '~> 1.25' 25 | gem.add_development_dependency 'rubocop-rake', '~> 0.6' 26 | gem.add_development_dependency 'rubocop-rspec', '~> 2.8' 27 | gem.add_development_dependency 'simplecov', '~> 0.21' 28 | end 29 | -------------------------------------------------------------------------------- /spec/redis_session_store_spec.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | 3 | describe RedisSessionStore do 4 | subject(:store) { described_class.new(nil, options) } 5 | 6 | let :random_string do 7 | "#{rand}#{rand}#{rand}" 8 | end 9 | let :default_options do 10 | store.instance_variable_get(:@default_options) 11 | end 12 | 13 | let :options do 14 | {} 15 | end 16 | 17 | it 'assigns a :namespace to @default_options' do 18 | expect(default_options[:namespace]).to eq('rack:session') 19 | end 20 | 21 | describe 'when initializing with the redis sub-hash options' do 22 | let :options do 23 | { 24 | key: random_string, 25 | secret: random_string, 26 | redis: { 27 | host: 'hosty.local', 28 | port: 16_379, 29 | db: 2, 30 | key_prefix: 'myapp:session:', 31 | expire_after: 60 * 120 32 | } 33 | } 34 | end 35 | 36 | it 'creates a redis instance' do 37 | expect(store.instance_variable_get(:@redis)).not_to be_nil 38 | end 39 | 40 | it 'assigns the :host option to @default_options' do 41 | expect(default_options[:host]).to eq('hosty.local') 42 | end 43 | 44 | it 'assigns the :port option to @default_options' do 45 | expect(default_options[:port]).to eq(16_379) 46 | end 47 | 48 | it 'assigns the :db option to @default_options' do 49 | expect(default_options[:db]).to eq(2) 50 | end 51 | 52 | it 'assigns the :key_prefix option to @default_options' do 53 | expect(default_options[:key_prefix]).to eq('myapp:session:') 54 | end 55 | 56 | it 'assigns the :expire_after option to @default_options' do 57 | expect(default_options[:expire_after]).to eq(60 * 120) 58 | end 59 | end 60 | 61 | describe 'when configured with both :ttl and :expire_after' do 62 | let(:ttl_seconds) { 60 * 120 } 63 | let :options do 64 | { 65 | key: random_string, 66 | secret: random_string, 67 | redis: { 68 | host: 'hosty.local', 69 | port: 16_379, 70 | db: 2, 71 | key_prefix: 'myapp:session:', 72 | ttl: ttl_seconds, 73 | expire_after: nil 74 | } 75 | } 76 | end 77 | 78 | it 'assigns the :ttl option to @default_options' do 79 | expect(default_options[:ttl]).to eq(ttl_seconds) 80 | expect(default_options[:expire_after]).to be_nil 81 | end 82 | end 83 | 84 | describe 'when initializing with top-level redis options' do 85 | let :options do 86 | { 87 | key: random_string, 88 | secret: random_string, 89 | host: 'hostersons.local', 90 | port: 26_379, 91 | db: 4, 92 | key_prefix: 'appydoo:session:', 93 | expire_after: 60 * 60 94 | } 95 | end 96 | 97 | it 'creates a redis instance' do 98 | expect(store.instance_variable_get(:@redis)).not_to be_nil 99 | end 100 | 101 | it 'assigns the :host option to @default_options' do 102 | expect(default_options[:host]).to eq('hostersons.local') 103 | end 104 | 105 | it 'assigns the :port option to @default_options' do 106 | expect(default_options[:port]).to eq(26_379) 107 | end 108 | 109 | it 'assigns the :db option to @default_options' do 110 | expect(default_options[:db]).to eq(4) 111 | end 112 | 113 | it 'assigns the :key_prefix option to @default_options' do 114 | expect(default_options[:key_prefix]).to eq('appydoo:session:') 115 | end 116 | 117 | it 'assigns the :expire_after option to @default_options' do 118 | expect(default_options[:expire_after]).to eq(60 * 60) 119 | end 120 | end 121 | 122 | describe 'when initializing with existing redis object' do 123 | let :options do 124 | { 125 | key: random_string, 126 | secret: random_string, 127 | redis: { 128 | client: redis_client, 129 | key_prefix: 'myapp:session:', 130 | expire_after: 60 * 30 131 | } 132 | } 133 | end 134 | 135 | let(:redis_client) { double('redis_client') } 136 | 137 | it 'assigns given redis object to @redis' do 138 | expect(store.instance_variable_get(:@redis)).to be(redis_client) 139 | end 140 | 141 | it 'assigns the :client option to @default_options' do 142 | expect(default_options[:client]).to be(redis_client) 143 | end 144 | 145 | it 'assigns the :key_prefix option to @default_options' do 146 | expect(default_options[:key_prefix]).to eq('myapp:session:') 147 | end 148 | 149 | it 'assigns the :expire_after option to @default_options' do 150 | expect(default_options[:expire_after]).to eq(60 * 30) 151 | end 152 | end 153 | 154 | describe 'rack 1.45 compatibility' do 155 | # Rack 1.45 (which Rails 3.2.x depends on) uses the return value of 156 | # set_session to set the cookie value. See: 157 | # https://github.com/rack/rack/blob/1.4.5/lib/rack/session/abstract/id.rb 158 | 159 | let(:env) { double('env') } 160 | let(:session_id) { Rack::Session::SessionId.new('12 345') } 161 | let(:session_data) { double('session_data') } 162 | let(:options) { { expire_after: 123 } } 163 | 164 | context 'when successfully persisting the session' do 165 | it 'returns the session id' do 166 | expect(store.send(:set_session, env, session_id, session_data, options)) 167 | .to eq(session_id) 168 | end 169 | end 170 | 171 | context 'when unsuccessfully persisting the session' do 172 | before do 173 | allow(store).to receive(:redis).and_raise(Redis::CannotConnectError) 174 | end 175 | 176 | it 'returns false' do 177 | expect(store.send(:set_session, env, session_id, session_data, options)) 178 | .to eq(false) 179 | end 180 | end 181 | 182 | context 'when no expire_after option is given' do 183 | let(:options) { {} } 184 | 185 | it 'sets the session value without expiry' do 186 | expect(store.send(:set_session, env, session_id, session_data, options)) 187 | .to eq(session_id) 188 | end 189 | end 190 | 191 | context 'when redis is down' do 192 | before do 193 | allow(store).to receive(:redis).and_raise(Redis::CannotConnectError) 194 | store.on_redis_down = ->(*_a) { @redis_down_handled = true } 195 | end 196 | 197 | it 'returns false' do 198 | expect(store.send(:set_session, env, session_id, session_data, options)) 199 | .to eq(false) 200 | end 201 | 202 | it 'calls the on_redis_down handler' do 203 | store.send(:set_session, env, session_id, session_data, options) 204 | expect(@redis_down_handled).to eq(true) 205 | end 206 | 207 | context 'when :on_redis_down re-raises' do 208 | before { store.on_redis_down = ->(e, *) { raise e } } 209 | 210 | it 'explodes' do 211 | expect do 212 | store.send(:set_session, env, session_id, session_data, options) 213 | end.to raise_error(Redis::CannotConnectError) 214 | end 215 | end 216 | end 217 | end 218 | 219 | describe 'checking for session existence' do 220 | let(:public_id) { 'foo' } 221 | let(:session_id) { Rack::Session::SessionId.new(public_id) } 222 | 223 | before do 224 | allow(store).to receive(:current_session_id) 225 | .with(:env).and_return(session_id) 226 | end 227 | 228 | context 'when session id is not provided' do 229 | context 'when session id is nil' do 230 | let(:session_id) { nil } 231 | 232 | it 'returns false' do 233 | expect(store.send(:session_exists?, :env)).to eq(false) 234 | end 235 | end 236 | 237 | context 'when session id is empty string' do 238 | let(:public_id) { '' } 239 | 240 | it 'returns false' do 241 | expect(store.send(:session_exists?, :env)).to eq(false) 242 | end 243 | end 244 | end 245 | 246 | context 'when session id is provided' do 247 | let(:redis) do 248 | double('redis').tap do |o| 249 | allow(store).to receive(:redis).and_return(o) 250 | end 251 | end 252 | 253 | context 'when session private id does not exist in redis' do 254 | context 'when session public id does not exist in redis' do 255 | it 'returns false' do 256 | expect(redis).to receive(:exists) 257 | .with(session_id.private_id) 258 | .and_return(false) 259 | expect(redis).to receive(:exists).with('foo').and_return(false) 260 | expect(store.send(:session_exists?, :env)).to eq(false) 261 | end 262 | end 263 | 264 | context 'when session public id exists in redis' do 265 | it 'returns true' do 266 | expect(redis).to receive(:exists) 267 | .with(session_id.private_id) 268 | .and_return(false) 269 | expect(redis).to receive(:exists).with('foo').and_return(true) 270 | expect(store.send(:session_exists?, :env)).to eq(true) 271 | end 272 | end 273 | end 274 | 275 | context 'when session private id exists in redis' do 276 | it 'returns true' do 277 | expect(redis).to receive(:exists) 278 | .with(session_id.private_id) 279 | .and_return(true) 280 | expect(store.send(:session_exists?, :env)).to eq(true) 281 | end 282 | end 283 | 284 | context 'when session public id is formatted like a private id' do 285 | let(:public_id) { Rack::Session::SessionId.new('foo').private_id } 286 | 287 | it 'returns false' do 288 | expect(redis).not_to receive(:exists) 289 | expect(store.send(:session_exists?, :env)).to eq(false) 290 | end 291 | end 292 | 293 | context 'when redis is down' do 294 | it 'returns true (fallback to old behavior)' do 295 | allow(store).to receive(:redis).and_raise(Redis::CannotConnectError) 296 | expect(store.send(:session_exists?, :env)).to eq(true) 297 | end 298 | end 299 | end 300 | end 301 | 302 | describe 'fetching a session' do 303 | let :options do 304 | { 305 | key_prefix: 'customprefix::' 306 | } 307 | end 308 | 309 | let(:fake_key) { 'thisisarediskey' } 310 | let(:session_id) { Rack::Session::SessionId.new(fake_key) } 311 | 312 | describe 'generate_sid' do 313 | it 'generates a secure ID' do 314 | sid = store.send(:generate_sid) 315 | expect(sid).to be_a(Rack::Session::SessionId) 316 | end 317 | end 318 | 319 | context 'when redis is up' do 320 | let(:redis) { double('redis') } 321 | let(:private_exists) { true } 322 | 323 | before do 324 | allow(store).to receive(:redis).and_return(redis) 325 | allow(redis).to receive(:exists) 326 | .with("#{options[:key_prefix]}#{session_id.private_id}") 327 | .and_return(private_exists) 328 | end 329 | 330 | context 'when session private id exists in redis' do 331 | it 'retrieves the prefixed private id from redis' do 332 | expect(redis).to receive(:get).with("#{options[:key_prefix]}#{session_id.private_id}") 333 | 334 | store.send(:get_session, double('env'), session_id) 335 | end 336 | end 337 | 338 | context 'when session private id not found in redis' do 339 | let(:private_exists) { false } 340 | 341 | it 'retrieves the prefixed public id from redis' do 342 | expect(redis).to receive(:get).with("#{options[:key_prefix]}#{fake_key}") 343 | 344 | store.send(:get_session, double('env'), session_id) 345 | end 346 | end 347 | 348 | context 'when session id is formatted like a private id' do 349 | let(:fake_key) { Rack::Session::SessionId.new('anykey').private_id } 350 | let(:new_sid) { Rack::Session::SessionId.new('newid') } 351 | 352 | before do 353 | allow(store).to receive(:generate_sid).and_return(new_sid) 354 | end 355 | 356 | it 'returns a default new session' do 357 | expect(redis).not_to receive(:exists) 358 | expect(redis).not_to receive(:get) 359 | expect(store.send(:get_session, double('env'), session_id)) 360 | .to eq([new_sid, {}]) 361 | end 362 | end 363 | end 364 | 365 | context 'when redis is down' do 366 | before do 367 | allow(store).to receive(:redis).and_raise(Redis::CannotConnectError) 368 | allow(store).to receive(:generate_sid).and_return('foop') 369 | end 370 | 371 | it 'returns an empty session hash' do 372 | expect(store.send(:get_session, double('env'), session_id).last) 373 | .to eq({}) 374 | end 375 | 376 | it 'returns a newly generated sid' do 377 | expect(store.send(:get_session, double('env'), session_id).first) 378 | .to eq('foop') 379 | end 380 | 381 | context 'when :on_redis_down re-raises' do 382 | before { store.on_redis_down = ->(e, *) { raise e } } 383 | 384 | it 'explodes' do 385 | expect do 386 | store.send(:get_session, double('env'), session_id) 387 | end.to raise_error(Redis::CannotConnectError) 388 | end 389 | end 390 | end 391 | end 392 | 393 | describe 'destroying a session' do 394 | context 'when the key is in the cookie hash' do 395 | let(:env) { { 'rack.request.cookie_hash' => cookie_hash } } 396 | let(:cookie_hash) { double('cookie hash') } 397 | let(:fake_key) { 'thisisarediskey' } 398 | let(:session_id) { Rack::Session::SessionId.new(fake_key) } 399 | 400 | before do 401 | allow(cookie_hash).to receive(:[]).and_return(fake_key) 402 | end 403 | 404 | it 'deletes the prefixed key from redis' do 405 | redis = double('redis') 406 | allow(store).to receive(:redis).and_return(redis) 407 | expect(redis).to receive(:del) 408 | .with("#{options[:key_prefix]}#{fake_key}") 409 | expect(redis).to receive(:del) 410 | .with("#{options[:key_prefix]}#{session_id.private_id}") 411 | 412 | store.send(:destroy, env) 413 | end 414 | 415 | context 'when redis is down' do 416 | before do 417 | allow(store).to receive(:redis).and_raise(Redis::CannotConnectError) 418 | end 419 | 420 | it 'returns false' do 421 | expect(store.send(:destroy, env)).to eq(false) 422 | end 423 | 424 | context 'when :on_redis_down re-raises' do 425 | before { store.on_redis_down = ->(e, *) { raise e } } 426 | 427 | it 'explodes' do 428 | expect do 429 | store.send(:destroy, env) 430 | end.to raise_error(Redis::CannotConnectError) 431 | end 432 | end 433 | end 434 | end 435 | 436 | context 'when destroyed via #destroy_session' do 437 | it 'deletes the prefixed key from redis' do 438 | redis = double('redis', setnx: true) 439 | allow(store).to receive(:redis).and_return(redis) 440 | sid = store.send(:generate_sid) 441 | expect(redis).to receive(:del).with("#{options[:key_prefix]}#{sid.public_id}") 442 | expect(redis).to receive(:del).with("#{options[:key_prefix]}#{sid.private_id}") 443 | 444 | store.send(:destroy_session, {}, sid, nil) 445 | end 446 | end 447 | end 448 | 449 | describe 'session encoding' do 450 | let(:env) { double('env') } 451 | let(:session_id) { Rack::Session::SessionId.new('12 345') } 452 | let(:session_data) { { 'some' => 'data' } } 453 | let(:options) { {} } 454 | let(:encoded_data) { Marshal.dump(session_data) } 455 | let(:redis) { double('redis', set: nil, get: encoded_data) } 456 | let(:expected_encoding) { encoded_data } 457 | 458 | before do 459 | allow(store).to receive(:redis).and_return(redis) 460 | end 461 | 462 | shared_examples_for 'serializer' do 463 | it 'encodes correctly' do 464 | expect(redis).to receive(:set).with(session_id.private_id, expected_encoding) 465 | store.send(:set_session, env, session_id, session_data, options) 466 | end 467 | 468 | it 'decodes correctly' do 469 | allow(redis).to receive(:exists).with(session_id.private_id).and_return(true) 470 | expect(store.send(:get_session, env, session_id)) 471 | .to eq([session_id, session_data]) 472 | end 473 | end 474 | 475 | context 'marshal' do 476 | let(:options) { { serializer: :marshal } } 477 | 478 | it_behaves_like 'serializer' 479 | end 480 | 481 | context 'json' do 482 | let(:options) { { serializer: :json } } 483 | let(:encoded_data) { '{"some":"data"}' } 484 | 485 | it_behaves_like 'serializer' 486 | end 487 | 488 | context 'hybrid' do 489 | let(:options) { { serializer: :hybrid } } 490 | let(:expected_encoding) { '{"some":"data"}' } 491 | 492 | context 'marshal encoded data' do 493 | it_behaves_like 'serializer' 494 | end 495 | 496 | context 'json encoded data' do 497 | let(:encoded_data) { '{"some":"data"}' } 498 | 499 | it_behaves_like 'serializer' 500 | end 501 | end 502 | 503 | context 'custom' do 504 | let :custom_serializer do 505 | Class.new do 506 | def self.load(_value) 507 | { 'some' => 'data' } 508 | end 509 | 510 | def self.dump(_value) 511 | 'somedata' 512 | end 513 | end 514 | end 515 | 516 | let(:options) { { serializer: custom_serializer } } 517 | let(:expected_encoding) { 'somedata' } 518 | 519 | it_behaves_like 'serializer' 520 | end 521 | end 522 | 523 | describe 'handling decode errors' do 524 | context 'when a class is serialized that does not exist' do 525 | before do 526 | allow(store).to receive(:redis) 527 | .and_return(double('redis', 528 | get: "\x04\bo:\nNonExistentClass\x00", 529 | del: true)) 530 | end 531 | 532 | it 'returns an empty session' do 533 | expect(store.send(:load_session_from_redis, 'whatever')).to be_nil 534 | end 535 | 536 | it 'destroys and drops the session' do 537 | expect(store).to receive(:destroy_session_from_sid) 538 | .with('wut', drop: true) 539 | store.send(:load_session_from_redis, 'wut') 540 | end 541 | 542 | context 'when a custom on_session_load_error handler is provided' do 543 | before do 544 | store.on_session_load_error = lambda do |e, sid| 545 | @e = e 546 | @sid = sid 547 | end 548 | end 549 | 550 | it 'passes the error and the sid to the handler' do 551 | store.send(:load_session_from_redis, 'foo') 552 | expect(@e).to be_kind_of(StandardError) 553 | expect(@sid).to eq('foo') 554 | end 555 | end 556 | end 557 | 558 | context 'when the encoded data is invalid' do 559 | before do 560 | allow(store).to receive(:redis) 561 | .and_return(double('redis', get: "\x00\x00\x00\x00", del: true)) 562 | end 563 | 564 | it 'returns an empty session' do 565 | expect(store.send(:load_session_from_redis, 'bar')).to be_nil 566 | end 567 | 568 | it 'destroys and drops the session' do 569 | expect(store).to receive(:destroy_session_from_sid) 570 | .with('wut', drop: true) 571 | store.send(:load_session_from_redis, 'wut') 572 | end 573 | 574 | context 'when a custom on_session_load_error handler is provided' do 575 | before do 576 | store.on_session_load_error = lambda do |e, sid| 577 | @e = e 578 | @sid = sid 579 | end 580 | end 581 | 582 | it 'passes the error and the sid to the handler' do 583 | store.send(:load_session_from_redis, 'foo') 584 | expect(@e).to be_kind_of(StandardError) 585 | expect(@sid).to eq('foo') 586 | end 587 | end 588 | end 589 | end 590 | 591 | describe 'validating custom handlers' do 592 | %w(on_redis_down on_session_load_error).each do |h| 593 | context 'when nil' do 594 | it 'does not explode at init' do 595 | expect { store }.not_to raise_error 596 | end 597 | end 598 | 599 | context 'when callable' do 600 | let(:options) { { "#{h}": ->(*) { true } } } 601 | 602 | it 'does not explode at init' do 603 | expect { store }.not_to raise_error 604 | end 605 | end 606 | 607 | context 'when not callable' do 608 | let(:options) { { "#{h}": 'herpderp' } } 609 | 610 | it 'explodes at init' do 611 | expect { store }.to raise_error(ArgumentError) 612 | end 613 | end 614 | end 615 | end 616 | 617 | describe 'setting the session' do 618 | it 'allows changing the session' do 619 | env = { 'rack.session.options' => {} } 620 | sid = Rack::Session::SessionId.new('1234') 621 | allow(store).to receive(:redis).and_return(Redis.new) 622 | data1 = { 'foo' => 'bar' } 623 | store.send(:set_session, env, sid, data1) 624 | data2 = { 'baz' => 'wat' } 625 | store.send(:set_session, env, sid, data2) 626 | _, session = store.send(:get_session, env, sid) 627 | expect(session).to eq(data2) 628 | end 629 | 630 | it 'allows changing the session when the session has an expiry' do 631 | env = { 'rack.session.options' => { expire_after: 60 } } 632 | sid = Rack::Session::SessionId.new('1234') 633 | allow(store).to receive(:redis).and_return(Redis.new) 634 | data1 = { 'foo' => 'bar' } 635 | store.send(:set_session, env, sid, data1) 636 | data2 = { 'baz' => 'wat' } 637 | store.send(:set_session, env, sid, data2) 638 | _, session = store.send(:get_session, env, sid) 639 | expect(session).to eq(data2) 640 | end 641 | end 642 | end 643 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | require_relative 'support' 3 | require 'fakeredis/rspec' 4 | require 'redis-session-store' 5 | -------------------------------------------------------------------------------- /spec/support.rb: -------------------------------------------------------------------------------- 1 | unless defined?(Rack::Session::Abstract::ENV_SESSION_OPTIONS_KEY) 2 | module Rack 3 | module Session 4 | module Abstract 5 | ENV_SESSION_OPTIONS_KEY = 'rack.session.options'.freeze 6 | end 7 | end 8 | end 9 | end 10 | unless defined?(Rack::Session::SessionId) 11 | module Rack 12 | module Session 13 | class SessionId 14 | ID_VERSION = 2 15 | 16 | attr_reader :public_id 17 | 18 | def initialize(public_id) 19 | @public_id = public_id 20 | end 21 | 22 | alias to_s public_id 23 | 24 | def empty? 25 | false 26 | end 27 | 28 | def inspect 29 | public_id.inspect 30 | end 31 | 32 | def private_id 33 | "#{ID_VERSION}::#{hash_sid(public_id)}" 34 | end 35 | 36 | private 37 | 38 | def hash_sid(value) 39 | "test_hash_from:#{value}" 40 | end 41 | end 42 | end 43 | end 44 | end 45 | 46 | unless defined?(ActionDispatch::Session::AbstractSecureStore) 47 | module ActionDispatch 48 | module Session 49 | class AbstractSecureStore 50 | ENV_SESSION_OPTIONS_KEY = 'rack.session.options'.freeze 51 | DEFAULT_OPTIONS = { 52 | key: '_session_id', 53 | path: '/', 54 | domain: nil, 55 | expire_after: nil, 56 | secure: false, 57 | httponly: true, 58 | cookie_only: true 59 | }.freeze 60 | 61 | def initialize(app, options = {}) 62 | @app = app 63 | @default_options = DEFAULT_OPTIONS.dup.merge(options) 64 | @key = @default_options[:key] 65 | @cookie_only = @default_options[:cookie_only] 66 | end 67 | 68 | private 69 | 70 | def generate_sid 71 | Rack::Session::SessionId.new(rand(999..9999).to_s(16)) 72 | end 73 | end 74 | end 75 | end 76 | end 77 | 78 | unless defined?(Rails) 79 | require 'logger' 80 | 81 | module Rails 82 | def self.logger 83 | @logger ||= Logger.new('/dev/null') 84 | end 85 | end 86 | end 87 | --------------------------------------------------------------------------------